摘要:极客时间《数据结构与算法之美》基础篇笔记,算法思想:贪心算法、分治算法、回溯算法、动态规划
算法思想
贪心算法(greedy algorithm)
贪心算法解决问题的步骤:
1、联想到贪心算法:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大;
2、尝试看下这个问题是否可以用贪心算法解决:每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据;
3、举几个例子看下贪心算法产生的结果是否是最优的
但如果前面的选择,会影响后面的选择,则不适合使用贪心算法
常见实例
1、分糖果
有 m 个大小不一的糖果和 n 个孩子,但m<n
。此外,只有糖果的大小大于等于孩子的对糖果大小的需求时,孩子才会满足。如何分配糖果才能满足最多数量的孩子?
分析:糖果数量少于孩子数量,让满足的孩子的数量(期望值)最多,限制值是糖果的个数
每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果
2、钱币找零
有多张不同价值的纸币,要用这些钱来支付 K 元,最少要用多少张纸币?
分析:在贡献相同期望值(纸币数目)的情况下,希望每张纸币多贡献点金额,这样就可以让纸币数更少
先用面值最大的纸币来支付,如果不够就使用更小一点的面值,以此类推,最后使用 1 元补齐
3、区间覆盖
假设有 n 个区间,从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间?
分析:假设这 n 个区间中最左端点是 lmin,最右端点是 rmax。这个问题就相当于,选择几个不相交的区间,从左到右将 [lmin, rmax] 覆盖上
按照起始端点从小到大的顺序对这 n 个区间排序,每次选择左端点跟前面已经覆盖的区间不重合的,右端点又尽量小的区间,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间
霍夫曼编码(Huffman Coding)
霍夫曼编码是利用贪心算法来实现对数据压缩编码,有效节省数据存储空间:把出现频率比较多的字符,用稍微短一些的编码;出现频率比较少的字符,用稍微长一些的编码。
具体方法:把每个字符看作一个节点,并且附带着把频率放到优先级队列中,频率小的在前面。从队列中取出频率最小的两个节点 A、B,然后新建一个节点 C,把频率设置为两个节点的频率之和,并把这个新节点 C 作为节点 A、B 的父节点。最后再把 C 节点放入到优先级队列中。重复这个过程,直到队列中没有数据,给每一条边加上画一个权值,指向左子节点的边标记为 0,指向右子节点的边,标记为 1,那从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。
假设字符 a,b,c,d,e,f 的出现次数依次为 6,5,4,3,2,1,那么其霍夫曼编码为:
分治算法(Divide And Conquer)
分治算法是一种处理问题的思想,递归是一种编程技巧。分治算法一般都比较适合用递归来实现
当满足以下条件时,可以使用分治算法解决问题:
1、原问题与分解成的小问题具有相同的模式;
2、原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别;
3、具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
4、可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
应用
分治算法求出一组数据的逆序对个数:
套用分治的思想来求数组 A 的逆序对个数,将数组分成前后两半 A1 和 A2,分别计算 A1 和 A2 的逆序对个数 K1 和 K2,然后再计算 A1 与 A2 之间的逆序对个数 K3。那数组 A 的逆序对个数就等于 K1+K2+K3。这里需要借助归并排序算法来计算逆序对个数
1 | private int num = 0; // 全局变量或者成员变量,记录逆序对个数 |
重点是归并合并两个数组的同时,计算对应的逆序度
回溯算法(Back Tracking)
回溯算法也叫试探法,它是一种系统地搜索问题的解的方法。回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。回溯算法非常适合用递归代码实现
八皇后问题
有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子
1 | int[] result = new int[8]; // 下标表示行,值表示 queen 存储在哪一列 |
isOk 是向上判断的,所以只有前面放置棋子符合要求才会继续放置下一个棋子。又因为外面是一个 for 循环,将每一行中的 8 列都遍历了一遍,那么一行的所有情况都会遍历到,也就不需要 result 的回退复原了。所以能够执行下去的一定是都满足需求的情况,不满足的情况都终止了。
0-1 背包问题
假设有一个背包,背包总的承载重量是 Wkg。现在有 n 个物品,每个物品的重量不等,并且不可分割。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?把物品依次排列,整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。回溯法解决这个问题的时间复杂度为 O(2^n):先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品,每次决策都有放或者不放两种方法,一共有 n 次 决策。假设背包可承受重量 100,物品个数 10,物品重量存储在数组 a 中:
1 | public int maxW = Integer.MIN_VALUE; // 存储背包中物品总重量的最大值 |
可以把输入的变量都定义成了成员变量
1 | private int maxW = Integer.MIN_VALUE; // 结果放到 maxW 中 |
其实还可以优化,使用递归树将回溯求解过程展示出来:递归树中的每个节点 (i, cw) 表示一种状态,表示要决策到第 i 步时,背包重量为 cw 状态。
将每个状态都记录到“备忘录”中,如果之前已经计算过这种状态,那么当再次遇到的时候可以直接从备忘录中取出,避免冗余的计算。在这个问题里,如果已经有这个状态,说明经过前面的 i 次决策后,又有与之前相同状态的分支,只计算前面那次就行,后面的可以直接跳过
1 | private int maxW = Integer.MIN_VALUE; // 结果放到 maxW 中 |
0-1 背包问题升级版
有一组不同重量、不同价值、不可分割的物品,将某些物品装入背包,在满足背包最大重量限制的前提下,背包中装入物品的最大价值是多少
1 | private int maxV = Integer.MIN_VALUE; // 结果放到 maxV 中 |
正则表达式
假设正则表达式中只包含“”和“?”这两种通配符,且与正常的正则匹配意思不一样:“”表示匹配任意多个(大于等于 0 个)任意字符,“?”表示匹配零个或者一个任意字符。用回溯算法来判断一个给定的文本能否跟给定的正则表达式匹配相匹配:
1 | public class Pattern { |
动态规划(Dynamic Programming)
动态规划是一个以空间换时间的算法。把问题分解为多个阶段,每个阶段对应一个决策。只记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。
0-1 背包问题
动态规划的解决方法:把整个过程分为 n 个阶段,每个阶段决策一个物品是否放到背包中,每个物品决策完后会有很多种情况(状态),只记录不同的状态,然后对应到前面的递归树上,就是有很多不同的节点 (i, cw)。现在只记录下每层不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。这样做可以保证每层不同状态的个数不超过 w 个(w 为背包可承载的重量),因为可能的最大值在 0-w 之间,也就是 1,2,3…w 中的某个值。而其他的都不符合要求,也就避免了每层状态个数的指数级增长。动态规划解决这个问题的时间复杂度为 O(n*w)
使用一个二维数组 states[n][w+1] 来记录每层可以达到的不同状态。像上面的 2 2 4 6 3 这组重量,从第一个物品(下标为 0)开始,不放入背包的话 states[0][0]=true,放入背包则 states[0][2]=true;决策第二个物品放不放背包中,会有三种不同状态 states[1][0]=true states[1][2]=true states[1][4]=true;依次类推,直到最后一层找到最接近 w 的值(这里是 9)就是最大的重量。
1 | // weight:物品重量,n:物品个数,w:背包可承载重量 |
其实可以基于一个长度为 w+1 的一维数组来操作,只需要记录每层最新的决策状态即可
1 | // weight:物品重量,n:物品个数,w:背包可承载重量 |
注意代码中 j 要从大到小遍历,因为从小到大遍历时,上一层的临时状态还未被访问就被本层前面的决策状态覆盖了,后面的状态更新时就会重复计算状态导致失真。动态规划具有无后效性,后面的状态不会影响前面的状态,从后往前计算就不会出现信息丢失
0-1 背包问题升级版
类似上面的解法:每个节点记为 f(i, cw, cv),其中的 i 表示要决策的第 i 个物品是否放入背包,cw 表示当前背包中的物品重量,cv 表示背包中物品的总价值。合并 i 和 cw 都相同的决策,只需要保留 cv 最大的这种状态,继续递归,其他的可以忽略。用一个二维数组 states[n][w+1],来记录每层可以达到的状态(价值)。
1 | // weight:物品重量,value:物品价值,n:物品个数,w:背包可承载重量 |
满减购物
活动满 200 减 30,如何才能使选出来的商品价格总和最大程度地接近 200?并且还要显示出购买哪些商品才能达到这种状态
1 | // items:商品价格,n:商品个数, w:表示满减条件,200 |
动态规划理论
使用动态规划可以解决的问题符合“一个模型三个特征”
一个模型:多阶段决策最优解模型。解决问题的过程中,需要经历多个决策阶段,每个阶段对应一组状态。然后其中一组决策序列能够产生最终期望求解的最优值。
三个特征分别是最优子结构、无后效性和重复子问题
最优子结构:问题的最优解包含子问题的最优解,也可以通过子问题的最优解,推导出问题的最优解
无后效性:1、在推到后面阶段状态时,只关注这个状态值,而不关心怎么得到这种状态的;2、某阶段状态一旦确定,就不受后面决策的影响
重复子问题:不同的决策序列,可能会产生重复的状态
动态规划解题思路
1、状态转移表法
思路:回溯算法实现 -> 定义状态 -> 画递归树 -> 找重复子问题 -> 画状态转移表 -> 根据递推关系填表 -> 将填表过程翻译成代码
一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树,从递归树中查找有无重复子问题。如果找到重复的子问题,可以使用回溯加“备忘录”的方法避免重复子问题,也可以使用动态规划的状态转移表法。状态表一般都是二维的,每个状态包含三个变量,行、列、数组值。根据决策的先后过程,从前往后,分阶段填充状态表中的每个状态,最后将这个填表的过程翻译成动态规划代码。
例如:从左上角移动到右下角,使路径上的数字之和最小
从图中可以看出:min_dist(i, j) 可以通过 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))
1 | // 回溯法 |
根据回溯法画出递归树
每个节点状态记为 (i,j,dist),其中 i,j 分别表示行和列,dist 表示从起点到达 (i, j) 的数字之和。同时合并 i、j 相同的节点,仅保留 dist 的最小值
1 | // matrix:二维数组,n:数组长度 |
- 状态转移方程法
思路:找最优子结构 -> 写状态转移方程 -> 将状态转移方程翻译成代码
状态转移方程是解决动态规划的关键,它有点类似递归的解题思路。根据最优子结构,写出递归公式,也就是所谓的状态转移方程。有两种代码实现方法,一种是递归加“备忘录”,另一种是迭代递推。迭代递推与状态转移表法的代码实现是一样的,只是思路不同1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 递归加“备忘录”
private int[][] matrix = {{1,3,5,9}, {2,1,3,4},{5,2,6,7},{6,8,4,3}};
private int n = 4;
private int[][] mem = new int[4][4];
public int minDist(int i, int j) { // 调用 minDist(n-1, n-1);
if (i == 0 && j == 0) return matrix[0][0];
if (mem[i][j] > 0) return mem[i][j]; // “备忘录”,已有值就直接返回
int minLeft = Integer.MAX_VALUE;
if (j-1 >= 0) {
minLeft = minDist(i, j-1);
}
int minUp = Integer.MAX_VALUE;
if (i-1 >= 0) {
minUp = minDist(i-1, j);
}
int currMinDist = matrix[i][j] + Math.min(minLeft, minUp);
mem[i][j] = currMinDist;
return currMinDist;
}
杨辉三角
现在经过某个数字只能到达下面一层相邻的两个数字,假设从第一行开始向下移动,当到达最底层时,所经过的数字之和最小(总路径最短)是多少
杨辉三角的动态规划转移方程是:S[i][j] = min(S[i-1][j],S[i-1][j-1]) + a[i][j]
其中 a 表示到这个点的 value 值,S 表示到 a[i][j] 这个点的最短路径值。
1 | // 状态转移表法 |
硬币找零
假设有 3 种不同的硬币,1 元、3 元、5 元,现在需要支付 9 元,则最少需要多少个硬币
1 | public int minCoins(int money) { |
编辑距离(Edit Distance)
编辑距离指的就是,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符),可以量化两个字符串之间的相似程度:编辑距离越大,相似程度越小;编辑距离就越小,相似程度越大;两个完全相同的字符串的编辑距离是 0。
常用的两种编辑距离计算方式:
1、莱文斯坦距离:允许增加、删除、替换字符这三个编辑操作,表示两个字符串的“差异度”,越小越相似
2、最长公共子串长度:允许增加、删除字符这两个编辑操作,表示两个字符串的“相似度”,越大越相似
莱文斯坦距离(Levenshtein distance)
计算莱温斯坦距离,实际上是把一个字符串变成另一个字符串,求出需要的最少编辑次数。
使用回溯法:如果 a[i] 与 b[j] 匹配,编辑距离不变,然后递归考察 a[i+1] 和 b[j+1] 是否匹配。
如果 a[i] 和 b[j] 不匹配,有增、删、改 3 类处理方法:
1、增:在 a[i] 前面添加一个跟 b[j] 相同的字符,然后递归考察 a[i]和 b[j+1];或在 b[j] 前面添加一个跟 a[i] 相同的字符,然后递归考察 a[i+1] 和 b[j]。即距离 +1,然后拿其中一字符串的当前字符比较另一条字符串的下一个字符
2、删:删除 a[i],然后递归判断 a[i+1] 和 b[j];或删除 b[i],然后递归判断 a[i] 和 b[j+1]。与 1 效果相同,即距离 +1,然后拿其中一字符串的当前字符比较另一条字符串的下一个字符
3、改:将 a[i] 替换成 b[j],或将 b[j] 替换成 a[i],然后递归考察 a[i+1]和 b[j+1]。即距离 +1,同时两条字符串都跳过当前字符,直接比较下一个字符
1 | private char[] a = "mitcmu".toCharArray(); |
画出递归树,查看是否有重复的子问题。如果存在重复子问题就可以使用动态规划来解决;如果没有重复子问题,则回溯法就是最好的方法
在节点的 i 与 j 相同情况下,只保留 edist 最小的值,即最少的编辑次数。
由回溯法的代码可以看出状态转移过程:到达状态 (i, j),可以由 (i-1, j)、(i, j-1)、(i-1, j-1) 这三种状态任意一个转移而来。
根据这个转移过程得到状态转移方程:
1 | // 如果:a[i]!=b[j],由 (i-1, j)、(i, j-1)、(i-1, j-1) 这三种状态转移得到 |
使用一个二维数组 minDist 记录每个递推过程的状态
1 | // a:第一个字符串,n:第一个字符串长度,i:行序号;b:第二个字符串,m:第二个字符串长度,j:列序号 |
最长公共子串长度(Longest common substring length)
最长公共子串长度只允许增加和删除字符两种编辑操作,它表征的是两个字符串之间的相似程度。
如果 a[i] 与 b[j] 相匹配,那么最长公共子串长度 +1。如果不匹配,最长公共子串长度不变,然后有增或删两种处理方法:
1、在 a[i] 前增加一个字符 b[j],然后匹配 a[i] 与 b[j+1]。或者在 b[j] 前添加一个字符 a[i],然后匹配 a[i+1] 与 b[j];
2、删除 a[i],然后匹配 a[i+1] 与 b[j]。或者删除 b[j],然后匹配 a[i] 与 b[j+1]。
则 a[i] 与 b[j] 的最长公共子串长度 max_lcs(i, j) 的可以由 max_lcs(i-1,j-1)、max_lcs(i-1, j)、 max_lcs(i, j-1) 这三种状态转移而来。相同状态下,保留最大的值。
1 | // 如果 a[i]==b[j],由 max_lcs(i-1,j-1)、max_lcs(i-1, j)、 max_lcs(i, j-1) 这三种状态转移得到 |
具体代码实现:
1 | // a:第一个字符串,n:第一个字符串长度,i:行序号;b:第二个字符串,m:第二个字符串长度,j:列序号 |
最长递增子序列长度
一个数字序列包含 n 个不同的数字,求出这个序列中的最长递增子序列长度。比如 2, 9, 3, 6, 5, 1, 7 这样一组数字序列,它的最长递增子序列就是 2, 3, 5, 7,所以最长递增子序列的长度是 4。思路:a[0…i] 的最长子序列为: a[i] 之前所有比它小的元素中子序列长度最大的 +1
1 | public int longestIncreaseSubArrayDP(int[] array) { |