一、贪心
贪心算法解决问题的步骤
- 针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。
- 每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据。
- 举几个例子看下贪心算法产生的结果是否是最优的。
贪心算法主要的缺陷是只在当前的范围内考虑最优解。因此对于那种“前面的选择,会影响后面的选择” 的情况就会出现问题
问题
分糖果:我们有 m 个糖果和 n 个孩子。糖果少,孩子多(m<n),每个糖果的大小不等,这 m 个糖果的大小分别是 s1,s2,s3,……,sm。除此之外,每个孩子对糖果大小的需求也是不一样的,只有糖果的大小大于等于孩子的对糖果大小的需求的时候,孩子才得到满足。假设这 n 个孩子对糖果大小的需求分别是 g1,g2,g3,……,gn。如何分配糖果,能尽可能满足最多数量的孩子?
我们每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案。
钱币找零:假设我们有 1 元、2 元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,它们的张数分别是 c1、c2、c5、c10、c20、c50、c100。我们现在要用这些钱来支付 K 元,最少要用多少张纸币呢?
在贡献相同期望值(纸币数目)的情况下,我们希望多贡献点金额,这样就可以让纸币数更少,
找零问题不能用贪婪算法,即使有面值为一元的币值也不行:考虑币值为100,99和1的币种,每种各一百张,找396元。 动态规划可求出四张99元,但贪心算法解出需三张一百和96张一元。
区间覆盖:假设我们有 n 个区间,区间的起始端点和结束端点分别是[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我们从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢?
我们假设这 n 个区间中最左端点是 lmin,最右端点是 rmax。这个问题就相当于,我们选择几个不相交的区间,从左到右将[lmin, rmax]覆盖上。我们按照起始端点从小到大的顺序对这 n 个区间排序。
我们每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间。这实际上就是一种贪心的选择方法。
霍夫曼编码
二、分治
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分治算法一般都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作:
- 分解:将原问题分解成一系列子问题;
- 解决:递归地求解各个子问题,若子问题足够小,则直接求解;
- 合并:将子问题的结果合并成原问题。
分治算法能解决的问题,一般需要满足下面这几个条件:
- 原问题与分解成的小问题具有相同的模式;
- 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别,等我们讲到动态规划的时候,会详细对比这两种算法;
- 具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
- 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
问题1: 如何编程求出一组数据的有序对个数或者逆序对个数呢?
比如:[ 2 4 3 1 5 6 ] 这一组数据有4组逆序对: (2, 1) (4, 3) (4, 1) (3, 1)
我们套用分治的思想来求数组 A 的逆序对个数。我们可以将数组分成前后两半 A1 和 A2,分别计算 A1 和 A2 的逆序对个数 K1 和 K2,然后再计算 A1 与 A2 之间的逆序对个数 K3。那数组 A 的逆序对个数就等于 K1+K2+K3。其实就是归并排序的思路
1 |
|
其他问题
- 二维平面上有 n 个点,如何快速计算出两个距离最近的点对?
- 有两个 nn 的矩阵 A,B,如何快速求解两个矩阵的乘积 C=AB?
三、回溯
- 应用1:八皇后问题
1 | std::vector<int> chessboard(8, -1); |
- 应用2:0-1 背包问题:我们有一个背包,背包总的承载重量是 Wkg。现在我们有 n 个物品,每个物品的重量不等,并且不可分割。我们现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
对于每个物品来说,都有两种选择,装进背包或者不装进背包。对于 n 个物品来说,总的装法就有 2^n 种,去掉总重量超过 Wkg 的,从剩下的装法中选择总重量最接近 Wkg 的。
还用到搜索剪枝的技巧,就是当发现已经选择的物品的重量超过 Wkg 之后,我们就停止继续探测剩下的物品。
1 | class Knapsack { |
应用3:正则表达式。假设正则表达式中只包含
“*”
和“?”
这两种通配符,并且对这两个通配符的语义稍微做些改变,其中,“*”
匹配任意多个(大于等于 0 个)任意字符,“?”
匹配零个或者一个任意字符。基于以上背景假设,我们看下,如何用回溯算法,判断一个给定的文本,能否跟给定的正则表达式匹配?我们依次考察正则表达式中的每个字符,当是非通配符时,我们就直接跟文本的字符进行匹配,如果相同,则继续往下处理;如果不同,则回溯。如果遇到特殊字符的时候,我们就有多种处理方式了,也就是所谓的岔路口,比如
“*”
有多种匹配方案,可以匹配任意个文本串中的字符,我们就先随意的选择一种匹配方案,然后继续考察剩下的字符。如果中途发现无法继续匹配下去了,我们就回到这个岔路口,重新选择一种匹配方案,然后再继续匹配剩下的字符。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36class Pattern {
private:
bool matched = false;
std::string pattern;
int pLen;
public:
explicit Pattern(const std::string& pattern) : pattern(pattern), pLen(pattern.size()) {}
bool match(const std::string& text) {
rematch(0, 0, text, text.size());
return matched;
}
void rematch(int ti, int pi, const std::string& text, int tLen) {
if (matched) return;
if (pi == pLen) {
if (ti == tLen) matched = true;
return;
}
if (pattern[pi] == '*') {
// * 匹配符
for (int k = 0; k < tLen-ti; k++) {
rematch(ti+k, pi+1, text, tLen);
}
} else if(pattern[pi] == '?') {
// ? 匹配符
rematch(ti, pi+1, text, tLen);
rematch(ti+1, pi+1, text, tLen);
} else {
// 直接匹配
if (ti < tLen && text[ti] == pattern[pi]) {
rematch(ti+1, pi+1, text, tLen);
}
}
}
};