贪心算法有很多经典的应用,比如:
物品 | 总量(KG) | 总价值(元) |
---|---|---|
黄豆 | 100 | 100 |
绿豆 | 30 | 90 |
红豆 | 60 | 120 |
黑豆 | 20 | 80 |
青豆 | 50 | 75 |
这个问题的解决思路本质上就是贪心算法。
第一步,当看到这个问题的时候,首先要联想到贪心算法:针对一组数据(5种豆子),定义了限制值(100kg)和期望值(总价值),希望从中选出几个数据,在满足限制值的情况下,期望值最大。
第二步,尝试下这个问题是否可以用贪心算法解决:每次选择当前情况下,在对限制值同等贡献量(重量相同)的情况下,对期望值(总价值)贡献最大的数据。
第三步,举几个例子看贪心算法产生的结果是否是最优。大部分情况下,举几个例子验证一下就可以了。严格地证明贪心算法的正确性,是非常复杂的,需要涉及比较多的数学推理。而且,从实践的角度来说,大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明。
实际上,用贪心算法解决问题的思路,并不总能给出最优解。
在一个有权图中,从顶点 S 开始,找一条到顶点 T 的最短路径(路径中边的权值和最小)。
贪心算法的解决思路是,每次都选择一条跟当前顶点相连的权最小的边,直到找到顶点 T。
按照这种思路,求出的最短路径是 S->A->E->T
,路径长度是 1+4+4=9。
但是,这种贪心的选择方式,最终求的路径并不是最短路径,因为路径 S->B->D->T
才是最短路径,路径的长度是 2+2+2=6。
在这个问题上,贪心算法不工作的主要原因是,前面的选择,会影响后面的选择。如果第一步从顶点 S 走到顶点 A,那接下来面对的顶点和边,跟第一步从顶点 S 走到顶点 B,是完全不同的。所以,即便第一步选择最优的走法(边最短),但有可能因为这一步选择,导致后面每一步的选择都很糟糕,最终也就无缘全局最优解了。
有 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 元,最少要用多少张纸币呢?
把这个问题抽象成:
用贪心算法来解决。在贡献相同期望值的情况下,希望多贡献金额。
假设有 n 个区间,区间的起始端点和结束端点分别是
[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]
。从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢?
把这个问题抽象成:
用贪心算法来解决。
霍夫曼编码,利用贪心算法来实现对数据压缩编码,有效节省数据存储空间。
假设有一个包含 1000 个字符的文件,每个字符占 1 个 byte(1byte=8bits),存储这 1000 个字符就一共需要 8000bits,那有没有更加节省空间的存储方式呢?
a(000)、b(001)、c(010)、d(011)、e(100)、f(101)
还有更加节省空间的存储方式,霍夫曼编码,一种十分有效的编码方法,广泛用于数据压缩中,其压缩率通常在 20%~90% 之间。
霍夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,根据频率的不同,选择不同长度的编码。
霍夫曼编码试图用这种不等长的编码方法,来进一步增加压缩的效率。
如何给不同频率的字符选择不同长度的编码呢?
把这个问题抽象成:
用贪心算法来解决。
对于等长的编码来说,解压缩起来很简单。比如上面例子中
abcdef
用 3 个 bit 表示一个字符。在解压缩的时候,每次从文本中读取 3 位二进制码,然后翻译成对应的字符。
霍夫曼编码是不等长的,每次应该读取读取的位数无法确定,这个问题就导致霍夫曼编码解压缩起来比较复杂。为了避免解压缩过程中的歧义,霍夫曼编码要求各个字符的编码之间,不会出现某个编码是另一个编码前缀的情况。
经过这种编码压缩之后,这 1000 个字符只需要 2100bits 就可以了。
字符 | 出现频率 | 编码 | 总二进制位数 |
---|---|---|---|
a | 450 | 1 | 450 |
b | 350 | 01 | 700 |
c | 90 | 001 | 270 |
d | 60 | 0001 | 150 |
f | 20 | 00000 | 100 |
霍夫曼编码中,如何根据字符出现频率的不同,给不同的字符进行不同长度的编码,这里的处理稍微有些技巧。
给每一条边加上一个权值:
从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。
实际上,贪心算法适用的场景比较有限。这种算法思想更多的是指导设计基础算法。比如最小生成树算法、单源最短路径算法,这些算法都用到了贪心算法。
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之,将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分治算法一般都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作:
分治算法能解决的问题,一般需要满足下面这几个条件:
使用分治算法解决排序问题。在排序算法中,用有序度来表示一组数据的有序程度,用逆序度表示一组数据的无序程度。
假设有 n 个数据,期望数据从小到大排列,那完全有序的数据的有序度就是 n(n-1)/2
,逆序度等于 0;相反,倒序排列的数据的有序度就是 0,逆序度是 n(n-1)/2
。除了这两种极端情况外,通过计算有序对或者逆序对的个数,来表示数据的有序度或逆序度,如何编程求出一组数据的有序对个数或者逆序对个数。
k1+k2+k3
。分治算法的一个要求是,子问题合并的代价不能太大,否则就起不了降低时间复杂度的效果了。
解法二中如何快速计算出两个子问题 A1 与 A2 之间的逆序对个数呢?可以使用归并排序。归并排序中非常关键的操作,就是将两个有序的小数组,合并成一个有序的数组。实际上,在这个合并的过程中,就可以计算这两个小数组的逆序对个数了。每次合并操作,都计算逆序对个数,把这些计算出来的逆序对个数求和,就是这个数组的逆序对个数了。
分治算法思想的应用是非常广泛的:
大部分数据结构和算法都是基于内存存储和单机处理。
但是,如果要处理的数据量非常大,没法一次性放到内存中,这个时候,这些数据结构和算法就无法工作了。
比如,给 10GB 的订单文件按照金额排序这样一个需求,看似是一个简单的排序问题,但是因为数据量大,有 10GB,而单机的内存可能只有 2、3GB,无法一次性加载到内存,也就无法通过单纯地使用快排、归并等基础算法来解决了。
要解决数据量大到内存装不下的问题,利用分治的思想。
实际上,利用这种分治的处理思路,不仅仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。
上面的例子,先扫描一遍订单,根据订单的金额,将 10GB 的文件划分为几个金额区间。
每个小文件都可以单独加载到内存排序,最后将这些有序的小文件合并,就是最终有序的 10GB 订单数据了。
如果订单数据存储在类似 GFS 这样的分布式系统上,当 10GB 的订单被划分成多个小文件的时候,每个文件可以并行加载到多台机器上处理,最后再将结果合并在一起,这样并行处理的速度也加快了很多。但是,注意:数据的存储与计算所在的机器是同一个或者在网络中靠的很近(比如一个局域网内,数据存取速度很快),否则就会因为数据访问的速度,导致整个处理过程不但不会变快,反而有可能变慢。
实际上:
它从 GFS 中拿数据,交给 Borg 中的机器执行,并且时刻监控机器执行的进度,一旦出现机器宕机、进度卡壳等,就重新从 Borg 中调度一台机器执行。
回溯算法在软件开发场景中的应用:
回溯算法在数学中的应用:
笼统地讲,回溯算法很多时候都应用在“搜索”这类问题上。这里的搜索,并不是狭义的指图的搜索算法,而是广义上的在一组可能的解中,搜索满足期望的解。
回溯的处理思想,有点类似枚举搜索。枚举所有的解,找到满足期望的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。
为了有规律地枚举所有可能的解,避免遗漏和重复,把问题求解的过程分为多个阶段。每个阶段,都会面对一个岔路口,先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
举一个经典的回溯例子,八皇后问题。有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。
第一幅图是满足条件的一种方法,第二幅图是不满足条件的。八皇后问题就是期望找到所有满足这种要求的放棋子方式。
回溯算法非常适合用递归代码实现。
0-1 背包是非常经典的算法问题,很多场景都可以抽象成这个问题模型。
这个问题的经典解法是动态规划,另一种简单但没有那么高效的解法就是回溯算法。
有一个背包,背包总的承载重量是 Wkg。有 n 个物品,每个物品的重量不等,并且不可分割。现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
显然,这个问题已经无法通过贪心算法来解决了。
用回溯算法如何来解决。
如何才能不重复地穷举出这 \(2^n\) 种装法呢?
这里还稍微用到了一点搜索剪枝的技巧,就是当发现已经选择的物品的重量超过 Wkg 之后,就停止继续探测剩下的物品。
正则表达式里最重要的一种算法思想就是回溯。
正则表达式中,最重要的就是通配符,通配符结合在一起,可以表达非常丰富的语义。
假设正则表达式中只包含“*
”和“?
”这两种通配符,并且对这两个通配符的语义稍微做些改变,其中:
*
”:匹配任意多个(大于等于 0 个)任意字符?
”:匹配零个或者一个任意字符用回溯算法,判断一个给定的文本,能否跟给定的正则表达式匹配?
*
”有多种匹配方案,可以匹配任意个文本串中的字符,就先随意的选择一种匹配方案,然后继续考察剩下的字符动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。
对于一组不同重量、不可分割的物品,选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?
使用回溯的解决方法,穷举搜索所有可能的装法,然后找出满足条件的最大值。但是,回溯算法的复杂度比较高,是指数级别的。
寻找规律从而有效降低时间复杂度。
使用回溯求解过程,用递归树画出来,就是下面这个样子:
递归树中的每个节点表示一种状态,用(i,cw)
来表示。其中:
比如,(2,2)表示将要决策第 2 个物品是否装入背包,在决策前,背包中物品的总重量是 2。
从递归树中可以看出,有些子问题的求解是重复的,比如图中 f(2, 2)
和 f(3,4)
都被重复计算了两次。
因此,可以使用缓存的方式来优化,记录已经计算好的 f(i,cw)
,当再次计算到重复的 f(i,cw)
的时候,可以直接从缓存中取出来结果,不用再递归计算了,这样就可以避免冗余计算。
使用缓存的解决方法非常好。它已经跟动态规划的执行效率基本上没有差别。
时间复杂度:\(O(2^n)\)
states[n][w+1]
,来记录每层可以达到的不同状态具体过程:
states[0][0]=true
和 states[0][2]=true
来表示这两种状态0(0+0)
,2(0+2 or 2+0)
,4(2+2)
states[1][0]=true
,states[1][2]=true
,states[1][4]=true
来表示这三种状态把整个计算的过程画了出来,图中 0 表示 false,1 表示 true。只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。
动态规划解决问题的思路:把问题分解为多个阶段,每个阶段对应一个决策。记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。
时间复杂度:O(n×w)
,n 表示物品个数,w 表示背包可以承载的总重量。
尽管动态规划的执行效率比较高,但是需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。所以,动态规划是一种空间换时间的解决思路。
引入物品价值,对于一组不同重量、不同价值、不可分割的物品,选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?
这个问题依旧可以用回溯算法来解决。画出递归树。在递归树中,每个节点表示一个状态,需要 3 个变量(i, cw, cv)来表示一个状态。其中,i 表示即将要决策第 i 个物品是否装入背包,cw 表示当前背包中物品的总重量,cv 表示当前背包中物品的总价值。
在递归树中,有几个节点的 i 和 cw 是完全相同的,比如 f(2,2,4)
和 f(2,2,3)
。在背包中物品总重量一样的情况下,f(2,2,4) 这种状态对应的物品总价值更大,可以舍弃 f(2,2,3) 这种状态,只需要沿着 f(2,2,4) 这条决策路线继续往下决策就可以。也就是说,对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑。
使用动态规划来解决这个问题,把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完之后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。
用一个二维数组 states[n][w+1]
,来记录每层可以达到的不同状态。不过这里数组存储的值是当前状态对应的最大总价值。把每一层中 (i,cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。
w[n][n]
。从 (0,0) 走到 (n-1,n-1),总共要走 2*(n-1) 步,也就对应着 2*(n-1) 个阶段。每个阶段都有向右走或者向下走两种决策,并且每个阶段都会对应一个状态集合。把状态定义为 min_dist(i, j),其中 i 表示行,j 表示列。min_dist 表达式的值表示从 (0,0) 到达 (i,j) 的最短路径长度。这个问题是一个多阶段决策最优解问题,符合动态规划的模型。
把从起始位置 (0,0) 到 (i,j) 的最小路径,记作 min_dist(i, j)。因为只能往右或往下移动,所以,只有可能从 (i,j-1) 或者 (i-1,j) 两个位置到达 (i,j)。也就是说,到达 (i,j) 的最短路径要么经过 (i,j-1),要么经过 (i-1,j),而且到达 (i,j) 的最短路径肯定包含到达这两个位置的最短路径之一。换句话说就是,min_dist(i, j) 可以通过 min_dist(i,j-1) 和 min_dist(i-1,j) 两个状态推导出来。这就说明,这个问题符合“最优子结构”。
如果走到 (i,j) 这个位置,只能通过 (i-1,j),(i,j-1) 这两个位置移动过来,也就是说,想要计算 (i,j) 位置对应的状态,只需要关心 (i-1,j),(i,j-1) 两个位置对应的状态,并不关心棋子是通过什么样的路线到达这两个位置的。而且,仅允许往下和往右移动,不允许后退,所以,前面阶段的状态确定之后,不会被后面阶段的决策所改变,所以,这个问题符合“无后效性”这一特征。
使用回溯算法来解决这个问题。画出递归树就会发现,递归树中有重复的节点。重复的节点表示,从左上角到节点对应的位置,有多种路线,说明这个问题中存在重复子问题。
min_dist(i, j) = w[i][j] + min(min_dist(i,j-1), min_dist(i-1,j))
回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码。
一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。
先画出一个状态表。状态表一般都是二维的,把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。
尽管大部分状态表都是二维的,但是如果问题的状态比较复杂,需要很多变量来表示,那对应的状态表可能就是高维的,比如三维、四维。那就不适合用状态转移表法来解决了。
如何用这个状态转移表法,来解决上面那个矩阵最短路径的问题。
既然存在重复子问题,就可以尝试用动态规划来解决。
画出一个二维状态表,表中的行、列表示棋子所在的位置,表中的数值表示从起点到这个位置的最短路径。按照决策过程,通过不断状态递推演进,将状态表填好。
找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码。
状态转移方程法有点类似递归的解题思路。
还是以上面的例子为例,状态转移方程min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))
。
这里我强调一下,状态转移方程是解决动态规划的关键。
量化两个字符串的相似度:编辑距离。
编辑距离:将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是 0。
根据所包含的编辑操作种类的不同,编辑距离有多种不同的计算方式,比较著名的有:
两个字符串 mitcmu
和 mtacnu
它们的莱文斯坦距离是 3,最长公共子串长度是 4。
这个问题是求把一个字符串变成另一个字符串,需要的最少编辑次数。整个求解过程,涉及多个决策阶段,依次考察一个字符串中的每个字符,跟另一个字符串中的字符是否匹配,匹配的话如何处理,不匹配的话又如何处理。所以,这个问题符合多阶段决策最优解模型(贪心、回溯、动态规划可以解决的问题,都可以抽象成这样一个模型)。
回溯是一个递归处理的过程:
a[i]
与 b[j]
匹配,递归考察 a[i+1]
和 b[j+1]
a[i]
与 b[j]
不匹配,有多种处理方式可选:a[i]
,然后递归考察 a[i+1]
和 b[j]
b[j]
,然后递归考察 a[i]
和 b[j+1]
a[i]
前面添加一个跟 b[j]
相同的字符,然后递归考察 a[i]
和 b[j+1]
b[j]
前面添加一个跟 a[i]
相同的字符,然后递归考察 a[i+1]
和 b[j]
a[i]
替换成 b[j]
,或者将 b[j]
替换成 a[i]
,然后递归考察 a[i+1]
和 b[j+1]
根据回溯算法的实现画出递归树,看是否存在重复子问题。如果存在重复子问题,就可以考虑能否用动态规划来解决;如果不存在重复子问题,那回溯就是最好的解决方法。
在递归树中,每个节点代表一个状态,状态包含三个变量 (i,j,edist)
,其中,edist 表示处理到 a[i]
和 b[j]
时,已经执行的编辑操作的次数。
在递归树中,(i,j)
两个变量重复的节点很多,比如 (3, 2) 和 (2, 3)。对于 (i,j)
相同的节点,只需要保留 edist 最小的,继续递归处理就可以了,剩下的节点都可以舍弃。
所以,状态就从 (i,j,edist)
变成了 (i,j,min_edist)
,其中,min_edist 表示处理到 a[i]
和 b[j]
,已经执行的最少编辑次数。
这个问题符合动态规划,但是,状态转移方式要复杂很多,状态 (i,j)
可能从 (i-1,j)
,(i,j-1)
,(i-1,j-1)
三个状态中的任意一个转移过来。基于刚刚的分析,把状态转移的过程,用公式写出来,就是状态转移方程。
// 如果:a[i]!=b[j],那么:min_edist(i,j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1, min_edist(i-1,j-1)+1)
// 如果:a[i]==b[j],那么:min_edist(i,j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1,min_edist(i-1,j-1))
// 其中,min表示求三数中的最小值。
了解了状态与状态之间的递推关系,画出一个二维的状态表,按行依次来填充状态表中的每个值。
既有状态转移方程,又理清了完整的填表过程,代码实现就非常简单了。
关于复杂算法问题的解决思路的一些经验和技巧:
- 当拿到一个问题的时候,先不思考,计算机会如何实现这个问题,而是单纯考虑“人脑”会如何去解决这个问题。人脑比较倾向于思考具象化的、摸得着看得见的东西,不适合思考过于抽象的问题。
- 把抽象问题具象化,可以实例化几个测试数据,通过人脑去分析具体实例的解,然后总结规律,再尝试套用学过的算法,看是否能够解决。
- 看到问题,立马想到能否用动态规划解决,然后直接就可以寻找最优子结构,写出动态规划方程,然后将状态转移方程翻译成代码。
最长公共子串作为编辑距离中的一种,只允许增加、删除字符两种编辑操作。从本质上来说,它表征的也是两个字符串之间的相似程度。这个问题的解决思路,跟莱文斯坦距离的解决思路非常相似,也可以用动态规划解决。
每个状态包括三个变量 (i, j, max_lcs)
,max_lcs 表示 a[0…i]
和 b[0…j]
的最长公共子串长度。
回溯的处理思路:
a[0]
和 b[0]
开始,依次考察两个字符串中的字符是否匹配a[i]
与 b[j]
互相匹配,将最大公共子串长度加一,并且继续考察 a[i+1]
和 b[j+1]
a[i]
与 b[j]
不匹配,最长公共子串长度不变,这个时候,有两个不同的决策路线:a[i]
,或者在 b[j]
前面加上一个字符 a[i]
,然后继续考察 a[i+1]
和 b[j]
b[j]
,或者在 a[i]
前面加上一个字符 b[j]
,然后继续考察 a[i]
和 b[j+1]
反过来也就是说,要求 a[0…i]
和 b[0…j]
的最长公共长度 max_lcs(i,j)
,只有可能通过下面三个状态转移过来:
(i-1,j-1,max_lcs)
,其中 max_lcs 表示 a[0…i-1]
和 b[0…j-1]
的最长公共子串长度(i-1,j,max_lcs)
,其中 max_lcs 表示 a[0…i-1]
和 b[0…j]
的最长公共子串长度(i,j-1,max_lcs)
,其中 max_lcs 表示 a[0…i]
和 b[0…j-1]
的最长公共子串长度把这个转移过程,用状态转移方程写出来,就是下面这个样子:
// 如果:a[i]==b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1));
// 如果:a[i]!=b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1), max_lcs(i-1, j), max_lcs(i, j-1));
// 其中max表示求三数中的最大值。