文章放置于:https://github.com/zgkaii/CS-Notes-Kz,欢迎批评指正!
贪心算法
基本概念
贪心算法(Greedy)是在每一步选择中都采取在当前状态下最好或者最优(即最有利的)选择,从而希望导致结果是全局最好或最优的算法(实际上考虑的是局部最优解)。
适用贪心算法的场景:局部最优策略能导致产生全局最优解。当问题能够分解成子问题解决,并且每个子问题的最优解能递推到最终问题的最优解(这种子问题最优解被称为最优子结构)。
经典举例
教室调度
以《算法图解》中教室调度为例,如果有课程表如下,希望尽可能多地安排教室。
课程 | 开始时间 | 结束时间 |
---|---|---|
美术 | 9 AM | 10 AM |
英语 | 9:30 AM | 10:30 AM |
数学 | 10 AM | 11 AM |
计算机 | 10:30 AM | 11:30 AM |
音乐 | 11 AM | 12 AM |
这个问题好像很难,实际上,算法可能简单得让你大吃一惊。具体做法如下。
- 选出结束最早的课,它就是要在这间教室上的第一堂课。
- 接下来,必须选择第一堂课结束后才开始的课。同样,你选择结束最早的课,这将是要 在这间教室上的第二堂课。
美术课的结束时间最早,为10:00 AM,因此它 就是第一堂课。接下来的课必须在10:00 AM后开始,且结束得最早,那么再选择数学课;最后,计算机课与数学课的 时间是冲突的,但音乐课可以。最终课程安排就是美术->数学->音乐
。
跳跃游戏
再以LeetCode 跳跃游戏为例,给定一个非负整数数组 nums
,玩家最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断是否能够到达最后一个下标。
当nums = [2,3,1,1,4]
时,玩家位于下标0处,那么玩家可以第一步跳1格,然后再跳3格到达终点。当然,玩家第一步也可以最大跳2格,然后跳1格,再跳1格到终点。
由题可以分析出,对于每一个可以到达的位置 n,它使得 n+1, n+2,⋯,n+nums[n]
这些连续的位置也都可以到达。运用贪心算法的思想,我们依次遍历数组中每个位置并实时维护 最远可以到达的位置。在遍历的过程中,如果 最远可以到达的位置 大于等于数组中的最后一个位置,那就说明最后一个位置可达。
public boolean canJump(int[] nums) {
int max = 0;
for(int i = 0;i < nums.length; i++)
if(i > max)
return false;
max = Math.max(nums[i] + i, max);
}
return true;
}
其实也可以逆向思考,从数组的倒数第二位开始计算,如果当前的位置加上当前所能跳转的最大距离大于等于last,说明这一步跳转是没问题的从最终位置先跳到上一可达位置,那么更新最终位置,直到最终位置与起始点重合,那么证明可达。
public boolean canJump(int[] nums) {
int last = nums.length - 1;
for (int i = last; i >= 0; i--) {
if (nums[i] + i >= last)
last = i;
}
return last == 0;
}
动态规划
基本概念
动态规划(英语:Dynamic programming,简称DP)是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划背后的基本思想与分治法如出一辙。大致上,若要解一个给定问题,需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表,从而大大节省了处理时间。
使用场景
-
最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
-
无后效性。即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
-
子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率,降低了时间复杂度。
贪心算法与动态规划的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前运算结果,并根据以前的结果对当前进行选择,有回退功能。
动态规划与分治法最大的差别是,适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。
经典举例
其实看了上面的定义,会觉得还是很懵,有种似懂非懂的样子,下面就先以LeetCode. 不同路径为例,初步了解动态规划。
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。求问总共有多少条不同的路径?
状态转移表
一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决,第一步都是查找出重复子问题。找到重复子问题之后,接下来就可以直接用**回溯加“备忘录”**的方法,来避免重复子问题。从执行效率上来讲,这跟动态规划的解决思路没有差别。第二种是使用动态规划的解决方法,状态转移表法。
状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,将这个递推填表的过程,翻译成代码,就是动态规划代码了。
以3x3
表格为例。很明显,第一行与第一列无论有多少格子,其中格子的路径只能为1,即table[i][0] == table[0][j] == 1
, table[1][1]
的值明显来自左与上相邻两个格子的路径之和 1 + 1 = 2
。table[1][2]
也来自左与上相邻两个格子的路径之和1 + 2 = 3
… … 最终计算出table[2][2] == 6
,也就是说3x3
网格的路径为6。
状态转移表填写过程:
// 3x3 0 1 2 3x3 0 1 2 3x3 0 1 2 3x3 0 1 2 3x3 0 1 2
// 0 ? ? ? => 0 1 1 1 => 0 1 1 1 => 0 1 1 1 => ... => 0 1 1 1
// 1 ? ? ? 1 1 ? ? 1 1 2 ? 1 1 2 ? 1 1 2 3
// 2 ? ? ? 2 1 ? ? 2 1 ? ? 2 1 3 ? 2 1 3 6
弄懂了填表的过程,代码实现就简单多了。将上面的过程,翻译成代码:
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
dp[i][0] = 1; // 第一行路径全设为1
for (int j = 0; j < n; j++)
dp[0][j] = 1; // 第一列路径全设为1
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];// 每个网格路径等于其左边与上边的相邻两个网格路径之和
}
}
return dp[m- 1][n - 1];
}
状态转移方程
状态转移方程法有点类似递归的解题思路。需要分析某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。根据最优子结构,写出递归公式,也就是所谓的状态转移方程。
如果用 f(i, j)
表示从左上角走到(i,j)
的路径数量,其中 i
和 j
的范围分别是 [0, m)
和 [0,n)
。
由于我们每一步只能从向下或者向右移动一步,因此要想走到 (i,j)
,如果向下走一步,那么会从 (i−1,j)
走过来;如果向右走一步,那么会从(i,j−1)
走过来。因此我们可以写出动态规划转移方程:f(i, j) = f(i-1, j) + f(i, j-1)
。
了方便代码编写,我们可以将所有的f(0, j)
以及 f(i, 0)
都设置为边界条件,它们的值均为 1。需要注意的是,如果i = 0
,那么 f(i−1,j)
并不是一个满足要求的状态,我们需要忽略这一项;同理,如果j=0
,那么 f(i,j−1)
并不是一个满足要求的状态,我们需要忽略这一项。初始条件为 f(0,0) = 1
,即从左上角走到左上角有一种方法。
最终的答案即为 f(m-1, n-1)
。
这里代码与还是与上面一样… …
可以看出,不论用何种方法,确认状态转移方程是解决动态规划的关键,上面状态转移表法也仅仅是为了递推状态转移方程更易于理解与直观。
从上面分析过程可以看出,状态转移方程法解题无非分为三步:
- 分治(找最优子结构)
- 确认状态转移方程
- 确定边界条件
当然,上面动态规划解法还可以空间优化。由于当前坐标的值只和相邻左边与上边网格的值有关,本质上就是一个“杨辉三角形”,每个位置的路径 = 该位置左边的路径 + 该位置上边的路径
,那么使用二维数组会造成大量的空间浪费。
我们可以把状态转移方程优化为一维数组。
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
转化:对于第i
行,dp[j] = dp[j] + dp[j - 1]
,等号未赋值前dp[j]
就是上一行的第j
列个网格对应路径,即dp[i - 1][j]
等号右边的dp[j - 1]
是已经更新过的本行第j - 1
个网格对应的的路径,则本行dp[j]
= 上一行的dp[j] + 本行的dp[j - 1]
,所以状态转移方程为dp[j] += dp[j - 1]
。
// 3x4 0 1 2 3 => 自顶向下 遍历行扫描列 => 自顶而下 遍历列扫描行
// 0 1 1 1 1 1 1 1 1 1 1 1
// 1 1 2 3 4 => 1 2 3 4 => 1 2 3
// 2 1 3 6 10 => 1 3 6 10 => 1 3 6
// => 1 4 10
遍历行,扫描列:
public int uniquePaths(int m, int n) {
int[] dp = new int[n];
dp[0] = 1;
for (int i = 0; i < m; i++)
for (int j = 1; j < n; j++)
dp[j] += dp[j - 1];
return dp[n - 1];
}
遍历列,扫描行:
public int uniquePaths(int m, int n) {
int[] dp = new int[m];
dp[0] = 1;
for (int j = 0; j < n; j++)
for (int i = 1; i < m; i++)
dp[i] += dp[i - 1];
return dp[m - 1];
}
高阶实战
在搜索框中,一不小心输错单词时,搜索引擎会非常智能地检测出你的拼写错误,然后用对应的正确单词来进行搜索。这是什么原理呢?其实有种量化字符串相似程度的方法——编辑距离。编辑距离(Edit Distance)指将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是 0
。
编辑距离有多种不同的计算方式,较著名的有莱文斯坦距离(Levenshtein distance
)和最长公共子串长度(Longest common substring length
)。其中,莱文斯坦距离允许增加、删除、替换字符这三个编辑操作;最长公共子串长度只允许增加、删除字符这两个编辑操作。
最长公共子序列
先来看LeetCode. 最长公共子序列,给定两个字符串 s1
和 s2
,返回这两个字符串的最长公共子序列的长度。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。例如,“ace” 是 “abcde” 的最长子序列,但 “aec” 不是 “abcde” 的子序列。
初看题目会觉得很懵逼,仔细分析后发现,此题目跟上面不同路径题目是类似的。以s1:"ace"
与为例s2:"abcde"
,画出状态转移表。
// m = s1.length(), n = s2.length()
// m/n 0 1a 2b 3c 4d 5e
// 0 0
// 1a 0 1 1 1 2 2
// 2c 1 1 2 2 2
// 3e 1 1 2 2 3
- 若当前字符相同,则找到了一个公共元素,并将公共子序列的长度在
f(i - 1)(j - 1)
的基础上再加 1,此时状态转移方程:dp[i][j] = dp[i-1][j-1] + 1
。 - 若当前字符不同,只需要把第一个字符串往前退一个字符或者第二个字符串往前退一个字符然后记录最大值即可(相当于看
是[0, i - 2] 与 s2[0, j - 1]
的最长公共子序列 和s1[0, i - 1] 与 s2[0, j - 2]
的最长公共子序列,取最大的),此时状态转移方程:dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
。 - 将以上分析翻译为代码:
public int longestCommonSubsequence1(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (s1.charAt(i) == s2.charAt(j)) {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
return dp[m][n];
}
莱文斯坦距离
再来看LeetCode. 编辑距离问题,给你两个单词 word1
和 word2
,请你计算出将 word1
转换成 word2
所使用的最少操作数 (每次可以进行插入、删除或替换一个字符)。
首先我们需要明确,对于任一单词:
word1
中删除一个字符与word2
中插入一个字符是等价的。例如word1: haa
与word2: ha
。- 同理,
word2
中插入一个字符与word1
中删除一个字符是等价的。 word1
中替换一个字符与word2
中替换一个字符是等价的。例如word1: abc
与word2: abd
。
下面就可以开始分析了。
如果二维数组dp[m][n]
表示:将 word1[0..i)
转换成为 word2[0..j)
的方案数。
-
存在空字符串时。例如
word1 = "horse", word2 = ""
,那么编辑距离等于i: word1.length()
;同理word1 =""
,那么编辑距离等于j: word2.length()
。 -
如果
word1[i - 1] == word2[j - 1]
,即两个字符串的最后一个字符相等,那么其编辑距离等于同时删除这个字符以后的编辑距离,即dp[i][j] = dp[i - 1][j - 1]
。例如hors与ros
等同于hor与ro
,编辑距离都为2。 -
如果
word1[i - 1] != word2[j - 1]
,可采取三种操作:- 插入:在当前
word1
末尾插入一个与word2
末尾字符相同的字符(操作次数 + 1),此时编辑距离dp[i][j] = dp[i][j - 1] + 1
(由于word1
中插入相当于word2
删除,word2
中下标减1,[i][j-1]
)。例如haa
与hb
编辑距离为2,hb => hba
后,hba
与haa
编辑距离为1,加上操作次数仍为2。 - 删除:删除当前
word1
末尾最后一个字符(操作次数 + 1),此时编辑距离dp[i][j] = dp[i - 1][j] + 1
(word1
中删除元素,下标减1,[i-1][j]
)。还是以haa
与hb
为例,haa => ha
后,ha
与hb
编辑距离为1,加上操作次数仍为2。 - 替换:将当前
word1
末尾字符替换成当前word2
末尾字符(操作次数 + 1),此时编辑距离dp[i][j] = dp[i - 1][j - 1] + 1
。还是以haa
与hb
为例,haa => hab
后,hab
与hb
编辑距离为1,加上操作次数仍为2。
- 插入:在当前
综上,dp[i][j]
的编辑距离等于以上4类操作的最小值。由于dp[i - 1][j - 1] + 1 > dp[i - 1][j - 1]
,所以状态转移方程:
dp[i][j] = min(dp[i - 1][j - 1], dp[i][j - 1] + 1, dp[i - 1][j] + 1)
以word1 = "horse", word2 = "ros"
为例,状态转移表:
// 6x4 r o s 6x4 r o s 6x4 r o s
// 0 1 2 3 0 1 2 3 0 1 2 3
// h 1 ? ? ? h 1 1 2 3 h 1 1 2 3
// o 2 ? ? ? o 2 2 1 2 o 2 2 1 2
// r 2 ? ? ? --> r 2 ? ? ? --> ... ... --> r 3 2 2 2
// s 2 ? ? ? s 2 ? ? ? s 4 3 3 2
// e 2 ? ? ? e 2 ? ? ? e 5 4 4 3
将以上分析翻译成代码:
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
// 存在空字符串
if (m * n == 0)
return m + n;
// 定义DP数组
int[][] dp = new int[m + 1][n + 1];
// 边界状态:第一行
for (int j = 1; j <= n; j++)
dp[0][j] = dp[0][j - 1] + 1;
// 边界条件:第一列
for (int i = 1; i <= m; i++)
dp[i][0] = dp[i - 1][0] + 1;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
// dp[i][j] = dp[i - 1][j - 1];
// continue;
// }
// int insert = dp[i][j - 1] + 1;
// int replace = dp[i - 1][j - 1] + 1;
// int delete = dp[i - 1][j] + 1;
// dp[i][j] = Math.min(Math.min(insert, replace), delete);
if (word1.charAt(i - 1) == word2.charAt(j - 1))
// 当前字母相同,不需要额外操作,等于前一段编辑距离
dp[i][j] = dp[i - 1][j - 1];
else
// 状态转移方程
dp[i][j] = Math.min(Math.min(dp[i - 1][j - 1], dp[i][j - 1]), dp[i - 1][j]) + 1;
}
}
return dp[m][n];
}
推荐阅读
- https://muchen.fun/passages/38/
- https://leetcode-cn.com/circle/article/yXFal5/
- https://muchen.fun/passages/39/