关键词:DP算法 面试 最值问题 子序列问题 子数组问题 回文问题 回溯算法
本文为数篇动态规划问题文章笔记整合,非简单拼凑,本着“只有亲身实践过并整理成体系才属于自己真正掌握的知识” 的理念写出一篇“一通百通”的文章,不要用您再多查太多资料,浪费宝贵时间,简单暴力吃透原理。后续每天更新,持续关注,欢迎留言讨论~。
提示:以下是本篇文章正文内容,下面案例可供参考
300.最长上升子序列
本文就借助经典的「最长递增子序列问题」来讲一讲设计动态规划的通用技巧:数学归纳思想。
我们先假设这个结论在
k
时成立,然后根据这个假设,想办法推导证明出 k=n
的时候此结论也成立**。如果能够证明出来,那么就说明这个结论对于k
等于任何数都成立。
最长递增子序列(Longest Increasing Subsequence,简写 LIS)是非常经典的一个算法问题,比较容易想到的是动态规划解法,时间复杂度 O(N^2),我们借这个问题来由浅入深讲解如何找状态转移方程,如何写出动态规划解法。比较难想到的是利用二分查找,时间复杂度是 O(NlogN)。
dp[0...i-1]
都已经被算出来了,怎么通过这些结果算出 dp[i]
?首先给出定义:
dp[i]
表示以nums[i]
这个数结尾的最长递增子序列的长度。
可以推出 base case:dp[i]
初始值为 1,因为以 nums[i]
结尾的最长递增子序列先要包含它自己。算法演进的过程
然后根据这个定义,最终结果(子序列的最大长度)应该是 dp 数组中的最大值。最终问题在于,如何设计算法逻辑来正确计算每个 dp[i]
。
假设已经知道
dp[0..4]
的所有结果,我们如何通过这些已知结果推出dp[5]
?即求以nums[5]
为结尾的最长递增子序列。
nums[5] = 3
,既然是递增子序列,只要找到前面那些结尾比 3 小的子序列,然后把 3 接到最后,形成一个新的递增子序列,而且这个新的子序列长度加一。可能形成很多种新的子序列,但是我们只选择最长的那一个,把最长子序列的长度作为 dp[5]
的值即可
//当 `i = 5` 时,这段代码的逻辑就可以算出 dp[5]
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
在外面嵌套一层,进行遍历
//类似数学归纳法,算出dp[4], dp[3]...
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
完整代码:
public int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length];
// base case:dp 数组全都初始化为 1
Arrays.fill(dp, 1);
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
int res = 0;
for (int i = 0; i < dp.length; i++) {
res = Math.max(res, dp[i]);
}
return res;
}
时间复杂度 O(N^2)。总结一下如何找到动态规划的状态转移关系:
1、明确 dp
数组所存数据的含义。这一步对于任何动态规划问题都很重要,如果不得当或者不够清晰,会阻碍之后的步骤。
2、根据 dp
数组的定义,运用数学归纳法的思想,假设 dp[0...i-1]
都已知,想办法求出 dp[i]
,一旦这一步完成,整个题目基本就解决了。
时间复杂度为 O(NlogN):
public int lengthOfLIS(int[] nums) {
int[] top = new int[nums.length];
// 牌堆数初始化为 0
int piles = 0;
for (int i = 0; i < nums.length; i++) {
// 要处理的扑克牌
int poker = nums[i];
/***** 搜索左侧边界的二分查找 *****/
int left = 0, right = piles;
while (left < right) {
int mid = (left + right) / 2;
if (top[mid] > poker) {
right = mid;
} else if (top[mid] < poker) {
left = mid + 1;
} else {
right = mid;
}
}
/*********************************/
// 没找到合适的牌堆,新建一堆
if (left == piles) piles++;
// 把这张牌放到牌堆顶
top[left] = poker;
}
// 牌堆数就是 LIS 长度
return piles;
}
这个解法确实很难想到。首先涉及数学证明,谁能想到按照这些规则执行,就能得到最长递增子序列呢?其次还有二分查找的运用,要是对二分查找的细节不清楚,给了思路也很难写对。作为思维拓展好了。但动态规划的设计方法应该完全理解:假设之前的答案已知,利用数学归纳的思想正确进行状态的推演转移,最终得到答案。
354.俄罗斯套娃信封问题
信封嵌套问题就需要先按特定的规则排序,之后就转换为一个 最长递增子序列问题,可以用前文 二分查找详解 的技巧来解决了。
最长递增子序列(Longes Increasing Subsequence,简写为 LIS)的一个变种,因为很显然,每次合法的嵌套是大的套小的,相当于找一个最长递增的子序列,其长度就是最多能嵌套的信封个数。难点在于,标准的 LIS 算法只能在数组中寻找最长子序列,而我们的信封是由 (w, h)
这样的二维数对形式表示的,如何把 LIS 算法运用过来呢?
这道题的解法是比较巧妙的:
先对宽度 w
进行升序排序,如果遇到 w
相同的情况,则按照高度 h
降序排序。之后把所有的 h
作为一个数组,在这个数组上计算 LIS 的长度就是答案。
先对这些数对进行排序:
然后在 h
上寻找最长递增子序列
w
相同的数对,要对其高度 h
进行降序排序。因为两个宽度相同的信封不能相互包含的,逆序排序保证在 w
相同的数对中最多只选取一个。// envelopes = [[w, h], [w, h]...]
public int maxEnvelopes(int[][] envelopes) {
int n = envelopes.length;
// 按宽度升序排列,如果宽度一样,则按高度降序排列
Arrays.sort(envelopes, new Comparator<int[]>()
{
public int compare(int[] a, int[] b) {
return a[0] == b[0] ?
b[1] - a[1] : a[0] - b[0];
}
});
// 对高度数组寻找 LIS
int[] height = new int[n];
for (int i = 0; i < n; i++)
height[i] = envelopes[i][1];
return lengthOfLIS(height);
}
关于最长递增子序列的寻找方法,在前文中详细介绍了动态规划解法,并用扑克牌游戏解释了二分查找解法,本文就不展开了,直接套用算法模板:
/* 返回 nums 中 LIS 的长度 */
public int lengthOfLIS(int[] nums) {
int piles = 0, n = nums.length;
int[] top = new int[n];
for (int i = 0; i < n; i++) {
// 要处理的扑克牌
int poker = nums[i];
int left = 0, right = piles;
// 二分查找插入位置
while (left < right) {
int mid = (left + right) / 2;
if (top[mid] >= poker)
right = mid;
else
left = mid + 1;
}
if (left == piles) piles++;
// 把这张牌放到牌堆顶
top[left] = poker;
}
// 牌堆数就是 LIS 长度
return piles;
}
此算法的时间复杂度为 O(NlogN)
,因为排序和计算 LIS 各需要 O(NlogN)
的时间。
空间复杂度为 O(N)
,因为计算 LIS 的函数中需要一个 top
数组。
这个问题是个 Hard 级别的题目,难就难在排序,正确地排序后此问题就被转化成了一个标准的 LIS 问题,容易解决一些。
其实这种问题还可以拓展到三维,比如说现在不是让你嵌套信封,而是嵌套箱子,每个箱子有长宽高三个维度,请你算算最多能嵌套几个箱子?
我们可能会这样想,先把前两个维度(长和宽)按信封嵌套的思路求一个嵌套序列,最后在这个序列的第三个维度(高度)找一下 LIS,应该能算出答案。
实际上,这个思路是错误的。这类问题叫做「偏序问题」
53.最大子序和(简单)
最大子数组问题和前文讲过的 经典动态规划:最长递增子序列 的套路非常相似,代表着一类比较特殊的动态规划问题的思路:
首先想到的是滑动窗口算法,滑动窗口算法就是专门处理子串/子数组问题的,这里不就是子数组问题么?
但是,稍加分析就发现,这道题还不能用滑动窗口算法,因为数组中的数字可以是负数。
滑动窗口算法无非就是双指针形成的窗口扫描整个数组/子串,但关键是,你得清楚地知道什么时候应该移动右侧指针来扩大窗口,什么时候移动左侧指针来减小窗口。
而对于这道题目,你想想,当窗口扩大的时候可能遇到负数,窗口中的值也就可能增加也可能减少,这种情况下不知道什么时机去收缩左侧窗口,也就无法求出「最大子数组和」。
解决这个问题需要动态规划技巧,但是 dp
数组的定义比较特殊。按照我们常规的动态规划思路,一般是这样定义 dp
数组:
nums[0..i]
中的「最大的子数组和」为 dp[i]
。
利用数学归纳法,能用 dp[i]
推出 dp[i+1]
吗?
实际上是不行的,因为子数组一定是连续的,按照我们当前 dp
数组定义,并不能保证 nums[0..i]
中的最大子数组与 nums[i+1]
是相邻的,也就没办法从 dp[i]
推导出 dp[i+1]
。
如何选择?既然要求「最大子数组和」,当然选择结果更大的那个啦:
// 要么自成一派,要么和前面的子数组合并
dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
我们已经写出了状态转移方程,就可以直接写出解法了
int maxSubArray(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
int[] dp = new int[n];
// base case
// 第一个元素前面没有子数组
dp[0] = nums[0];
// 状态转移方程
for (int i = 1; i < n; i++) {
dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
}
// 得到 nums 的最大子数组
int res = Integer.MIN_VALUE;
for (int i = 0; i < n; i++) {
res = Math.max(res, dp[i]);
}
return res;
}
以上解法时间复杂度是 O(N),空间复杂度也是 O(N),较暴力解法已经很优秀了,不过注意到 dp[i]
仅仅和 dp[i-1]
的状态有关,那么我们可以进行「状态压缩」,将空间复杂度降低:
int maxSubArray(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
// base case
int dp_0 = nums[0];
int dp_1 = 0, res = dp_0;
for (int i = 1; i < n; i++) {
// dp[i] = max(nums[i], nums[i] + dp[i-1])
dp_1 = Math.max(nums[i], nums[i] + dp_0);
dp_0 = dp_1;
// 顺便计算最大的结果
res = Math.max(res, dp_1);
}
return res;
}
虽然说动态规划推状态转移方程确实比较玄学,但大部分还是有些规律可循的。
这道「最大子数组和」就和「最长递增子序列」非常类似,dp
数组的定义是「以 nums[i]
为结尾的最大子数组和/最长递增子序列为 dp[i]
」。因为只有这样定义才能将 dp[i+1]
和 dp[i]
建立起联系,利用数学归纳法写出状态转移方程。
相关推荐:
这篇文章就给你讲明白两个问题:
1、到底什么才叫「最优子结构」,和动态规划什么关系。
2、为什么动态规划遍历 dp
数组的方式五花八门,有的正着遍历,有的倒着遍历,有的斜着遍历。
「最优子结构」是某些问题的一种特定性质,并不是动态规划问题专有的。也就是说,很多问题其实都具有最优子结构,只是其中大部分不具有重叠子问题,所以我们不把它们归为动态规划系列问题而已。
我先举个很容易理解的例子:假设你们学校有 10 个班,你已经计算出了每个班的最高考试成绩。那么现在我要求你计算全校最高的成绩,你会不会算?当然会,而且你不用重新遍历全校学生的分数进行比较,而是只要在这 10 个最高成绩中取最大的就是全校的最高成绩。
我给你提出的这个问题就符合最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果。让你算每个班的最优成绩就是子问题,你知道所有子问题的答案后,就可以借此推出全校学生的最优成绩这个规模更大的问题的答案。
你看,这么简单的问题都有最优子结构性质,只是因为显然没有重叠子问题,所以我们简单地求最值肯定用不出动态规划。
再举个例子:假设你们学校有 10 个班,你已知每个班的最大分数差(最高分和最低分的差值)。那么现在我让你计算全校学生中的最大分数差,你会不会算?可以想办法算,但是肯定不能通过已知的这 10 个班的最大分数差推到出来。因为这 10 个班的最大分数差不一定就包含全校学生的最大分数差,比如全校的最大分数差可能是 3 班的最高分和 6 班的最低分之差。
这次我给你提出的问题就不符合最优子结构,因为你没办通过每个班的最优值推出全校的最优值,没办法通过子问题的最优值推出规模更大的问题的最优值。前文「动态规划详解」说过,想满足最优子结,子问题之间必须互相独立。全校的最大分数差可能出现在两个班之间,显然子问题不独立,所以这个问题本身不符合最优子结构。
那么遇到这种最优子结构失效情况,怎么办?策略是:改造问题。对于最大分数差这个问题,我们不是没办法利用已知的每个班的分数差吗,那我只能这样写一段暴力代码:
int result = 0;
for (Student a : school) {
for (Student b : school) {
if (a is b) continue;
result = max(result, |a.score - b.score|);
}
}
return result;
改造问题,也就是把问题等价转化:最大分数差,不就等价于最高分数和最低分数的差么,那不就是要求最高和最低分数么,不就是我们讨论的第一个问题么,不就具有最优子结构了么?那现在改变思路,借助最优子结构解决最值问题,再回过头解决最大分数差问题,是不是就高效多了?
当然,上面这个例子太简单了,不过请读者回顾一下,我们做动态规划问题,是不是一直在求各种最值,本质跟我们举的例子没啥区别,无非需要处理一下重叠子问题。
前文不同定义不同解法 和 高楼扔鸡蛋进阶 就展示了如何改造问题,不同的最优子结构,可能导致不同的解法和效率。
再举个常见但也十分简单的例子,求一棵二叉树的最大值,不难吧(简单起见,假设节点中的值都是非负数):
int maxVal(TreeNode root) {
if (root == null)
return -1;
int left = maxVal(root.left);
int right = maxVal(root.right);
return max(root.val, left, right);
}
你看这个问题也符合最优子结构,以 root
为根的树的最大值,可以通过两边子树(子问题)的最大值推导出来,结合刚才学校和班级的例子,很容易理解吧。
当然这也不是动态规划问题,旨在说明,最优子结构并不是动态规划独有的一种性质,能求最值的问题大部分都具有这个性质;但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的,以后碰到那种恶心人的最值题,思路往动态规划想就对了,这就是套路。
动态规划不就是从最简单的 base case 往后推导吗,可以想象成一个链式反应,以小博大。但只有符合最优子结构的问题,才有发生这种链式反应的性质。
找最优子结构的过程,其实就是证明状态转移方程正确性的过程,方程符合最优子结构就可以写暴力解了,写出暴力解就可以看出有没有重叠子问题了,有则优化,无则 OK。这也是套路,经常刷题的朋友应该能体会。
这里就不举那些正宗动态规划的例子了,读者可以翻翻历史文章,看看状态转移是如何遵循最优子结构的,这个话题就聊到这,下面再来看另外个动态规划迷惑行为。
我相信读者做动态规问题时,肯定会对 dp
数组的遍历顺序有些头疼。我们拿二维 dp
数组来举例,有时候我们是正向遍历:
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
// 计算 dp[i][j]
有时候我们反向遍历:
for (int i = m - 1; i >= 0; i--)
for (int j = n - 1; j >= 0; j--)
// 计算 dp[i][j]
有时候可能会斜向遍历:
// 斜着遍历数组
for (int l = 2; l <= n; l++) {
for (int i = 0; i <= n - l; i++) {
int j = l + i - 1;
// 计算 dp[i][j]
}
}
甚至更让人迷惑的是,有时候发现正向反向遍历都可以得到正确答案,比如我们在「团灭股票问题」中有的地方就正反皆可。
那么,如果仔细观察的话可以发现其中的原因的。只要把住两点就行了:
1、遍历的过程中,所需的状态必须是已经计算出来的。
2、遍历的终点必须是存储结果的那个位置。
下面来距离解释上面两个原则是什么意思。
比如编辑距离这个经典的问题,详解见前文「编辑距离详解」,我们通过对 dp
数组的定义,确定了 base case 是 dp[..][0]
和 dp[0][..]
,最终答案是 dp[m][n]
;而且我们通过状态转移方程知道 dp[i][j]
需要从 dp[i-1][j]
, dp[i][j-1]
, dp[i-1][j-1]
转移而来,如下图:
那么,参考刚才说的两条原则,你该怎么遍历 dp
数组?肯定是正向遍历:
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
// 通过 dp[i-1][j], dp[i][j - 1], dp[i-1][j-1]
// 计算 dp[i][j]
因为,这样每一步迭代的左边、上边、左上边的位置都是 base case 或者之前计算过的,而且最终结束在我们想要的答案 dp[m][n]
。
再举一例,回文子序列问题,详见前文「子序列问题模板」,我们通过过对 dp
数组的定义,确定了 base case 处在中间的对角线,dp[i][j]
需要从 dp[i+1][j]
, dp[i][j-1]
, dp[i+1][j-1]
转移而来,想要求的最终答案是 dp[0][n-1]
,如下图:
要么从左至右斜着遍历,要么从下向上从左到右遍历,这样才能保证每次 dp[i][j]
的左边、下边、左下边已经计算完毕,得到正确结果。
现在,你应该理解了这两个原则,主要就是看 base case 和最终结果的存储位置,保证遍历过程中使用的数据都是计算完毕的就行,有时候确实存在多种方法可以得到正确答案,可根据个人口味自行选择。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
1143.最长公共子序列
最长公共子序列(Longest Common Subsequence,简称 LCS)是一道非常经典的面试题目,因为它的解法是典型的二维动态规划,大部分比较困难的字符串问题都和这个问题一个套路,比如说编辑距离。而且,这个算法稍加改造就可以用于解决其他问题,所以说 LCS 算法是值得掌握的。
题目就是让我们求两个字符串的 LCS 长度:
输入: str1 = "abcde", str2 = "ace"
输出: 3
解释: 最长公共子序列是 "ace",它的长度是 3
肯定有读者会问,为啥这个问题就是动态规划来解决呢?因为子序列类型的问题,穷举出所有可能的结果都不容易,而动态规划算法做的就是穷举 + 剪枝,它俩天生一对儿。所以可以说只要涉及子序列问题,十有八九都需要动态规划来解决,往这方面考虑就对了。
下面就来手把手分析一下,这道题目如何用动态规划技巧解决。
第一步,一定要明确 dp
数组的含义。对于两个字符串的动态规划问题,套路是通用的。
比如说对于字符串 s1
和 s2
,一般来说都要构造一个这样的 DP table:
为了方便理解此表,我们暂时认为索引是从 1 开始的,待会的代码中只要稍作调整即可。其中,dp[i][j]
的含义是:对于 s1[1..i]
和 s2[1..j]
,它们的 LCS 长度是 dp[i][j]
。
比如上图的例子,d[2][4] 的含义就是:对于 "ac"
和 "babc"
,它们的 LCS 长度是 2。我们最终想得到的答案应该是 dp[3][6]
。
第二步,定义 base case。
我们专门让索引为 0 的行和列表示空串,dp[0][..]
和 dp[..][0]
都应该初始化为 0,这就是 base case。
比如说,按照刚才 dp 数组的定义,dp[0][3]=0
的含义是:对于字符串 ""
和 "bab"
,其 LCS 的长度为 0。因为有一个字符串是空串,它们的最长公共子序列的长度显然应该是 0。
第三步,找状态转移方程。
这是动态规划最难的一步,不过好在这种字符串问题的套路都差不多,权且借这道题来聊聊处理这类问题的思路。
状态转移说简单些就是做选择,比如说这个问题,是求 s1
和 s2
的最长公共子序列,不妨称这个子序列为 lcs
。那么对于 s1
和 s2
中的每个字符,有什么选择?很简单,两种选择,要么在 lcs
中,要么不在。
这个「在」和「不在」就是选择,关键是,应该如何选择呢?这个需要动点脑筋:如果某个字符应该在 lcs
中,那么这个字符肯定同时存在于 s1
和 s2
中,因为 lcs
是最长公共子序列嘛。所以本题的思路是这样:
用两个指针 i
和 j
从后往前遍历 s1
和 s2
,如果 s1[i]==s2[j]
,那么这个字符一定在 lcs
中;否则的话,s1[i]
和 s2[j]
这两个字符至少有一个不在 lcs
中,需要丢弃一个。先看一下递归解法,比较容易理解:
def longestCommonSubsequence(str1, str2) -> int:
def dp(i, j):
# 空串的 base case
if i == -1 or j == -1:
return 0
if str1[i] == str2[j]:
# 这边找到一个 lcs 的元素,继续往前找
return dp(i - 1, j - 1) + 1
else:
# 谁能让 lcs 最长,就听谁的
return max(dp(i-1, j), dp(i, j-1))
# i 和 j 初始化为最后一个索引
return dp(len(str1)-1, len(str2)-1)
对于第一种情况,找到一个 lcs
中的字符,同时将 i
j
向前移动一位,并给 lcs
的长度加一;对于后者,则尝试两种情况,取更大的结果。
其实这段代码就是暴力解法,我们可以通过备忘录或者 DP table 来优化时间复杂度,比如通过前文描述的 DP table 来解决:
def longestCommonSubsequence(str1, str2) -> int:
m, n = len(str1), len(str2)
# 构建 DP table 和 base case
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 进行状态转移
for i in range(1, m + 1):
for j in range(1, n + 1):
if str1[i - 1] == str2[j - 1]:
# 找到一个 lcs 中的字符
dp[i][j] = 1 + dp[i-1][j-1]
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[-1][-1]
对于 s1[i]
和 s2[j]
不相等的情况,至少有一个字符不在 lcs
中,会不会两个字符都不在呢?比如下面这种情况:
所以代码是不是应该考虑这种情况,改成这样:
if str1[i - 1] == str2[j - 1]:
# ...
else:
dp[i][j] = max(dp[i-1][j],
dp[i][j-1],
dp[i-1][j-1])
我一开始也有这种怀疑,其实可以这样改,也能得到正确答案,但是多此一举,因为 dp[i-1][j-1]
永远是三者中最小的,max 根本不可能取到它。
原因在于我们对 dp 数组的定义:对于 s1[1..i]
和 s2[1..j]
,它们的 LCS 长度是 dp[i][j]
。
这样一看,显然 dp[i-1][j-1]
对应的 lcs
长度不可能比前两种情况大,所以没有必要参与比较。
对于两个字符串的动态规划问题,一般来说都是像本文一样定义 DP table,因为这样定义有一个好处,就是容易写出状态转移方程,dp[i][j]
的状态可以通过之前的状态推导出来:
找状态转移方程的方法是,思考每个状态有哪些「选择」,只要我们能用正确的逻辑做出正确的选择,算法就能够正确运行。
Shawn 提供 Java 代码:
public int longestCommonSubsequence(String text1, String text2) {
// 字符串转为char数组以加快访问速度
char[] str1 = text1.toCharArray();
char[] str2 = text2.toCharArray();
int m = str1.length, n = str2.length;
// 构建dp table,初始值默认为0
int[][] dp = new int[m + 1][n + 1];
// 状态转移
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
if (str1[i - 1] == str2[j - 1])
// 找到LCS中的字符
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
return dp[m][n];
}
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
72.编辑距离
前几天看了一份鹅厂的面试题,算法部分大半是动态规划,最后一题就是写一个计算编辑距离的函数,今天就专门写一篇文章来探讨一下这个问题。
我个人很喜欢编辑距离这个问题,因为它看起来十分困难,解法却出奇得简单漂亮,而且它是少有的比较实用的算法(是的,我承认很多算法问题都不太实用)。下面先来看下题目:
为什么说这个问题难呢,因为显而易见,它就是难,让人手足无措,望而生畏。
为什么说它实用呢,因为前几天我就在日常生活中用到了这个算法。之前有一篇公众号文章由于疏忽,写错位了一段内容,我决定修改这部分内容让逻辑通顺。但是公众号文章最多只能修改 20 个字,且只支持增、删、替换操作(跟编辑距离问题一模一样),于是我就用算法求出了一个最优方案,只用了 16 步就完成了修改。
再比如高大上一点的应用,DNA 序列是由 A,G,C,T 组成的序列,可以类比成字符串。编辑距离可以衡量两个 DNA 序列的相似度,编辑距离越小,说明这两段 DNA 越相似,说不定这俩 DNA 的主人是远古近亲啥的。
下面言归正传,详细讲解一下编辑距离该怎么算,相信本文会让你有收获。
编辑距离问题就是给我们两个字符串 s1
和 s2
,只能用三种操作,让我们把 s1
变成 s2
,求最少的操作数。需要明确的是,不管是把 s1
变成 s2
还是反过来,结果都是一样的,所以后文就以 s1
变成 s2
举例。
前文「最长公共子序列」说过,解决两个字符串的动态规划问题,一般都是用两个指针 i,j
分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模。
设两个字符串分别为 “rad” 和 “apple”,为了把 s1
变成 s2
,算法会这样进行:
请记住这个 GIF 过程,这样就能算出编辑距离。关键在于如何做出正确的操作,稍后会讲。
根据上面的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什么都不要做(skip)。比如这个情况:
因为这两个字符本来就相同,为了使编辑距离最小,显然不应该对它们有任何操作,直接往前移动 i,j
即可。
还有一个很容易处理的情况,就是 j
走完 s2
时,如果 i
还没走完 s1
,那么只能用删除操作把 s1
缩短为 s2
。比如这个情况:
类似的,如果 i
走完 s1
时 j
还没走完了 s2
,那就只能用插入操作把 s2
剩下的字符全部插入 s1
。等会会看到,这两种情况就是算法的 base case。
下面详解一下如何将思路转换成代码,坐稳,要发车了。
先梳理一下之前的思路:
base case 是 i
走完 s1
或 j
走完 s2
,可以直接返回另一个字符串剩下的长度。
对于每对儿字符 s1[i]
和 s2[j]
,可以有四种操作:
if s1[i] == s2[j]:
啥都别做(skip)
i, j 同时向前移动
else:
三选一:
插入(insert)
删除(delete)
替换(replace)
有这个框架,问题就已经解决了。读者也许会问,这个「三选一」到底该怎么选择呢?很简单,全试一遍,哪个操作最后得到的编辑距离最小,就选谁。这里需要递归技巧,理解需要点技巧,先看下代码:
def minDistance(s1, s2) -> int:
def dp(i, j):
# base case
if i == -1: return j + 1
if j == -1: return i + 1
if s1[i] == s2[j]:
return dp(i - 1, j - 1) # 啥都不做
else:
return min(
dp(i, j - 1) + 1, # 插入
dp(i - 1, j) + 1, # 删除
dp(i - 1, j - 1) + 1 # 替换
)
# i,j 初始化指向最后一个索引
return dp(len(s1) - 1, len(s2) - 1)
下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。
都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 dp(i, j) 函数的定义是这样的:
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
记住这个定义之后,先来看这段代码:
if s1[i] == s2[j]:
return dp(i - 1, j - 1) # 啥都不做
# 解释:
# 本来就相等,不需要任何操作
# s1[0..i] 和 s2[0..j] 的最小编辑距离等于
# s1[0..i-1] 和 s2[0..j-1] 的最小编辑距离
# 也就是说 dp(i, j) 等于 dp(i-1, j-1)
如果 s1[i]!=s2[j]
,就要对三个操作递归了,稍微需要点思考:
dp(i, j - 1) + 1, # 插入
# 解释:
# 我直接在 s1[i] 插入一个和 s2[j] 一样的字符
# 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比
# 别忘了操作数加一
dp(i - 1, j) + 1, # 删除
# 解释:
# 我直接把 s[i] 这个字符删掉
# 前移 i,继续跟 j 对比
# 操作数加一
dp(i - 1, j - 1) + 1 # 替换
# 解释:
# 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了
# 同时前移 i,j 继续对比
# 操作数加一
现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。
怎么能一眼看出存在重叠子问题呢?前文「动态规划之正则表达式」有提过,这里再简单提一下,需要抽象出本文算法的递归框架:
def dp(i, j):
dp(i - 1, j - 1) #1
dp(i, j - 1) #2
dp(i - 1, j) #3
对于子问题 dp(i-1, j-1)
,如何通过原问题 dp(i, j)
得到呢?有不止一条路径,比如 dp(i, j) -> #1
和 dp(i, j) -> #2 -> #3
。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
对于重叠子问题呢,前文「动态规划详解」详细介绍过,优化方法无非是备忘录或者 DP table。
备忘录很好加,原来的代码稍加修改即可:
def minDistance(s1, s2) -> int:
memo = dict() # 备忘录
def dp(i, j):
if (i, j) in memo:
return memo[(i, j)]
...
if s1[i] == s2[j]:
memo[(i, j)] = ...
else:
memo[(i, j)] = ...
return memo[(i, j)]
return dp(len(s1) - 1, len(s2) - 1)
主要说下 DP table 的解法:
首先明确 dp 数组的含义,dp 数组是一个二维数组,长这样:
有了之前递归解法的铺垫,应该很容易理解。dp[..][0]
和 dp[0][..]
对应 base case,dp[i][j]
的含义和之前的 dp 函数类似:
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
dp[i-1][j-1]
# 存储 s1[0..i] 和 s2[0..j] 的最小编辑距离
dp 函数的 base case 是 i,j
等于 -1,而数组索引至少是 0,所以 dp 数组会偏移一位。
既然 dp 数组和递归 dp 函数含义一样,也就可以直接套用之前的思路写代码,唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解:
int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
// base case
for (int i = 1; i <= m; i++)
dp[i][0] = i;
for (int j = 1; j <= n; j++)
dp[0][j] = j;
// 自底向上求解
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
if (s1.charAt(i-1) == s2.charAt(j-1))
dp[i][j] = dp[i - 1][j - 1];
else
dp[i][j] = min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i-1][j-1] + 1
);
// 储存着整个 s1 和 s2 的最小编辑距离
return dp[m][n];
}
int min(int a, int b, int c) {
return Math.min(a, Math.min(b, c));
}
一般来说,处理两个字符串的动态规划问题,都是按本文的思路处理,建立 DP table。为什么呢,因为易于找出状态转移的关系,比如编辑距离的 DP table:
还有一个细节,既然每个 dp[i][j]
只和它附近的三个状态有关,空间复杂度是可以压缩成 O(min(M, N))
的(M,N 是两个字符串的长度)。不难,但是可解释性大大降低,读者可以自己尝试优化一下。
你可能还会问,这里只求出了最小的编辑距离,那具体的操作是什么?你之前举的修改公众号文章的例子,只有一个最小编辑距离肯定不够,还得知道具体怎么修改才行。
这个其实很简单,代码稍加修改,给 dp 数组增加额外的信息即可:
// int[][] dp;
Node[][] dp;
class Node {
int val;
int choice;
// 0 代表啥都不做
// 1 代表插入
// 2 代表删除
// 3 代表替换
}
val
属性就是之前的 dp 数组的数值,choice
属性代表操作。在做最优选择时,顺便把操作记录下来,然后就从结果反推具体操作。
我们的最终结果不是 dp[m][n]
吗,这里的 val
存着最小编辑距离,choice
存着最后一个操作,比如说是插入操作,那么就可以左移一格:
重复此过程,可以一步步回到起点 dp[0][0]
,形成一条路径,按这条路径上的操作进行编辑,就是最佳方案。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
516.最长回文子序列
子序列问题是常见的算法问题,而且并不好解决。
首先,子序列问题本身就相对子串、子数组更困难一些,因为前者是不连续的序列,而后两者是连续的,就算穷举你都不一定会,更别说求解相关的算法问题了。
而且,子序列问题很可能涉及到两个字符串,比如前文「最长公共子序列」,如果没有一定的处理经验,真的不容易想出来。所以本文就来扒一扒子序列问题的套路,其实就有两种模板,相关问题只要往这两种思路上想,十拿九稳。
一般来说,这类问题都是让你求一个最长子序列,因为最短子序列就是一个字符嘛,没啥可问的。一旦涉及到子序列和最值,那几乎可以肯定,考察的是动态规划技巧,时间复杂度一般都是 O(n^2)。
原因很简单,你想想一个字符串,它的子序列有多少种可能?起码是指数级的吧,这种情况下,不用动态规划技巧,还想怎么着?
既然要用动态规划,那就要定义 dp 数组,找状态转移关系。我们说的两种思路模板,就是 dp 数组的定义思路。不同的问题可能需要不同的 dp 数组定义来解决。
1、第一种思路模板是一个一维的 dp 数组:
int n = array.length;
int[] dp = new int[n];
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}
举个我们写过的例子「最长递增子序列」,在这个思路中 dp 数组的定义是:
在子数组 array[0..i]
中,我们要求的子序列(最长递增子序列)的长度是 dp[i]
。
为啥最长递增子序列需要这种思路呢?前文说得很清楚了,因为这样符合归纳法,可以找到状态转移的关系,这里就不具体展开了。
2、第二种思路模板是一个二维的 dp 数组:
int n = arr.length;
int[][] dp = new dp[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (arr[i] == arr[j])
dp[i][j] = dp[i][j] + ...
else
dp[i][j] = 最值(...)
}
}
这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列,比如前文讲的「最长公共子序列」。本思路中 dp 数组含义又分为「只涉及一个字符串」和「涉及两个字符串」两种情况。
2.1 涉及两个字符串/数组时(比如最长公共子序列),dp 数组的含义如下:
在子数组 arr1[0..i]
和子数组 arr2[0..j]
中,我们要求的子序列(最长公共子序列)长度为 dp[i][j]
。
2.2 只涉及一个字符串/数组时(比如本文要讲的最长回文子序列),dp 数组的含义如下:
在子数组 array[i..j]
中,我们要求的子序列(最长回文子序列)的长度为 dp[i][j]
。
第一种情况可以参考这两篇旧文:「编辑距离」「公共子序列」
下面就借最长回文子序列这个问题,详解一下第二种情况下如何使用动态规划。
之前解决了「最长回文子串」的问题,这次提升难度,求最长回文子序列的长度:
我们说这个问题对 dp 数组的定义是:在子串 s[i..j]
中,最长回文子序列的长度为 dp[i][j]
。一定要记住这个定义才能理解算法。
为啥这个问题要这样定义二维的 dp 数组呢?我们前文多次提到,找状态转移需要归纳思维,说白了就是如何从已知的结果推出未知的部分,这样定义容易归纳,容易发现状态转移关系。
具体来说,如果我们想求 dp[i][j]
,假设你知道了子问题 dp[i+1][j-1]
的结果(s[i+1..j-1]
中最长回文子序列的长度),你是否能想办法算出 dp[i][j]
的值(s[i..j]
中,最长回文子序列的长度)呢?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Rg2wmGJI-1614346570122)(…/pictures/最长回文子序列/1.jpg)]
可以!这取决于 s[i]
和 s[j]
的字符:
如果它俩相等,那么它俩加上 s[i+1..j-1]
中的最长回文子序列就是 s[i..j]
的最长回文子序列:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xnPMLbCG-1614346570122)(…/pictures/最长回文子序列/2.jpg)]
如果它俩不相等,说明它俩不可能同时出现在 s[i..j]
的最长回文子序列中,那么把它俩分别加入 s[i+1..j-1]
中,看看哪个子串产生的回文子序列更长即可:
以上两种情况写成代码就是这样:
if (s[i] == s[j])
// 它俩一定在最长回文子序列中
dp[i][j] = dp[i + 1][j - 1] + 2;
else
// s[i+1..j] 和 s[i..j-1] 谁的回文子序列更长?
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
至此,状态转移方程就写出来了,根据 dp 数组的定义,我们要求的就是 dp[0][n - 1]
,也就是整个 s
的最长回文子序列的长度。
首先明确一下 base case,如果只有一个字符,显然最长回文子序列长度是 1,也就是 dp[i][j] = 1 (i == j)
。
因为 i
肯定小于等于 j
,所以对于那些 i > j
的位置,根本不存在什么子序列,应该初始化为 0。
另外,看看刚才写的状态转移方程,想求 dp[i][j]
需要知道 dp[i+1][j-1]
,dp[i+1][j]
,dp[i][j-1]
这三个位置;再看看我们确定的 base case,填入 dp 数组之后是这样:
为了保证每次计算 dp[i][j]
,左下右方向的位置已经被计算出来,只能斜着遍历或者反着遍历:
我选择反着遍历,代码如下:
int longestPalindromeSubseq(string s) {
int n = s.size();
// dp 数组全部初始化为 0
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case
for (int i = 0; i < n; i++)
dp[i][i] = 1;
// 反着遍历保证正确的状态转移
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 状态转移方程
if (s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1] + 2;
else
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
// 整个 s 的最长回文子串长度
return dp[0][n - 1];
}
至此,最长回文子序列的问题就解决了。
相关推荐:
可以说动态规划技巧对于算法效率的提升非常可观,一般来说都能把指数级和阶乘级时间复杂度的算法优化成 O(N^2),堪称算法界的二向箔,把各路魑魅魍魉统统打成二次元。
但是,动态规划本身也是可以进行阶段性优化的,比如说我们常听说的「状态压缩」技巧,就能够把很多动态规划解法的空间复杂度进一步降低,由 O(N^2) 降低到 O(N),
能够使用状态压缩技巧的动态规划都是二维 dp
问题,你看它的状态转移方程,如果计算状态 dp[i][j]
需要的都是 dp[i][j]
相邻的状态,那么就可以使用状态压缩技巧,将二维的 dp
数组转化成一维,将空间复杂度从 O(N^2) 降低到 O(N)。
什么叫「和 dp[i][j]
相邻的状态」呢,比如前文 最长回文子序列 中,最终的代码如下:
//之前解决了「最长回文子串」的问题,这次提升难度,求最长回文子序列的长度:
class longestPalindromeSubseq {
static int longestPalindromeSubseq(String s){
int n = s.length();
// 建一个dp 2维空数组
int[][] dp = new int[n][n];
// base case 如果只有一个字符,显然最长回文子序列长度是 1
for (int i = 0; i < n; i++)
dp[i][i] = 1;
// 反着遍历保证正确的状态转移
for (int i = n-2; i >=0 ; i--) {
for (int j = i+1; j < n; j++) {
//状态转移方程
if(s.charAt(i)==s.charAt(j)){
dp[i][j] = dp[i+1][j-1]+2;
}else{
dp[i][j] = Math.max(dp[i+1][j],dp[i][j-1]);
}
}
}
// 整个 s 的最长回文子串长度
return dp[0][n-1];
}
public static void main(String[] args) {
String test = "sedfhtghdes";
System.out.println(longestPalindromeSubseq(test));
}
}
PS:我们本文不探讨如何推状态转移方程,只探讨对二维 DP 问题进行状态压缩的技巧。技巧都是通用的,所以如果你没看过前文,不明白这段代码的逻辑也无妨,完全不会阻碍你学会状态压缩。
你看我们对 dp[i][j]
的更新,其实只依赖于 dp[i+1][j-1], dp[i][j-1], dp[i+1][j]
这三个状态:
这就叫和 dp[i][j]
相邻,反正你计算 dp[i][j]
只需要这三个相邻状态,其实根本不需要那么大一个二维的 dp table 对不对?状态压缩的核心思路就是,将二维数组「投影」到一维数组:
思路很直观,但是也有一个明显的问题,图中 dp[i][j-1]
和 dp[i+1][j-1]
这两个状态处在同一列,而一维数组中只能容下一个,那么当我计算 dp[i][j]
时,他俩必然有一个会被另一个覆盖掉,怎么办?
这就是状态压缩的难点,下面就来分析解决这个问题,还是拿「最长回文子序列」问题距离,它的状态转移方程主要逻辑就是如下这段代码:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 状态转移方程
if (s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1] + 2;
else
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
想把二维 dp
数组压缩成一维,一般来说是把第一个维度,也就是 i
这个维度去掉,只剩下 j
这个维度。压缩后的一维 dp
数组就是之前二维 dp
数组的 dp[i][..]
那一行。
我们先将上述代码进行改造,直接无脑去掉 i
这个维度,把 dp
数组变成一维:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 在这里,一维 dp 数组中的数是什么?
if (s[i] == s[j])
dp[j] = dp[j - 1] + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
}
}
上述代码的一维 dp
数组只能表示二维 dp
数组的一行 dp[i][..]
,那我怎么才能得到 dp[i+1][j-1], dp[i][j-1], dp[i+1][j]
这几个必要的的值,进行状态转移呢?
在代码中注释的位置,将要进行状态转移,更新 dp[j]
,那么我们要来思考两个问题:
1、在对 dp[j]
赋新值之前,dp[j]
对应着二维 dp
数组中的什么位置?
2、dp[j-1]
对应着二维 dp
数组中的什么位置?
对于问题 1,在对 dp[j]
赋新值之前,dp[j]
的值就是外层 for 循环上一次迭代算出来的值,也就是对应二维 dp
数组中 dp[i+1][j]
的位置。
**对于问题 2,dp[j-1]
的值就是内层 for 循环上一次迭代算出来的值,也就是对应二维 **dp
数组中 dp[i][j-1]
的位置。
那么问题已经解决了一大半了,只剩下二维 dp
数组中的 dp[i+1][j-1]
这个状态我们不能直接从一维 dp
数组中得到:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
if (s[i] == s[j])
// dp[i][j] = dp[i+1][j-1] + 2;
dp[j] = ?? + 2;
else
// dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
dp[j] = max(dp[j], dp[j - 1]);
}
}
因为 for 循环遍历 i
和 j
的顺序为从左向右,从下向上,所以可以发现,在更新一维 dp
数组的时候,dp[i+1][j-1]
会被 dp[i][j-1]
覆盖掉,图中标出了这四个位置被遍历到的次序:
那么如果我们想得到 dp[i+1][j-1]
,就必须在它被覆盖之前用一个临时变量 temp
把它存起来,并把这个变量的值保留到计算 dp[i][j]
的时候。为了达到这个目的,结合上图,我们可以这样写代码:
for (int i = n - 2; i >= 0; i--) {
// 存储 dp[i+1][j-1] 的变量
int pre = 0;
for (int j = i + 1; j < n; j++) {
int temp = dp[j];
if (s[i] == s[j])
// dp[i][j] = dp[i+1][j-1] + 2;
dp[j] = pre + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
// 到下一轮循环,pre 就是 dp[i+1][j-1] 了
pre = temp;
}
}
别小看这段代码,这是一维 dp
最精妙的地方,会者不难,难者不会。为了清晰起见,我用具体的数值来拆解这个逻辑:
假设现在 i = 5, j = 7
且 s[5] == s[7]
,那么现在会进入下面这个逻辑对吧:
if (s[5] == s[7])
// dp[5][7] = dp[i+1][j-1] + 2;
dp[7] = pre + 2;
我问你这个 pre
变量是什么?是内层 for 循环上一次迭代的 temp
值。
那我再问你内层 for 循环上一次迭代的 temp
值是什么?是 dp[j-1]
也就是 dp[6]
,但这是外层 for 循环上一次迭代对应的 dp[6]
,也就是二维 dp
数组中的 dp[i+1][6] = dp[6][6]
。
也就是说,pre
变量就是 dp[i+1][j-1] = dp[6][6]
,也就是我们想要的结果。
那么现在我们成功对状态转移方程进行了降维打击,算是最硬的的骨头啃掉了,但注意到我们还有 base case 要处理呀:
// dp 数组全部初始化为 0
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case
for (int i = 0; i < n; i++)
dp[i][i] = 1;
如何把 base case 也打成一维呢?很简单,记住状态压缩就是投影,我们把 base case 投影到一维看看:
二维 dp
数组中的 base case 全都落入了一维 dp
数组,不存在冲突和覆盖,所以说我们直接这样写代码就行了:
// 一维 dp 数组全部初始化为 1
vector<int> dp(n, 1);
至此,我们把 base case 和状态转移方程都进行了降维,实际上已经写出完整代码了:
class longestPalindromeSubseq1D {
static int longestPalindromeSubseq(String s){
int n = s.length();
// 建一个dp 1维空数组
int[] dp = new int[n];
// base case 如果只有一个字符,显然最长回文子序列长度是 1
for (int i = 0; i < n; i++)
dp[i] = 1;
// 反着遍历保证正确的状态转移
for (int i = n-2; i >=0 ; i--) {
int pre = 0;
for (int j = i+1; j < n; j++) {
//暂存dp[j] 给下次使用
int tmp = dp[j];
//状态转移方程
if(s.charAt(i)==s.charAt(j)){
dp[j] = pre+2;
}else{
dp[j] = Math.max(dp[j],dp[j-1]);
}
pre = tmp;
}
}
// 整个 s 的最长回文子串长度
return dp[n-1];
}
public static void main(String[] args) {
String test = "sedfhtghdes";
System.out.println(longestPalindromeSubseq(test));
}
}
本文就结束了,不过状态压缩技巧再牛逼,也是基于常规动态规划思路之上的。
你也看到了,使用状态压缩技巧对二维 dp
数组进行降维打击之后,解法代码的可读性变得非常差了,如果直接看这种解法,任何人都是一脸懵逼的。算法的优化就是这么一个过程,先写出可读性很好的暴力递归算法,然后尝试运用动态规划技巧优化重叠子问题,最后尝试用状态压缩技巧优化空间复杂度。
也就是说,你最起码能够熟练运用我们前文 动态规划框架套路详解 的套路找出状态转移方程,写出一个正确的动态规划解法,然后才有可能观察状态转移的情况,分析是否可能使用状态压缩技巧来优化。
希望读者能够稳扎稳打,层层递进,对于这种比较极限的优化,不做也罢。毕竟套路存于心,走遍天下都不怕!
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
1312.让字符串成为回文串的最少插入次数(困难)
回文串就是正着读反着读都一样的字符,在笔试面试中经常出现这类问题。
回文问题的文章,判断回文串或者寻找最长回文串/子序列的:
判断回文链表
计算最长回文子串
计算最长回文子序列
本文就来研究一道构造回文串的问题,难度 Hard 计算让字符串成为回文串的最少插入次数:
输入一个字符串 s
,你可以在字符串的任意位置插入任意字符。如果要把 s
变成回文串,请你计算最少要进行多少次插入?
函数签名如下:
int minInsertions(string s);
比如说输入 s = "abcea"
,算法返回 2,因为可以给 s
插入 2 个字符变成回文串 "abeceba"
或者 "aebcbea"
。如果输入 s = "aba"
,则算法返回 0,因为 s
已经是回文串,不用插入任何字符。
首先,要找最少的插入次数,那肯定得穷举喽,如果我们用暴力算法穷举出所有插入方法,时间复杂度是多少?
每次都可以在两个字符的中间插入任意一个字符,外加判断字符串是否为回文字符串,这时间复杂度肯定爆炸,是指数级。
那么无疑,这个问题需要使用动态规划技巧来解决。之前的文章说过,回文问题一般都是从字符串的中间向两端扩散,构造回文串也是类似的。
我们定义一个二维的 dp
数组,****dp[i][j]
的定义如下:对字符串 s[i..j]
****,最少需要进行 dp[i][j]
次插入才能变成回文串。
我们想求整个 s
的最少插入次数,根据这个定义,也就是想求 dp[0][n-1]
的大小(n
为 s
的长度)。
同时,base case 也很容易想到,当 i == j
时 dp[i][j] = 0
,因为当 i == j
时 s[i..j]
就是一个字符,本身就是回文串,所以不需要进行任何插入操作。
接下来就是动态规划的重头戏了,利用数学归纳法思考状态转移方程。
状态转移就是从小规模问题的答案推导更大规模问题的答案,从 base case 向其他状态推导嘛。如果我们现在想计算 dp[i][j]
的值,而且假设我们已经计算出了子问题 dp[i+1][j-1]
的值了,你能不能想办法推出 dp[i][j]
的值呢?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-25jRdOHf-1614346707688)(…\pictures\回文\1.jpeg)]
既然已经算出 dp[i+1][j-1]
,即知道了 s[i+1..j-1]
成为回文串的最小插入次数,那么也就可以认为 s[i+1..j-1]
已经是一个回文串了,所以通过 dp[i+1][j-1]
推导 dp[i][j]
的关键就在于 s[i]
和 s[j]
这两个字符。
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kZ9mwEAD-1614346707689)(…\pictures\回文\2.jpeg)]](https://img-blog.csdnimg.cn/20210228125
这个得分情况讨论,如果 s[i] == s[j]
的话,我们不需要进行任何插入,只要知道如何把 s[i+1..j-1]
变成回文串即可:
翻译成代码就是这样:
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1];
}
如果 s[i] != s[j]
的话,就比较麻烦了,比如下面这种情况:
最简单的想法就是,先把 s[j]
插到 s[i]
右边,同时把 s[i]
插到 s[j]
右边,这样构造出来的字符串一定是回文串:
PS:当然,把 s[j]
插到 s[i]
左边,然后把 s[i]
插到 s[j]
左边也是一样的,后面会分析。
但是,这是不是就意味着代码可以直接这样写呢?
if (s[i] != s[j]) {
// 把 s[j] 插到 s[i] 右边,把 s[i] 插到 s[j] 右边
dp[i][j] = dp[i + 1][j - 1] + 2;
}
不对,比如说如下这两种情况,只需要插入一个字符即可使得 s[i..j]
变成回文:
所以说,当 s[i] != s[j]
时,无脑插入两次肯定是可以让 s[i..j]
变成回文串,但是不一定是插入次数最少的,最优的插入方案应该被拆解成如下流程:
步骤一,做选择,先将 s[i..j-1]
或者 s[i+1..j]
变成回文串。怎么做选择呢?谁变成回文串的插入次数少,就选谁呗。
比如图二的情况,将 s[i+1..j]
变成回文串的代价小,因为它本身就是回文串,根本不需要插入;同理,对于图三,将 s[i..j-1]
变成回文串的代价更小。
然而,如果 s[i+1..j]
和 s[i..j-1]
都不是回文串,都至少需要插入一个字符才能变成回文,所以选择哪一个都一样:
那我怎么知道 s[i+1..j]
和 s[i..j-1]
谁变成回文串的代价更小呢?
回头看看 dp
数组的定义是什么,dp[i+1][j]
和 dp[i][j-1]
不就是它们变成回文串的代价么?
步骤二,根据步骤一的选择,将 s[i..j]
变成回文。
如果你在步骤一中选择把 s[i+1..j]
变成回文串,那么在 s[i+1..j]
右边插入一个字符 s[i]
一定可以将 s[i..j]
变成回文;同理,如果在步骤一中选择把 s[i..j-1]
变成回文串,在 s[i..j-1]
左边插入一个字符 s[j]
一定可以将 s[i..j]
变成回文。
那么根据刚才对 dp
数组的定义以及以上的分析,s[i] != s[j]
时的代码逻辑如下:
if (s[i] != s[j]) {
// 步骤一选择代价较小的
// 步骤二必然要进行一次插入
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
综合起来,状态转移方程如下:
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1];
} else {
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
这就是动态规划算法核心,我们可以直接写出解法代码了。
首先想想 base case 是什么,当 i == j
时 dp[i][j] = 0
,因为这时候 s[i..j]
就是单个字符,本身就是回文串,不需要任何插入;最终的答案是 dp[0][n-1]
(n
是字符串 s
的长度)。那么 dp table 长这样:
又因为状态转移方程中 dp[i][j]
和 dp[i+1][j]
,dp[i]-1]
,dp[i+1][j-1]
三个状态有关,为了保证每次计算 dp[i][j]
时,这三个状态都已经被计算,我们一般选择从下向上,从左到右遍历 dp
数组:
完整代码如下:
int minInsertions(string s) {
int n = s.size();
// 定义:对 s[i..j],最少需要插入 dp[i][j] 次才能变成回文
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case:i == j 时 dp[i][j] = 0,单个字符本身就是回文
// dp 数组已经全部初始化为 0,base case 已初始化
// 从下向上遍历
for (int i = n - 2; i >= 0; i--) {
// 从左向右遍历
for (int j = i + 1; j < n; j++) {
// 根据 s[i] 和 s[j] 进行状态转移
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1];
} else {
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
}
}
// 根据 dp 数组的定义,题目要求的答案
return dp[0][n - 1];
}
现在这道题就解决了,时间和空间复杂度都是 O(N^2)。还有一个小优化,注意到 dp
数组的状态之和它相邻的状态有关,所以 dp
数组是可以压缩成一维的:
public class minInsertion1D {
int minInsertions(String s){
// 记录dp[i+1][j-1]
int n = s.length();
int[] dp = new int[n];
int tmp = 0;
for (int i = n-2; i >=0 ; i--) {
int pre = 0;
for (int j = i+1; j < n; j++) {
tmp = dp[j];
if(s.charAt(i)==s.charAt(j))
dp[j] = pre;//dp[i][j] = dp[i+1][j-1]
else
dp[j] = Math.min(dp[j],dp[j-1])+1;//dp[j] = min(dp[j],dp[j-1])+1
}
pre = tmp;
}
return dp[n-1];
}
public static void main(String[] args) {
String s = "ssdff";
minInsertion1D minTest1D = new minInsertion1D();
System.out.println(minTest1D.minInsertions(s));
}
}
至于这个状态压缩是怎么做的,我们前文 状态压缩技巧 详细介绍过,这里就不展开了。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
10.正则表达式匹配
正则表达式是一个非常强力的工具,本文就来具体看一看正则表达式的底层原理是什么。力扣第 10 题「正则表达式匹配」就要求我们实现一个简单的正则匹配算法,包括「.」通配符和「*」通配符。
这两个通配符是最常用的,其中点号「.」可以匹配任意一个字符,星号「*」可以让之前的那个字符重复任意次数(包括 0 次)。
比如说模式串 ".a*b"
就可以匹配文本 "zaaab"
,也可以匹配 "cb"
;模式串 "a..b"
可以匹配文本 "amnb"
;而模式串 ".*"
就比较牛逼了,它可以匹配任何文本。
题目会给我们输入两个字符串 s
和 p
,s
代表文本,p
代表模式串,请你判断模式串 p
是否可以匹配文本 s
。我们可以假设模式串只包含小写字母和上述两种通配符且一定合法,不会出现 *a
或者 b**
这种不合法的模式串,
函数签名如下:
bool isMatch(string s, string p);
对于我们将要实现的这个正则表达式,难点在那里呢?
点号通配符其实很好实现,s
中的任何字符,只要遇到 .
通配符,无脑匹配就完事了。主要是这个星号通配符不好实现,一旦遇到 *
通配符,前面的那个字符可以选择重复一次,可以重复多次,也可以一次都不出现,这该怎么办?
对于这个问题,答案很简单,对于所有可能出现的情况,全部穷举一遍,只要有一种情况可以完成匹配,就认为 p
可以匹配 s
。那么一旦涉及两个字符串的穷举,我们就应该条件反射地想到动态规划的技巧了。
我们先脑补一下,s
和 p
相互匹配的过程大致是,两个指针 i, j
分别在 s
和 p
上移动,如果最后两个指针都能移动到字符串的末尾,那么久匹配成功,反之则匹配失败。
如果不考虑 *
通配符,面对两个待匹配字符 s[i]
和 p[j]
,我们唯一能做的就是看他俩是否匹配:
bool isMatch(string s, string p) {
int i = 0, j = 0;
while (i < s.size() && j < p.size()) {
// 「.」通配符就是万金油
if (s[i] == p[j] || p[j] == '.') {
// 匹配,接着匹配 s[i+1..] 和 p[j+1..]
i++; j++;
} else {
// 不匹配
return false;
}
}
return i == j;
}
那么考虑一下,如果加入 *
通配符,局面就会稍微复杂一些,不过只要分情况来分析,也不难理解。
当 p[j + 1]
为 *
通配符时,我们分情况讨论下:
1、如果 s[i] == p[j]
,那么有两种情况:
1.1 p[j]
有可能会匹配多个字符,比如 s = "aaa", p = "a*"
,那么 p[0]
会通过 *
匹配 3 个字符 "a"
。
1.2 p[i]
也有可能匹配 0 个字符,比如 s = "aa", p = "a*aa"
,由于后面的字符可以匹配 s
,所以 p[0]
只能匹配 0 次。
2、如果 s[i] != p[j]
,只有一种情况:
p[j]
只能匹配 0 次,然后看下一个字符是否能和 s[i]
匹配。比如说 s = "aa", p = "b*aa"
,此时 p[0]
只能匹配 0 次。
综上,可以把之前的代码针对 *
通配符进行一下改造:
if (s[i] == p[j] || p[j] == '.') {
// 匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 有 * 通配符,可以匹配 0 次或多次
} else {
// 无 * 通配符,老老实实匹配 1 次
i++; j++;
}
} else {
// 不匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 有 * 通配符,只能匹配 0 次
} else {
// 无 * 通配符,匹配无法进行下去了
return false;
}
}
整体的思路已经很清晰了,但现在的问题是,遇到 *
通配符时,到底应该匹配 0 次还是匹配多次?多次是几次?
你看,这就是一个做「选择」的问题,要把所有可能的选择都穷举一遍才能得出结果。动态规划算法的核心就是「状态」和「选择」,「状态」无非就是 i
和 j
两个指针的位置,「选择」就是 p[j]
选择匹配几个字符。
根据「状态」,我们可以定义一个 dp
函数:
bool dp(String s, int i, String p, int j);
dp
函数的定义如下:
若 dp(s, i, p, j) = true
,则表示 s[i..]
可以匹配 p[j..]
;若 dp(s, i, p, j) = false
,则表示 s[i..]
无法匹配 p[j..]
。
根据这个定义,我们想要的答案就是 i = 0, j = 0
时 dp
函数的结果,所以可以这样使用这个 dp
函数:
boolean isMatch(String s, String p) {
// 指针 i,j 从索引 0 开始移动
return dp(s, 0, p, 0);
可以根据之前的代码写出 dp
函数的主要逻辑:
bool dp(string& s, int i, string& p, int j) {
if (s[i] == p[j] || p[j] == '.') {
// 匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 1.1 通配符匹配 0 次或多次
return dp(s, i, p, j + 2)
|| dp(s, i + 1, p, j);
} else {
// 1.2 常规匹配 1 次
return dp(s, i + 1, p, j + 1);
}
} else {
// 不匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 2.1 通配符匹配 0 次
return dp(s, i, p, j + 2);
} else {
// 2.2 无法继续匹配
return false;
}
}
}
根据 dp
函数的定义,这几种情况都很好解释:
1.1 通配符匹配 0 次或多次
将 j
加 2,i
不变,含义就是直接跳过 p[j]
和之后的通配符,即通配符匹配 0 次:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3WbnEw5N-1614346773136)(…/pictures/正则/1.jpeg)]
将 i
加 1,j
不变,含义就是 p[j]
匹配了 s[i]
,但 p[j]
还可以继续匹配,即通配符匹配多次的情况:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xHk9Ele5-1614346773138)(…/pictures/正则/2.jpeg)]
两种情况只要有一种可以完成匹配即可,所以对上面两种情况求或运算。
1.2 常规匹配 1 次
由于这个条件分支是无 *
的常规匹配,那么如果 s[i] == p[j]
,就是 i
和 j
分别加一:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fQcBmLLE-1614346773139)(…/pictures/正则/3.jpeg)]
2.1 通配符匹配 0 次
类似情况 1.1,将 j
加 2,i
不变:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MZpz5T5A-1614346773140)(…/pictures/正则/1.jpeg)]
2.2 如果没有 *
通配符,也无法匹配,那只能说明匹配失败了:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2nnuImwg-1614346773142)(…/pictures/正则/4.jpeg)]
看图理解应该很容易了,现在可以思考一下 dp
函数的 base case:
一个 base case 是 j == p.size()
时,按照 dp
函数的定义,这意味着模式串 p
已经被匹配完了,那么应该看看文本串 s
匹配到哪里了,如果 s
也恰好被匹配完,则说明匹配成功:
if (j == p.size()) {
return i == s.size();
}
另一个 base case 是 i == s.size()
时,按照 dp
函数的定义,这种情况意味着文本串 s
已经全部被匹配了,那么是不是只要简单地检查一下 p
是否也匹配完就行了呢?
if (i == s.size()) {
// 这样行吗?
return j == p.size();
}
这是不正确的,此时并不能根据 j
是否等于 p.size()
来判断是否完成匹配,只要 p[j..]
能够匹配空串,就可以算完成匹配。比如说 s = "a", p = "ab*c*"
,当 i
走到 s
末尾的时候,j
并没有走到 p
的末尾,但是 p
依然可以匹配 s
。
所以我们可以写出如下代码:
int m = s.size(), n = p.size();
if (i == s.size()) {
// 如果能匹配空串,一定是字符和 * 成对儿出现
if ((n - j) % 2 == 1) {
return false;
}
// 检查是否为 x*y*z* 这种形式
for (; j + 1 < p.size(); j += 2) {
if (p[j + 1] != '*') {
return false;
}
}
return true;
}
根据以上思路,就可以写出完整的代码:
/* 计算 p[j..] 是否匹配 s[i..] */
bool dp(string& s, int i, string& p, int j) {
int m = s.size(), n = p.size();
// base case
if (j == n) {
return i == m;
}
if (i == m) {
if ((n - j) % 2 == 1) {
return false;
}
for (; j + 1 < n; j += 2) {
if (p[j + 1] != '*') {
return false;
}
}
return true;
}
// 记录状态 (i, j),消除重叠子问题
string key = to_string(i) + "," + to_string(j);
if (memo.count(key)) return memo[key];
bool res = false;
if (s[i] == p[j] || p[j] == '.') {
if (j < n - 1 && p[j + 1] == '*') {
res = dp(s, i, p, j + 2)
|| dp(s, i + 1, p, j);
} else {
res = dp(s, i + 1, p, j + 1);
}
} else {
if (j < n - 1 && p[j + 1] == '*') {
res = dp(s, i, p, j + 2);
} else {
res = false;
}
}
// 将当前结果记入备忘录
memo[key] = res;
return res;
}
代码中用了一个哈希表 memo
消除重叠子问题,因为正则表达算法的递归框架如下:
bool dp(string& s, int i, string& p, int j) {
dp(s, i, p, j + 2); // 1
dp(s, i + 1, p, j); // 2
dp(s, i + 1, p, j + 1); // 3
}
那么,如果让你从 dp(s, i, p, j)
得到 dp(s, i+2, p, j+2)
,至少有两条路径:1 -> 2 -> 2
和 3 -> 3
,那么就说明 (i+2, j+2)
这个状态存在重复,这就说明存在重叠子问题。
动态规划的时间复杂度为「状态的总数」*「每次递归花费的时间」,本题中状态的总数当然就是 i
和 j
的组合,也就是 M * N
(M
为 s
的长度,N
为 p
的长度);递归函数 dp
中没有循环(base case 中的不考虑,因为 base case 的触发次数有限),所以一次递归花费的时间为常数。二者相乘,总的时间复杂度为 O(MN)
。
空间复杂度很简单,就是备忘录 memo
的大小,即 O(MN)
。
今天聊一道 4 键键盘问题,这个问题挺有意思,而且可以明显感受到:对 dp 数组的不同定义需要完全不同的逻辑,从而产生完全不同的解法。
如何在 N 次敲击按钮后得到最多的 A?我们穷举呗,对于每次按键,我们可以穷举四种可能,很明显就是一个动态规划问题。
这种思路会很容易理解,但是效率并不高,我们直接走流程:对于动态规划问题,首先要明白有哪些「状态」,有哪些「选择」。
具体到这个问题,对于每次敲击按键,有哪些「选择」是很明显的:4 种,就是题目中提到的四个按键,分别是A
、C-A
、C-C
、C-V
(Ctrl
简写为C
)。
接下来,思考一下对于这个问题有哪些「状态」?或者换句话说,我们需要知道什么信息,才能将原问题分解为规模更小的子问题?
你看我这样定义三个状态行不行:第一个状态是剩余的按键次数,用n
表示;第二个状态是当前屏幕上字符 A 的数量,用a_num
表示;第三个状态是剪切板中字符 A 的数量,用copy
表示。
如此定义「状态」,就可以知道 base case:当剩余次数n
为 0 时,a_num
就是我们想要的答案。
结合刚才说的 4 种「选择」,我们可以把这几种选择通过状态转移表示出来:
dp(n - 1, a_num + 1, copy), # A
解释:按下 A 键,屏幕上加一个字符
同时消耗 1 个操作数
dp(n - 1, a_num + copy, copy), # C-V
解释:按下 C-V 粘贴,剪切板中的字符加入屏幕
同时消耗 1 个操作数
dp(n - 2, a_num, a_num) # C-A C-C
解释:全选和复制必然是联合使用的,
剪切板中 A 的数量变为屏幕上 A 的数量
同时消耗 2 个操作数
这样可以看到问题的规模n
在不断减小,肯定可以到达n = 0
的 base case,所以这个思路是正确的:
class dpfourKeyboard{
int dp(int n,int a_num,int copy){
if(n <= 0) return a_num;
return max(
dp(n - 1, a_num + 1, copy),
dp(n-1,a_num+copy,copy),
dp(n-2,a_num,a_num)
);
}
private int max(int dp, int dp1, int dp2) {
return Math.max(dp,Math.max(dp1,dp2));
}
//return dp(n,0,0);
public static void main(String[] args) {
dpfourKeyboard test = new dpfourKeyboard();
System.out.println(test.dp(3,0,0));
}
}
PS:这个思路和前文 详解一道腾讯面试题:编辑距离 有异曲同工之妙,如果有疑问的话可以去看看。
这个解法应该不难理解,因为语义明确。下面就继续走流程,用备忘录消除一下重叠子问题:
def maxA(N: int) -> int:
# 备忘录
memo = dict()
def dp(n, a_num, copy):
if n <= 0: return a_num;
# 避免计算重叠子问题
if (n, a_num, copy) in memo:
return memo[(n, a_num, copy)]
memo[(n, a_num, copy)] = max(
# 几种选择还是一样的
)
return memo[(n, a_num, copy)]
return dp(N, 0, 0)
这样优化代码之后,子问题虽然没有重复了,但数目仍然很多,在 LeetCode 提交会超时的。
尝试分析一下这个算法的时间复杂度,就会发现不容易分析。我们可以把这个 dp 函数写成 dp 数组:
dp[n][a_num][copy]
# 状态的总数(时空复杂度)就是这个三维数组的体积
我们知道变量n
最多为N
,但是a_num
和copy
最多为多少我们很难计算,复杂度起码也有 O(N^3) 吧。所以这个算法并不好,复杂度太高,且已经无法优化了。
这也就说明,这样定义「状态」是不太优秀的,下面我们换一种定义 dp 的思路。
这种思路稍微有点复杂,但是效率高。继续走流程,「选择」还是那 4 个,但是这次我们只定义一个「状态」,也就是剩余的敲击次数n
。
这个算法基于这样一个事实,最优按键序列一定只有两种情况:
要么一直按A
:A,A,…A(当 N 比较小时)。
要么是这么一个形式:A,A,…C-A,C-C,C-V,C-V,…C-V(当 N 比较大时)。
因为字符数量少(N 比较小)时,C-A C-C C-V
这一套操作的代价相对比较高,可能不如一个个按A
;而当 N 比较大时,后期C-V
的收获肯定很大。这种情况下整个操作序列大致是:开头连按几个A
,然后C-A C-C
组合再接若干C-V
,然后再C-A C-C
接着若干C-V
,循环下去。
换句话说,最后一次按键要么是A
要么是C-V
。明确了这一点,可以通过这两种情况来设计算法:
int[] dp = new int[N + 1];
// 定义:dp[i] 表示 i 次操作后最多能显示多少个 A
for (int i = 0; i <= N; i++)
dp[i] = max(
这次按 A 键,
这次按 C-V
)
对于「按A
键」这种情况,就是状态i - 1
的屏幕上新增了一个 A 而已,很容易得到结果:
// 按 A 键,就比上次多一个 A 而已
dp[i] = dp[i - 1] + 1;
刚才说了,最优的操作序列一定是C-A C-C
接着若干C-V
,所以我们用一个变量j
作为若干C-V
的起点。那么j
之前的 2 个操作就应该是C-A C-C
了:
public class dpfourKeyboardMax {
int maxA(int n){
int[] dp = new int[n+1];
dp[0] = 0;
int countA = 0;
for (int i = 1; i <= n; i++) {
//按A
dp[i] = dp[i-1]+1;
//按C-V
for (int j = 2; j <i; j++) {
//全选并复制dp[j-2],连续粘贴i-j次
//屏幕共dp[j-2]*[i-j+1]
dp[i] = Math.max(dp[i],dp[j-2]*(i-j+1));
}
}
System.out.println("总次数:"+n+"次");
//按N次键之后最多几个A
return dp[n] ;
}
public static void main(String[] args) {
dpfourKeyboardMax test = new dpfourKeyboardMax();
System.out.println(test.maxA(3));
System.out.println(test.maxA(4));
System.out.println(test.maxA(5));
System.out.println(test.maxA(6));
System.out.println(test.maxA(7));
System.out.println(test.maxA(8));
System.out.println(test.maxA(9));
}
}
其中j
变量减 2 是给C-A C-C
留下操作数,看个图就明白了:
这样,此算法就完成了,时间复杂度 O(N^2),空间复杂度 O(N),这种解法应该是比较高效的了。
PS:这个思路跟前文 动态规划设计之最长递增子序列 有异曲同工之妙,如果有疑问可以去看看。
动态规划难就难在寻找状态转移,不同的定义可以产生不同的状态转移逻辑,虽然最后都能得到正确的结果,但是效率可能有巨大的差异。
回顾第一种解法,重叠子问题已经消除了,但是效率还是低,到底低在哪里呢?抽象出递归框架:
def dp(n, a_num, copy):
dp(n - 1, a_num + 1, copy), # A
dp(n - 1, a_num + copy, copy), # C-V
dp(n - 2, a_num, a_num) # C-A C-C
看这个穷举逻辑,是有可能出现这样的操作序列C-A C-C,C-A C-C...
或者C-V,C-V,...
。显然这种操作序列的结果不是最优的,但是我们并没有想办法规避这些情况的发生,从而增加了很多没必要的子问题计算。
回顾第二种解法,我们稍加思考,发现最优的序列应该是这种形式:A,A..C-A,C-C,C-V,C-V..C-A,C-C,C-V..
。
根据这个事实,我们重新定义了状态,重新寻找了状态转移,从逻辑上减少了无效的子问题个数,从而提高了算法的效率。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
887.鸡蛋掉落
今天要聊一个很经典的算法问题,若干层楼,若干个鸡蛋,让你算出最少的尝试次数,找到鸡蛋恰好摔不碎的那层楼。国内大厂以及谷歌脸书面试都经常考察这道题,只不过他们觉得扔鸡蛋太浪费,改成扔杯子,扔破碗什么的。
具体的问题等会再说,但是这道题的解法技巧很多,光动态规划就好几种效率不同的思路,最后还有一种极其高效数学解法。秉承咱们号一贯的作风,拒绝奇技淫巧,拒绝过于诡异的技巧,因为这些技巧无法举一反三,学了也不划算。
下面就来用我们一直强调的动态规划通用思路来研究一下这道题。
题目是这样:你面前有一栋从 1 到 N
共 N
层的楼,然后给你 K
个鸡蛋(K
至少为 1)。现在确定这栋楼存在楼层 0 <= F <= N
,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎(高于 F
的楼层都会碎,低于 F
的楼层都不会碎)。现在问你,最坏情况下,你至少要扔几次鸡蛋,才能确定这个楼层 F
呢?
也就是让你找摔不碎鸡蛋的最高楼层 F
,但什么叫「最坏情况」下「至少」要扔几次呢?我们分别举个例子就明白了。
比方说现在先不管鸡蛋个数的限制,有 7 层楼,你怎么去找鸡蛋恰好摔碎的那层楼?
最原始的方式就是线性扫描:我先在 1 楼扔一下,没碎,我再去 2 楼扔一下,没碎,我再去 3 楼……
以这种策略,最坏情况应该就是我试到第 7 层鸡蛋也没碎(F = 7
),也就是我扔了 7 次鸡蛋。
先在你应该理解什么叫做「最坏情况」下了,鸡蛋破碎一定发生在搜索区间穷尽时,不会说你在第 1 层摔一下鸡蛋就碎了,这是你运气好,不是最坏情况。
现在再来理解一下什么叫「至少」要扔几次。依然不考虑鸡蛋个数限制,同样是 7 层楼,我们可以优化策略。
最好的策略是使用二分查找思路,我先去第 (1 + 7) / 2 = 4
层扔一下:
如果碎了说明 F
小于 4,我就去第 (1 + 3) / 2 = 2
层试……
如果没碎说明 F
大于等于 4,我就去第 (5 + 7) / 2 = 6
层试……
以这种策略,最坏情况应该是试到第 7 层鸡蛋还没碎(F = 7
),或者鸡蛋一直碎到第 1 层(F = 0
)。然而无论那种最坏情况,只需要试 log7
向上取整等于 3 次,比刚才尝试 7 次要少,这就是所谓的至少要扔几次。
PS:这有点像 Big O 表示法计算算法的复杂度。
实际上,如果不限制鸡蛋个数的话,二分思路显然可以得到最少尝试的次数,但问题是,现在给你了鸡蛋个数的限制 K
,直接使用二分思路就不行了。
比如说只给你 1 个鸡蛋,7 层楼,你敢用二分吗?你直接去第 4 层扔一下,如果鸡蛋没碎还好,但如果碎了你就没有鸡蛋继续测试了,无法确定鸡蛋恰好摔不碎的楼层 F
了。这种情况下只能用线性扫描的方法,算法返回结果应该是 7。
有的读者也许会有这种想法:二分查找排除楼层的速度无疑是最快的,那干脆先用二分查找,等到只剩 1 个鸡蛋的时候再执行线性扫描,这样得到的结果是不是就是最少的扔鸡蛋次数呢?
很遗憾,并不是,比如说把楼层变高一些,100 层,给你 2 个鸡蛋,你在 50 层扔一下,碎了,那就只能线性扫描 1~49 层了,最坏情况下要扔 50 次。
如果不要「二分」,变成「五分」「十分」都会大幅减少最坏情况下的尝试次数。比方说第一个鸡蛋每隔十层楼扔,在哪里碎了第二个鸡蛋一个个线性扫描,总共不会超过 20 次。
最优解其实是 14 次。最优策略非常多,而且并没有什么规律可言。
说了这么多废话,就是确保大家理解了题目的意思,而且认识到这个题目确实复杂,就连我们手算都不容易,如何用算法解决呢?
对动态规划问题,直接套我们以前多次强调的框架即可:这个问题有什么「状态」,有什么「选择」,然后穷举。
「状态」很明显,就是当前拥有的鸡蛋数 K
和需要测试的楼层数 N
。随着测试的进行,鸡蛋个数可能减少,楼层的搜索范围会减小,这就是状态的变化。
「选择」其实就是去选择哪层楼扔鸡蛋。回顾刚才的线性扫描和二分思路,二分查找每次选择到楼层区间的中间去扔鸡蛋,而线性扫描选择一层层向上测试。不同的选择会造成状态的转移。
现在明确了「状态」和「选择」,动态规划的基本思路就形成了:肯定是个二维的 dp
数组或者带有两个状态参数的 dp
函数来表示状态转移;外加一个 for 循环来遍历所有选择,择最优的选择更新状态:
# 当前状态为 K 个鸡蛋,面对 N 层楼
# 返回这个状态下的最优结果
def dp(K, N):
int res
for 1 <= i <= N:
res = min(res, 这次在第 i 层楼扔鸡蛋)
return res
这段伪码还没有展示递归和状态转移,不过大致的算法框架已经完成了。
我们选择在第 i
层楼扔了鸡蛋之后,可能出现两种情况:鸡蛋碎了,鸡蛋没碎。注意,这时候状态转移就来了:
蛋碎,那么鸡蛋的个数 K
应该减一,搜索的楼层区间应该从 [1..N]
变为 [1..i-1]
共 i-1
层楼;
蛋没碎,那么鸡蛋的个数 K
不变,搜索的楼层区间应该从 [1..N]
变为 [i+1..N]
共 N-i
层楼。
PS:细心的读者可能会问,在第i层楼扔鸡蛋如果没碎,楼层的搜索区间缩小至上面的楼层,是不是应该包含第i层楼呀?不必,因为已经包含了。开头说了 F 是可以等于 0 的,向上递归后,第i层楼其实就相当于第 0 层,可以被取到,所以说并没有错误。
因为我们要求的是最坏情况下扔鸡蛋的次数,所以鸡蛋在第 i
层楼碎没碎,取决于那种情况的结果更大:
def dp(K, N):
for 1 <= i <= N:
# 最坏情况下的最少扔鸡蛋次数
res = min(res,
max(
dp(K - 1, i - 1), # 碎
dp(K, N - i) # 没碎
) + 1 # 在第 i 楼扔了一次
)
return res
递归的 base case 很容易理解:当楼层数 N
等于 0 时,显然不需要扔鸡蛋;当鸡蛋数 K
为 1 时,显然只能线性扫描所有楼层:
def dp(K, N):
if K == 1: return N
if N == 0: return 0
...
至此,其实这道题就解决了!只要添加一个备忘录消除重叠子问题即可:
def superEggDrop(K: int, N: int):
memo = dict()
def dp(K, N) -> int:
# base case
if K == 1: return N
if N == 0: return 0
# 避免重复计算
if (K, N) in memo:
return memo[(K, N)]
res = float('INF')
# 穷举所有可能的选择
for i in range(1, N + 1):
res = min(res,
max(
dp(K, N - i),
dp(K - 1, i - 1)
) + 1
)
# 记入备忘录
memo[(K, N)] = res
return res
return dp(K, N)
这个算法的时间复杂度是多少呢?动态规划算法的时间复杂度就是子问题个数 × 函数本身的复杂度。
函数本身的复杂度就是忽略递归部分的复杂度,这里 dp
函数中有一个 for 循环,所以函数本身的复杂度是 O(N)。
子问题个数也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。
所以算法的总时间复杂度是 O(K*N^2), 空间复杂度 O(KN)。
这个问题很复杂,但是算法代码却十分简洁,这就是动态规划的特性,穷举加备忘录/DP table 优化,真的没啥新意。
首先,有读者可能不理解代码中为什么用一个 for 循环遍历楼层 [1..N]
,也许会把这个逻辑和之前探讨的线性扫描混为一谈。其实不是的,这只是在做一次「选择」。
比方说你有 2 个鸡蛋,面对 10 层楼,你这次选择去哪一层楼扔呢?不知道,那就把这 10 层楼全试一遍。至于下次怎么选择不用你操心,有正确的状态转移,递归会算出每个选择的代价,我们取最优的那个就是最优解。
另外,这个问题还有更好的解法,比如修改代码中的 for 循环为二分搜索,可以将时间复杂度降为 O(K*N*logN);再改进动态规划解法可以进一步降为 O(KN);使用数学方法解决,时间复杂度达到最优 O(K*logN),空间复杂度达到 O(1)。
二分的解法也有点误导性,你很可能以为它跟我们之前讨论的二分思路扔鸡蛋有关系,实际上没有半毛钱关系。能用二分搜索是因为状态转移方程的函数图像具有单调性,可以快速找到最值。
简单介绍一下二分查找的优化吧,其实只是在优化这段代码:
def dp(K, N):
for 1 <= i <= N:
# 最坏情况下的最少扔鸡蛋次数
res = min(res,
max(
dp(K - 1, i - 1), # 碎
dp(K, N - i) # 没碎
) + 1 # 在第 i 楼扔了一次
)
return res
这个 for 循环就是下面这个状态转移方程的具体代码实现:
首先我们根据 dp(K, N)
数组的定义(有 K
个鸡蛋面对 N
层楼,最少需要扔几次),很容易知道 K
固定时,这个函数一定是单调递增的,无论你策略多聪明,楼层增加测试次数一定要增加。
那么注意 dp(K - 1, i - 1)
和 dp(K, N - i)
这两个函数,其中 i
是从 1 到 N
单增的,如果我们固定 K
和 N
,把这两个函数看做关于 i
的函数,前者随着 i
的增加应该也是单调递增的,而后者随着 i
的增加应该是单调递减的:
这时候求二者的较大值,再求这些最大值之中的最小值,其实就是求这个交点嘛,熟悉二分搜索的同学肯定敏感地想到了,这不就是相当于求 **Valley(山谷)**值嘛,可以用二分查找来快速寻找这个点的。
直接贴一下代码吧,思路还是完全一样的:
def superEggDrop(self, K: int, N: int) -> int:
memo = dict()
def dp(K, N):
if K == 1: return N
if N == 0: return 0
if (K, N) in memo:
return memo[(K, N)]
# for 1 <= i <= N:
# res = min(res,
# max(
# dp(K - 1, i - 1),
# dp(K, N - i)
# ) + 1
# )
res = float('INF')
# 用二分搜索代替线性搜索
lo, hi = 1, N
while lo <= hi:
mid = (lo + hi) // 2
broken = dp(K - 1, mid - 1) # 碎
not_broken = dp(K, N - mid) # 没碎
# res = min(max(碎,没碎) + 1)
if broken > not_broken:
hi = mid - 1
res = min(res, broken + 1)
else:
lo = mid + 1
res = min(res, not_broken + 1)
memo[(K, N)] = res
return res
return dp(K, N)
这里就不展开其他解法了,留在下一篇文章 高楼扔鸡蛋进阶
我觉得吧,我们这种解法就够了:找状态,做选择,足够清晰易懂,可流程化,可举一反三。掌握这套框架学有余力的话,再去考虑那些奇技淫巧也不迟。
最后预告一下,《动态规划详解(修订版)》和《回溯算法详解(修订版)》已经动笔了,教大家用模板的力量来对抗变化无穷的算法题,敬请期待。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
887.鸡蛋掉落
上篇文章聊了高楼扔鸡蛋问题,讲了一种效率不是很高,但是较为容易理解的动态规划解法。后台很多读者问如何更高效地解决这个问题,今天就谈两种思路,来优化一下这个问题,分别是二分查找优化和重新定义状态转移。
如果还不知道高楼扔鸡蛋问题的读者可以看下「经典动态规划:高楼扔鸡蛋」,那篇文章详解了题目的含义和基本的动态规划解题思路,请确保理解前文,因为今天的优化都是基于这个基本解法的。
二分搜索的优化思路也许是我们可以尽力尝试写出的,而修改状态转移的解法可能是不容易想到的,可以借此见识一下动态规划算法设计的玄妙,当做思维拓展。
之前提到过这个解法,核心是因为状态转移方程的单调性,这里可以具体展开看看。
首先简述一下原始动态规划的思路:
1、暴力穷举尝试在所有楼层 1 <= i <= N
扔鸡蛋,每次选择尝试次数最少的那一层;
2、每次扔鸡蛋有两种可能,要么碎,要么没碎;
3、如果鸡蛋碎了,F
应该在第 i
层下面,否则,F
应该在第 i
层上面;
4、鸡蛋是碎了还是没碎,取决于哪种情况下尝试次数更多,因为我们想求的是最坏情况下的结果。
核心的状态转移代码是这段:
# 当前状态为 K 个鸡蛋,面对 N 层楼
# 返回这个状态下的最优结果
def dp(K, N):
for 1 <= i <= N:
# 最坏情况下的最少扔鸡蛋次数
res = min(res,
max(
dp(K - 1, i - 1), # 碎
dp(K, N - i) # 没碎
) + 1 # 在第 i 楼扔了一次
)
return res
这个 for 循环就是下面这个状态转移方程的具体代码实现:
如果能够理解这个状态转移方程,那么就很容易理解二分查找的优化思路。
首先我们根据 dp(K, N)
数组的定义(有 K
个鸡蛋面对 N
层楼,最少需要扔几次),很容易知道 K
固定时,这个函数随着 N
的增加一定是单调递增的,无论你策略多聪明,楼层增加测试次数一定要增加。
那么注意 dp(K - 1, i - 1)
和 dp(K, N - i)
这两个函数,其中 i
是从 1 到 N
单增的,如果我们固定 K
和 N
,把这两个函数看做关于 i
的函数,前者随着 i
的增加应该也是单调递增的,而后者随着 i
的增加应该是单调递减的:
这时候求二者的较大值,再求这些最大值之中的最小值,其实就是求这两条直线交点,也就是红色折线的最低点嘛。
我们前文「二分查找只能用来查找元素吗」讲过,二分查找的运用很广泛,形如下面这种形式的 for 循环代码:
for (int i = 0; i < n; i++) {
if (isOK(i))
return i;
}
都很有可能可以运用二分查找来优化线性搜索的复杂度,回顾这两个 dp
函数的曲线,我们要找的最低点其实就是这种情况:
for (int i = 1; i <= N; i++) {
if (dp(K - 1, i - 1) == dp(K, N - i))
return dp(K, N - i);
}
熟悉二分搜索的同学肯定敏感地想到了,这不就是相当于求 Valley(山谷)值嘛,可以用二分查找来快速寻找这个点的,直接看代码吧,整体的思路还是一样,只是加快了搜索速度:
def superEggDrop(self, K: int, N: int) -> int:
memo = dict()
def dp(K, N):
if K == 1: return N
if N == 0: return 0
if (K, N) in memo:
return memo[(K, N)]
# for 1 <= i <= N:
# res = min(res,
# max(
# dp(K - 1, i - 1),
# dp(K, N - i)
# ) + 1
# )
res = float('INF')
# 用二分搜索代替线性搜索
lo, hi = 1, N
while lo <= hi:
mid = (lo + hi) // 2
broken = dp(K - 1, mid - 1) # 碎
not_broken = dp(K, N - mid) # 没碎
# res = min(max(碎,没碎) + 1)
if broken > not_broken:
hi = mid - 1
res = min(res, broken + 1)
else:
lo = mid + 1
res = min(res, not_broken + 1)
memo[(K, N)] = res
return res
return dp(K, N)
这个算法的时间复杂度是多少呢?动态规划算法的时间复杂度就是子问题个数 × 函数本身的复杂度。
函数本身的复杂度就是忽略递归部分的复杂度,这里 dp
函数中用了一个二分搜索,所以函数本身的复杂度是 O(logN)。
子问题个数也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。
所以算法的总时间复杂度是 O(K*N*logN), 空间复杂度 O(KN)。效率上比之前的算法 O(KN^2) 要高效一些。
前文「不同定义有不同解法」就提过,找动态规划的状态转移本就是见仁见智,比较玄学的事情,不同的状态定义可以衍生出不同的解法,其解法和复杂程度都可能有巨大差异。这里就是一个很好的例子。
再回顾一下我们之前定义的 dp
数组含义:
def dp(k, n) -> int
# 当前状态为 k 个鸡蛋,面对 n 层楼
# 返回这个状态下最少的扔鸡蛋次数
用 dp 数组表示的话也是一样的:
dp[k][n] = m
# 当前状态为 k 个鸡蛋,面对 n 层楼
# 这个状态下最少的扔鸡蛋次数为 m
按照这个定义,就是确定当前的鸡蛋个数和面对的楼层数,就知道最小扔鸡蛋次数。最终我们想要的答案就是 dp(K, N)
的结果。
这种思路下,肯定要穷举所有可能的扔法的,用二分搜索优化也只是做了「剪枝」,减小了搜索空间,但本质思路没有变,还是穷举。
现在,我们稍微修改 dp
数组的定义,确定当前的鸡蛋个数和最多允许的扔鸡蛋次数,就知道能够确定 F
的最高楼层数。具体来说是这个意思:
dp[k][m] = n
# 当前有 k 个鸡蛋,可以尝试扔 m 次鸡蛋
# 这个状态下,最坏情况下最多能确切测试一栋 n 层的楼
# 比如说 dp[1][7] = 7 表示:
# 现在有 1 个鸡蛋,允许你扔 7 次;
# 这个状态下最多给你 7 层楼,
# 使得你可以确定楼层 F 使得鸡蛋恰好摔不碎
# (一层一层线性探查嘛)
这其实就是我们原始思路的一个「反向」版本,我们先不管这种思路的状态转移怎么写,先来思考一下这种定义之下,最终想求的答案是什么?
我们最终要求的其实是扔鸡蛋次数 m
,但是这时候 m
在状态之中而不是 dp
数组的结果,可以这样处理:
int superEggDrop(int K, int N) {
int m = 0;
while (dp[K][m] < N) {
m++;
// 状态转移...
}
return m;
}
题目不是给你 K
鸡蛋,N
层楼,让你求最坏情况下最少的测试次数 m
吗?while
循环结束的条件是 dp[K][m] == N
,也就是给你 K
个鸡蛋,测试 m
次,最坏情况下最多能测试 N
层楼。
注意看这两段描述,是完全一样的!所以说这样组织代码是正确的,关键就是状态转移方程怎么找呢?还得从我们原始的思路开始讲。之前的解法配了这样图帮助大家理解状态转移思路:
这个图描述的仅仅是某一个楼层 i
,原始解法还得线性或者二分扫描所有楼层,要求最大值、最小值。但是现在这种 dp
定义根本不需要这些了,基于下面两个事实:
1、无论你在哪层楼扔鸡蛋,鸡蛋只可能摔碎或者没摔碎,碎了的话就测楼下,没碎的话就测楼上。
2、无论你上楼还是下楼,总的楼层数 = 楼上的楼层数 + 楼下的楼层数 + 1(当前这层楼)。
根据这个特点,可以写出下面的状态转移方程:
dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1
dp[k][m - 1]
就是楼上的楼层数,因为鸡蛋个数 k
不变,也就是鸡蛋没碎,扔鸡蛋次数 m
减一;
dp[k - 1][m - 1]
就是楼下的楼层数,因为鸡蛋个数 k
减一,也就是鸡蛋碎了,同时扔鸡蛋次数 m
减一。
PS:这个 m
为什么要减一而不是加一?之前定义得很清楚,这个 m
是一个允许的次数上界,而不是扔了几次。
至此,整个思路就完成了,只要把状态转移方程填进框架即可:
int superEggDrop(int K, int N) {
// m 最多不会超过 N 次(线性扫描)
int[][] dp = new int[K + 1][N + 1];
// base case:
// dp[0][..] = 0
// dp[..][0] = 0
// Java 默认初始化数组都为 0
int m = 0;
while (dp[K][m] < N) {
m++;
for (int k = 1; k <= K; k++)
dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1;
}
return m;
}
如果你还觉得这段代码有点难以理解,其实它就等同于这样写:
for (int m = 1; dp[K][m] < N; m++)
for (int k = 1; k <= K; k++)
dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1;
看到这种代码形式就熟悉多了吧,因为我们要求的不是 dp
数组里的值,而是某个符合条件的索引 m
,所以用 while
循环来找到这个 m
而已。
这个算法的时间复杂度是多少?很明显就是两个嵌套循环的复杂度 O(KN)。
另外注意到 dp[m][k]
转移只和左边和左上的两个状态有关,所以很容易优化成一维 dp
数组,这里就不写了。
再往下就要用一些数学方法了,不具体展开,就简单提一下思路吧。
在刚才的思路之上,注意函数 dp(m, k)
是随着 m
单增的,因为鸡蛋个数 k
不变时,允许的测试次数越多,可测试的楼层就越高。
这里又可以借助二分搜索算法快速逼近 dp[K][m] == N
这个终止条件,时间复杂度进一步下降为 O(KlogN),我们可以设 g(k, m) =
……
算了算了,打住吧。我觉得我们能够写出 O(K*N*logN) 的二分优化算法就行了,后面的这些解法呢,听个响鼓个掌就行了,把欲望限制在能力的范围之内才能拥有快乐!
不过可以肯定的是,根据二分搜索代替线性扫描 m
的取值,代码的大致框架肯定是修改穷举 m
的 for 循环:
// 把线性搜索改成二分搜索
// for (int m = 1; dp[K][m] < N; m++)
int lo = 1, hi = N;
while (lo < hi) {
int mid = (lo + hi) / 2;
if (... < N) {
lo = ...
} else {
hi = ...
}
for (int k = 1; k <= K; k++)
// 状态转移方程
}
简单总结一下吧,第一个二分优化是利用了 dp
函数的单调性,用二分查找技巧快速搜索答案;第二种优化是巧妙地修改了状态转移方程,简化了求解了流程,但相应的,解题逻辑比较难以想到;后续还可以用一些数学方法和二分搜索进一步优化第二种解法,不过看了看镜子中的发量,算了。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
312.戳气球(困难)
今天我们要聊的这道题「Burst Balloon」和之前我们写过的那篇 经典动态规划:高楼扔鸡蛋问题 分析过的高楼扔鸡蛋问题类似,知名度很高,但难度确实也很大。因此 labuladong 公众号就给这道题赐个座,来看一看这道题目到底有多难。
它是 LeetCode 第 312 题,题目如下:
首先必须要说明,这个题目的状态转移方程真的比较巧妙,所以说如果你看了题目之后完全没有思路恰恰是正常的。虽然最优答案不容易想出来,但基本的思路分析是我们应该力求做到的。所以本文会先分析一下常规思路,然后再引入动态规划解法。
先来顺一下解决这种问题的套路:
我们前文多次强调过,很显然只要涉及求最值,没有任何奇技淫巧,一定是穷举所有可能的结果,然后对比得出最值。
所以说,只要遇到求最值的算法问题,首先要思考的就是:如何穷举出所有可能的结果?
穷举主要有两种算法,就是回溯算法和动态规划,前者就是暴力穷举,而后者是根据状态转移方程推导「状态」。
如何将我们的扎气球问题转化成回溯算法呢?这个应该不难想到的,我们其实就是想穷举戳气球的顺序,不同的戳气球顺序可能得到不同的分数,我们需要把所有可能的分数中最高的那个找出来,对吧。
那么,这不就是一个「全排列」问题嘛,我们前文 [回溯算法框架套路详解](…/第1章 核心套路篇/1.3 回溯算法解题套路框架.md) 中有全排列算法的详解和代码,其实只要稍微改一下逻辑即可,伪码思路如下:
int res = Integer.MIN_VALUE;
/* 输入一组气球,返回戳破它们获得的最大分数 */
int maxCoins(int[] nums) {
backtrack(nums, 0);
return res;
}
/* 回溯算法的伪码解法 */
void backtrack(int[] nums, int socre) {
if (nums 为空) {
res = max(res, score);
return;
}
for (int i = 0; i < nums.length; i++) {
int point = nums[i-1] * nums[i] * nums[i+1];
int temp = nums[i];
// 做选择
在 nums 中删除元素 nums[i]
// 递归回溯
backtrack(nums, score + point);
// 撤销选择
将 temp 还原到 nums[i]
}
}
回溯算法就是这么简单粗暴,但是相应的,算法的效率非常低。这个解法等同于全排列,所以时间复杂度是阶乘级别,非常高,题目说了nums
的大小n
最多为 500,所以回溯算法肯定是不能通过所有测试用例的。
这个动态规划问题和我们之前的动态规划系列文章相比有什么特别之处?为什么它比较难呢?
原因在于,这个问题中我们每戳破一个气球nums[i]
,得到的分数和该气球相邻的气球nums[i-1]
和nums[i+1]
是有相关性的。
我们前文 动态规划套路框架详解 说过运用动态规划算法的一个重要条件:子问题必须独立。所以对于这个戳气球问题,如果想用动态规划,必须巧妙地定义dp
数组的含义,避免子问题产生相关性,才能推出合理的状态转移方程。
如何定义dp
数组呢,这里需要对问题进行一个简单地转化。题目说可以认为nums[-1] = nums[n] = 1
,那么我们先直接把这两个边界加进去,形成一个新的数组points
:
int maxCoins(int[] nums) {
int n = nums.length;
// 两端加入两个虚拟气球
int[] points = new int[n + 2];
points[0] = points[n + 1] = 1;
for (int i = 1; i <= n; i++) {
points[i] = nums[i - 1];
}
// ...
}
现在气球的索引变成了从1
到n
,points[0]
和points[n+1]
可以认为是两个「虚拟气球」。
那么我们可以改变问题:在一排气球points
中,请你戳破气球0
和气球n+1
之间的所有气球(不包括0
和n+1
),使得最终只剩下气球0
和气球n+1
两个气球,最多能够得到多少分?
现在可以定义dp
数组的含义:
dp[i][j] = x
表示,戳破气球i
和气球j
之间(开区间,不包括i
和j
)的所有气球,可以获得的最高分数为x
。
那么根据这个定义,题目要求的结果就是dp[0][n+1]
的值,而 base case 就是dp[i][j] = 0
,其中0 <= i <= n+1, j <= i+1
,因为这种情况下,开区间(i, j)
中间根本没有气球可以戳。
// base case 已经都被初始化为 0
int[][] dp = new int[n + 2][n + 2];
现在我们要根据这个dp
数组来推导状态转移方程了,根据我们前文的套路,所谓的推导「状态转移方程」,实际上就是在思考怎么「做选择」,也就是这道题目最有技巧的部分:
不就是想求戳破气球i
和气球j
之间的最高分数吗,如果「正向思考」,就只能写出前文的回溯算法;我们需要「反向思考」,想一想气球i
和气球j
之间最后一个被戳破的气球可能是哪一个?
其实气球i
和气球j
之间的所有气球都可能是最后被戳破的那一个,不防假设为k
。回顾动态规划的套路,这里其实已经找到了「状态」和「选择」:i
和j
就是两个「状态」,最后戳破的那个气球k
就是「选择」。
根据刚才对dp
数组的定义,如果最后一个戳破气球k
,dp[i][j]
的值应该为:
dp[i][j] = dp[i][k] + dp[k][j]
+ points[i]*points[k]*points[j]
你不是要最后戳破气球k
吗?那得先把开区间(i, k)
的气球都戳破,再把开区间(k, j)
的气球都戳破;最后剩下的气球k
,相邻的就是气球i
和气球j
,这时候戳破k
的话得到的分数就是points[i]*points[k]*points[j]
。
那么戳破开区间(i, k)
和开区间(k, j)
的气球最多能得到的分数是多少呢?嘿嘿,就是dp[i][k]
和dp[k][j]
,这恰好就是我们对dp
数组的定义嘛!
结合这个图,就能体会出dp
数组定义的巧妙了。由于是开区间,dp[i][k]
和dp[k][j]
不会影响气球k
;而戳破气球k
时,旁边相邻的就是气球i
和气球j
了,最后还会剩下气球i
和气球j
,这也恰好满足了dp
数组开区间的定义。
那么,对于一组给定的i
和j
,我们只要穷举i < k < j
的所有气球k
,选择得分最高的作为dp[i][j]
的值即可,这也就是状态转移方程:
// 最后戳破的气球是哪个?
for (int k = i + 1; k < j; k++) {
// 择优做选择,使得 dp[i][j] 最大
dp[i][j] = Math.max(
dp[i][j],
dp[i][k] + dp[k][j] + points[i]*points[j]*points[k]
);
}
写出状态转移方程就完成这道题的一大半了,但是还有问题:对于k
的穷举仅仅是在做「选择」,但是应该如何穷举「状态」i
和j
呢?
for (int i = ...; ; )
for (int j = ...; ; )
for (int k = i + 1; k < j; k++) {
dp[i][j] = Math.max(
dp[i][j],
dp[i][k] + dp[k][j] + points[i]*points[j]*points[k]
);
return dp[0][n+1];
关于「状态」的穷举,最重要的一点就是:状态转移所依赖的状态必须被提前计算出来。
拿这道题举例,dp[i][j]
所依赖的状态是dp[i][k]
和dp[k][j]
,那么我们必须保证:在计算dp[i][j]
时,dp[i][k]
和dp[k][j]
已经被计算出来了(其中i < k < j
)。
那么应该如何安排i
和j
的遍历顺序,来提供上述的保证呢?我们前文 [动态规划答疑篇](2.4 动态规划答疑:最优子结构及dp 遍历方向.md) 写过处理这种问题的一个鸡贼技巧:根据 base case 和最终状态进行推导。
PS:最终状态就是指题目要求的结果,对于这道题目也就是dp[0][n+1]
。
我们先把 base case 和最终的状态在 DP table 上画出来:
对于任一dp[i][j]
,我们希望所有dp[i][k]
和dp[k][j]
已经被计算,画在图上就是这种情况:
那么,为了达到这个要求,可以有两种遍历方法,要么斜着遍历,要么从下到上从左到右遍历:
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uKjk3ezK-1614346909600)(…\pictures\戳气球\5.jpg)]](https://img-blog.csdnimg.cn/20210228235447438.png?x-oss-process
)
斜着遍历有一点难写,所以一般我们就从下往上遍历,下面看完整代码:
int maxCoins(int[] nums) {
int n = nums.length;
// 添加两侧的虚拟气球
int[] points = new int[n + 2];
points[0] = points[n + 1] = 1;
for (int i = 1; i <= n; i++) {
points[i] = nums[i - 1];
}
// base case 已经都被初始化为 0
int[][] dp = new int[n + 2][n + 2];
// 开始状态转移
// i 应该从下往上
for (int i = n; i >= 0; i--) {
// j 应该从左往右
for (int j = i + 1; j < n + 2; j++) {
// 最后戳破的气球是哪个?
for (int k = i + 1; k < j; k++) {
// 择优做选择
dp[i][j] = Math.max(
dp[i][j],
dp[i][k] + dp[k][j] + points[i]*points[j]*points[k]
);
}
}
}
return dp[0][n + 1];
}
至此,这道题目就完全解决了,十分巧妙,但也不是那么难,对吧?
关键在于dp
数组的定义,需要避免子问题互相影响,所以我们反向思考,将dp[i][j]
的定义设为开区间,考虑最后戳破的气球是哪一个,以此构建了状态转移方程。
对于如何穷举「状态」,我们使用了小技巧,通过 base case 和最终状态推导出i,j
的遍历方向,保证正确的状态转移。
相关推荐:
后台天天有人问背包问题,这个问题其实不难啊,如果我们号动态规划系列的十几篇文章你都看过,借助框架,遇到背包问题可以说是手到擒来好吧。无非就是状态 + 选择,也没啥特别之处嘛。
今天就来说一下背包问题吧,就讨论最常说的 0-1 背包问题。描述:
给你一个可装载重量为 W
的背包和 N
个物品,每个物品有重量和价值两个属性。其中第 i
个物品的重量为 wt[i]
,价值为 val[i]
,现在让你用这个背包装物品,最多能装的价值是多少?
举个简单的例子,输入如下:
N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]
算法返回 6,选择前两件物品装进背包,总重量 3 小于 W
,可以获得最大价值 6。
题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这就是 0-1 背包这个名词的来历。
解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们「动态规划详解」中的套路,直接走流程就行了。
看来我得每篇动态规划文章都得重复一遍套路,历史文章中的动态规划问题都是按照下面的套路来的,今天再来手把手演示一下:
第一步,要明确两点,「状态」和「选择」。
先说状态,如何才能描述一个问题局面?只要给定几个可选物品和一个背包的容量限制,就形成了一个背包问题,对不对?所以状态有两个,就是「背包的容量」和「可选择的物品」。
再说选择,也很容易想到啊,对于每件物品,你能选择什么?选择就是「装进背包」或者「不装进背包」嘛。
明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2...)
PS:此框架出自历史文章 团灭 LeetCode 股票买卖问题。
第二步,要明确dp
数组的定义。
dp
数组是什么?其实就是描述问题局面的一个数组。换句话说,我们刚才明确问题有什么「状态」,现在需要用dp
数组把状态表示出来。
首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维dp
数组,一维表示可选择的物品,一维表示背包的容量。
dp[i][w]
的定义如下:对于前i
个物品,当前背包的容量为w
,这种情况下可以装的最大价值是dp[i][w]
。
比如说,如果 dp[3][5] = 6
,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。
PS:为什么要这么定义?便于状态转移,或者说这就是套路,记下来就行了。建议看一下我们的动态规划系列文章,几种动规套路都被扒得清清楚楚了。
根据这个定义,我们想求的最终答案就是dp[N][W]
。base case 就是dp[0][..] = dp[..][0] = 0
,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。
细化上面的框架:
int dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
把物品 i 装进背包,
不把物品 i 装进背包
)
return dp[N][W]
第三步,根据「选择」,思考状态转移的逻辑。
简单说就是,上面伪码中「把物品i
装进背包」和「不把物品i
装进背包」怎么用代码体现出来呢?
这一步要结合对dp
数组的定义和我们的算法逻辑来分析:
先重申一下刚才我们的dp
数组的定义:
dp[i][w]
表示:对于前i
个物品,当前背包的容量为w
时,这种情况下可以装下的最大价值是dp[i][w]
。
如果你没有把这第i
个物品装入背包,那么很显然,最大价值dp[i][w]
应该等于dp[i-1][w]
。你不装嘛,那就继承之前的结果。
如果你把这第i
个物品装入了背包,那么dp[i][w]
应该等于dp[i-1][w-wt[i-1]] + val[i-1]
。
首先,由于i
是从 1 开始的,所以对val
和wt
的取值是i-1
。
而dp[i-1][w-wt[i-1]]
也很好理解:你如果想装第i
个物品,你怎么计算这时候的最大价值?换句话说,在装第i
个物品的前提下,背包能装的最大价值是多少?
显然,你应该寻求剩余重量w-wt[i-1]
限制下能装的最大价值,加上第i
个物品的价值val[i-1]
,这就是装第i
个物品的前提下,背包可以装的最大价值。
综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
dp[i-1][w],
dp[i-1][w - wt[i-1]] + val[i-1]
)
return dp[N][W]
最后一步,把伪码翻译成代码,处理一些边界情况。
我用 C++ 写的代码,把上面的思路完全翻译了一遍,并且处理了w - wt[i-1]
可能小于 0 导致数组索引越界的问题:
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
// vector 全填入 0,base case 已初始化
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
for (int i = 1; i <= N; i++) {
for (int w = 1; w <= W; w++) {
if (w - wt[i-1] < 0) {
// 当前背包容量装不下,只能选择不装入背包
dp[i][w] = dp[i - 1][w];
} else {
// 装入或者不装入背包,择优
dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1],
dp[i - 1][w]);
}
}
}
return dp[N][W];
}
现在你看这个解法代码,是不是感觉非常简单,就是把我们刚才分析的思路原封不动翻译了一下而已。
所以说,明确了动态规划的套路,思路就显得行云流水,非常自然就出答案了。
至此,背包问题就解决了。相比而言,我觉得这是比较简单的动态规划问题,因为状态转移的推导逻辑比较容易想到,基本上你明确了dp
数组的定义,就可以理所当然地确定状态转移了。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
416.分割等和子集(中等)
上篇文章 经典动态规划:0-1 背包问题 详解了通用的 0-1 背包问题,今天来看看背包问题的思想能够如何运用到其他算法题目。
而且,不是经常有读者问,怎么将二维动态规划压缩成一维动态规划吗?这就是状态压缩,很容易的,本文也会提及这种技巧。
读者在阅读本文之前务必读懂前文 经典动态规划:0-1 背包问题 中讲的套路,因为本文就是按照背包问题的解题模板来讲解的。
先看一下题目:
算法的函数签名如下:
// 输入一个集合,返回是否能够分割成和相等的两个子集
boolean canPartition(int[] nums);
对于这个问题,看起来和背包没有任何关系,为什么说它是背包问题呢?
首先回忆一下背包问题大致的描述是什么:
给你一个可装载重量为 W
的背包和 N
个物品,每个物品有重量和价值两个属性。其中第 i
个物品的重量为 wt[i]
,价值为 val[i]
,现在让你用这个背包装物品,最多能装的价值是多少?
那么对于这个问题,我们可以先对集合求和,得出 sum
,把问题转化为背包问题:
给一个可装载重量为 sum / 2
的背包和 N
个物品,每个物品的重量为 nums[i]
。现在让你装物品,是否存在一种装法,能够恰好将背包装满**?
你看,这就是背包问题的模型,甚至比我们之前的经典背包问题还要简单一些,下面我们就直接转换成背包问题,开始套前文讲过的背包问题框架即可。
第一步要明确两点,「状态」和「选择」。
这个前文 经典动态规划:背包问题 已经详细解释过了,状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。
第二步要明确 dp
数组的定义。
按照背包问题的套路,可以给出如下定义:
dp[i][j] = x
表示,对于前 i
个物品,当前背包的容量为 j
时,若 x
为 true
,则说明可以恰好将背包装满,若 x
为 false
,则说明不能恰好将背包装满。
比如说,如果 dp[4][9] = true
,其含义为:对于容量为 9 的背包,若只是用前 4 个物品,可以有一种方法把背包恰好装满。
或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进行选择,存在一个子集的和可以恰好凑出 9。
根据这个定义,我们想求的最终答案就是 dp[N][sum/2]
,base case 就是 dp[..][0] = true
和 dp[0][..] = false
,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。
第三步,根据「选择」,思考状态转移的逻辑。
回想刚才的 dp
数组含义,可以根据「选择」对 dp[i][j]
得到以下状态转移:
如果不把 nums[i]
算入子集,或者说你不把这第 i
个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态 dp[i-1][j]
,继承之前的结果。
如果把 nums[i]
算入子集,或者说你把这第 i
个物品装入了背包,那么是否能够恰好装满背包,取决于状态 dp[i-1][j-nums[i-1]]
。
首先,由于 i
是从 1 开始的,而数组索引是从 0 开始的,所以第 i
个物品的重量应该是 nums[i-1]
,这一点不要搞混。
dp[i - 1][j-nums[i-1]]
也很好理解:你如果装了第 i
个物品,就要看背包的剩余重量 j - nums[i-1]
限制下是否能够被恰好装满。
换句话说,如果 j - nums[i-1]
的重量可以被恰好装满,那么只要把第 i
个物品装进去,也可恰好装满 j
的重量;否则的话,重量 j
肯定是装不满的。
最后一步,把伪码翻译成代码,处理一些边界情况。
以下是我的 C++ 代码,完全翻译了之前的思路,并处理了一些边界情况:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int num : nums) sum += num;
// 和为奇数时,不可能划分成两个和相等的集合
if (sum % 2 != 0) return false;
int n = nums.size();
sum = sum / 2;
vector<vector<bool>>
dp(n + 1, vector<bool>(sum + 1, false));
// base case
for (int i = 0; i <= n; i++)
dp[i][0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= sum; j++) {
if (j - nums[i - 1] < 0) {
// 背包容量不足,不能装入第 i 个物品
dp[i][j] = dp[i - 1][j];
} else {
// 装入或不装入背包
dp[i][j] = dp[i - 1][j] || dp[i - 1][j-nums[i-1]];
}
}
}
return dp[n][sum];
}
再进一步,是否可以优化这个代码呢?注意到 dp[i][j]
都是通过上一行 dp[i-1][..]
转移过来的,之前的数据都不会再使用了。
所以,我们可以进行状态压缩,将二维 dp
数组压缩为一维,节约空间复杂度:
bool canPartition(vector<int>& nums) {
int sum = 0, n = nums.size();
for (int num : nums) sum += num;
if (sum % 2 != 0) return false;
sum = sum / 2;
vector<bool> dp(sum + 1, false);
// base case
dp[0] = true;
for (int i = 0; i < n; i++)
for (int j = sum; j >= 0; j--)
if (j - nums[i] >= 0)
dp[j] = dp[j] || dp[j - nums[i]];
return dp[sum];
}
这就是状态压缩,其实这段代码和之前的解法思路完全相同,只在一行 dp
数组上操作,i
每进行一轮迭代,dp[j]
其实就相当于 dp[i-1][j]
,所以只需要一维数组就够用了。
唯一需要注意的是 j
应该从后往前反向遍历,因为每个物品(或者说数字)只能用一次,以免之前的结果影响其他的结果。
至此,子集切割的问题就完全解决了,时间复杂度 O(n*sum),空间复杂度 O(sum)。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
518.零钱兑换II(中等)
零钱兑换 2 是另一种典型背包问题的变体,我们前文已经讲了 经典动态规划:0-1 背包问题 和 背包问题变体:相等子集分割。
读本文之前,希望你已经看过前两篇文章,看过了动态规划和背包问题的套路,这篇继续按照背包问题的套路,列举一个背包问题的变形。
本文聊的是 LeetCode 第 518 题 Coin Change 2,题目如下:
int change(int amount, int[] coins);
PS:至于 Coin Change 1,在我们前文 动态规划套路详解 写过。
我们可以把这个问题转化为背包问题的描述形式:
有一个背包,最大容量为 amount
,有一系列物品 coins
,每个物品的重量为 coins[i]
,每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?
这个问题和我们前面讲过的两个背包问题,有一个最大的区别就是,每个物品的数量是无限的,这也就是传说中的「完全背包问题」,没啥高大上的,无非就是状态转移方程有一点变化而已。
下面就以背包问题的描述形式,继续按照流程来分析。
第一步要明确两点,「状态」和「选择」。
状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」嘛,背包问题的套路都是这样。
明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 计算(选择1,选择2...)
第二步要明确 dp
数组的定义。
首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维 dp
数组。
dp[i][j]
的定义如下:
若只使用前 i
个物品,当背包容量为 j
时,有 dp[i][j]
种方法可以装满背包。
换句话说,翻译回我们题目的意思就是:
若只使用 coins
中的前 i
个硬币的面值,若想凑出金额 j
,有 dp[i][j]
种凑法。
经过以上的定义,可以得到:
base case 为 dp[0][..] = 0, dp[..][0] = 1
。因为如果不使用任何硬币面值,就无法凑出任何金额;如果凑出的目标金额为 0,那么“无为而治”就是唯一的一种凑法。
我们最终想得到的答案就是 dp[N][amount]
,其中 N
为 coins
数组的大小。
大致的伪码思路如下:
int dp[N+1][amount+1]
dp[0][..] = 0
dp[..][0] = 1
for i in [1..N]:
for j in [1..amount]:
把物品 i 装进背包,
不把物品 i 装进背包
return dp[N][amount]
第三步,根据「选择」,思考状态转移的逻辑。
注意,我们这个问题的特殊点在于物品的数量是无限的,所以这里和之前写的背包问题文章有所不同。
如果你不把这第 i
个物品装入背包,也就是说你不使用 coins[i]
这个面值的硬币,那么凑出面额 j
的方法数 dp[i][j]
应该等于 dp[i-1][j]
,继承之前的结果。
如果你把这第 i
个物品装入了背包,也就是说你使用 coins[i]
这个面值的硬币,那么 dp[i][j]
应该等于 dp[i][j-coins[i-1]]
。
首先由于 i
是从 1 开始的,所以 coins
的索引是 i-1
时表示第 i
个硬币的面值。
dp[i][j-coins[i-1]]
也不难理解,如果你决定使用这个面值的硬币,那么就应该关注如何凑出金额 j - coins[i-1]
。
比如说,你想用面值为 2 的硬币凑出金额 5,那么如果你知道了凑出金额 3 的方法,再加上一枚面额为 2 的硬币,不就可以凑出 5 了嘛。
综上就是两种选择,而我们想求的 dp[i][j]
是「共有多少种凑法」,所以 dp[i][j]
的值应该是以上两种选择的结果之和:
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= amount; j++) {
if (j - coins[i-1] >= 0)
dp[i][j] = dp[i - 1][j]
+ dp[i][j-coins[i-1]];
return dp[N][W]
最后一步,把伪码翻译成代码,处理一些边界情况。
我用 Java 写的代码,把上面的思路完全翻译了一遍,并且处理了一些边界问题:
int change(int amount, int[] coins) {
int n = coins.length;
int[][] dp = amount int[n + 1][amount + 1];
// base case
for (int i = 0; i <= n; i++)
dp[i][0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= amount; j++)
if (j - coins[i-1] >= 0)
dp[i][j] = dp[i - 1][j]
+ dp[i][j - coins[i-1]];
else
dp[i][j] = dp[i - 1][j];
}
return dp[n][amount];
}
而且,我们通过观察可以发现,dp
数组的转移只和 dp[i][..]
和 dp[i-1][..]
有关,所以可以压缩状态,进一步降低算法的空间复杂度:
int change(int amount, int[] coins) {
int n = coins.length;
int[] dp = new int[amount + 1];
dp[0] = 1; // base case
for (int i = 0; i < n; i++)
for (int j = 1; j <= amount; j++)
if (j - coins[i] >= 0)
dp[j] = dp[j] + dp[j-coins[i]];
return dp[amount];
}
这个解法和之前的思路完全相同,将二维 dp
数组压缩为一维,时间复杂度 O(N*amount),空间复杂度 O(amount)。
至此,这道零钱兑换问题也通过背包问题的框架解决了。
相关推荐:
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
494.目标和(中等)
我们前文经常说回溯算法和递归算法有点类似,有的问题如果实在想不出状态转移方程,尝试用回溯算法暴力解决也是一个聪明的策略,总比写不出来解法强。
那么,回溯算法和动态规划到底是啥关系?它俩都涉及递归,算法模板看起来还挺像的,都涉及做「选择」,真的酷似父与子。
那么,它俩具体有啥区别呢?回溯算法和动态规划之间,是否可能互相转化呢?
今天就用力扣第 494 题「目标和」来详细对比一下回溯算法和动态规划,真可谓群魔乱舞:
注意,给出的例子 nums
全是 1,但实际上可以是任意正整数哦。
其实我第一眼看到这个题目,花了两分钟就写出了一个回溯解法。
任何算法的核心都是穷举,回溯算法就是一个暴力穷举算法,前文 回溯算法解题框架 就写了回溯算法框架:
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
关键就是搞清楚什么是「选择」,而对于这道题,「选择」不是明摆着的吗?对于每个数字 nums[i]
,我们可以选择给一个正号 +
或者一个负号 -
,然后利用回溯模板穷举出来所有可能的结果,数一数到底有几种组合能够凑出 target
不就行了嘛?
伪码思路如下:
def backtrack(nums, i):
if i == len(nums):
if 达到 target:
result += 1
return
for op in { +1, -1 }:
选择 op * nums[i]
# 穷举 nums[i + 1] 的选择
backtrack(nums, i + 1)
撤销选择
如果看过我们之前的几篇回溯算法文章,这个代码可以说是比较简单的了:
int result = 0;
/* 主函数 */
int findTargetSumWays(int[] nums, int target) {
if (nums.length == 0) return 0;
backtrack(nums, 0, target);
return result;
}
/* 回溯算法模板 */
void backtrack(int[] nums, int i, int rest) {
// base case
if (i == nums.length) {
if (rest == 0) {
// 说明恰好凑出 target
result++;
}
return;
}
// 给 nums[i] 选择 - 号
rest += nums[i];
// 穷举 nums[i + 1]
backtrack(nums, i + 1, rest);
// 撤销选择
rest -= nums[i];
// 给 nums[i] 选择 + 号
rest -= nums[i];
// 穷举 nums[i + 1]
backtrack(nums, i + 1, rest);
// 撤销选择
rest += nums[i];
}
有的读者可能问,选择 -
的时候,为什么是 rest += nums[i]
,选择 +
的时候,为什么是 rest -= nums[i]
呢,是不是写反了?
不是的,「如何凑出 target
」和「如何把 target
减到 0」其实是一样的。我们这里选择后者,因为前者必须给 backtrack
函数多加一个参数,我觉得不美观:
void backtrack(int[] nums, int i, int sum, int target) {
// base case
if (i == nums.length) {
if (sum == target) {
result++;
}
return;
}
// ...
}
因此,如果我们给 nums[i]
选择 +
号,就要让 rest - nums[i]
,反之亦然。
以上回溯算法可以解决这个问题,时间复杂度为 O(2^N)
,N
为 nums
的大小。这个复杂度怎么算的?回忆前文 学习数据结构和算法的框架思维,发现这个回溯算法就是个二叉树的遍历问题:
void backtrack(int[] nums, int i, int rest) {
if (i == nums.length) {
return;
}
backtrack(nums, i + 1, rest - nums[i]);
backtrack(nums, i + 1, rest + nums[i]);
}
树的高度就是 nums
的长度嘛,所以说时间复杂度就是这棵二叉树的节点数,为 O(2^N)
,其实是非常低效的。
那么,这个问题如何用动态规划思想进行优化呢?
动态规划之所以比暴力算法快,是因为动态规划技巧消除了重叠子问题。
如何发现重叠子问题?看是否可能出现重复的「状态」。对于递归函数来说,函数参数中会变的参数就是「状态」,对于 backtrack
函数来说,会变的参数为 i
和 rest
。
前文 动态规划之编辑距离 说了一种一眼看出重叠子问题的方法,先抽象出递归框架:
void backtrack(int i, int rest) {
backtrack(i + 1, rest - nums[i]);
backtrack(i + 1, rest + nums[i]);
}
举个简单的例子,如果 nums[i] = 0
,会发生什么?
void backtrack(int i, int rest) {
backtrack(i + 1, rest);
backtrack(i + 1, rest);
}
你看,这样就出现了两个「状态」完全相同的递归函数,无疑这样的递归计算就是重复的。这就是重叠子问题,而且只要我们能够找到一个重叠子问题,那一定还存在很多的重叠子问题。
因此,状态 (i, rest)
是可以用备忘录技巧进行优化的:
int findTargetSumWays(int[] nums, int target) {
if (nums.length == 0) return 0;
return dp(nums, 0, target);
}
// 备忘录
HashMap<String, Integer> memo = new HashMap<>();
int dp(int[] nums, int i, int rest) {
// base case
if (i == nums.length) {
if (rest == 0) return 1;
return 0;
}
// 把它俩转成字符串才能作为哈希表的键
String key = i + "," + rest;
// 避免重复计算
if (memo.containsKey(key)) {
return memo.get(key);
}
// 还是穷举
int result = dp(nums, i + 1, rest - nums[i]) + dp(nums, i + 1, rest + nums[i]);
// 记入备忘录
memo.put(key, result);
return result;
}
以前我们都是用 Python 的元组配合哈希表 dict
来做备忘录的,其他语言没有元组,可以用把「状态」转化为字符串作为哈希表的键,这是一个常用的小技巧。
这个解法通过备忘录消除了很多重叠子问题,效率有一定的提升,但是这就结束了吗?
其实,这个问题可以转化为一个子集划分问题,而子集划分问题又是一个典型的背包问题。动态规划总是这么玄学,让人摸不着头脑……
首先,如果我们把 nums
划分成两个子集 A
和 B
,分别代表分配 +
的数和分配 -
的数,那么他们和 target
存在如下关系
sum(A) - sum(B) = target
sum(A) = target + sum(B)
sum(A) + sum(A) = target + sum(B) + sum(A)
2 * sum(A) = target + sum(nums)
综上,可以推出 sum(A) = (target + sum(nums)) / 2
,也就是把原问题转化成:nums
中存在几个子集 A
,使得**A
** 中元素的和为 (target + sum(nums)) / 2
?
类似的子集划分问题我们前文 经典背包问题:子集划分 讲过,现在实现这么一个函数:
/* 计算 nums 中有几个子集的和为 sum */
int subsets(int[] nums, int sum) {}
然后,可以这样调用这个函数:
int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int n : nums) sum += n;
// 这两种情况,不可能存在合法的子集划分
if (sum < target || (sum + target) % 2 == 1) {
return 0;
}
return subsets(nums, (sum + target) / 2);
}
好的,变成背包问题的标准形式:
有一个背包,容量为sum
,现在给你 N
个物品,第i
个物品的重量为nums[i - 1]
(注意 1 <= i <= N
),每个物品只有一个,请问你有几种不同的方法能够恰好装满这个背包?
现在,这就是一个正宗的动态规划问题了,下面按照我们一直强调的动态规划套路走流程:
第一步要明确两点,「状态」和「选择」。
对于背包问题,这个都是一样的,状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。
第二步要明确 dp
数组的定义。
按照背包问题的套路,可以给出如下定义:
dp[i][j] = x
表示,若只在前 i
个物品中选择,若当前背包的容量为 j
,则最多有 x
种方法可以恰好装满背包。
翻译成我们探讨的子集问题就是,若只在 nums
的前 i
个元素中选择,若目标和为 j
,则最多有 x
种方法划分子集。
根据这个定义,显然 dp[0][..] = 0
,因为没有物品的话,根本没办法装背包;dp[..][0] = 1
,因为如果背包的最大载重为 0,「什么都不装」就是唯一的一种装法。
我们所求的答案就是 dp[N][sum]
,即使用所有 N
个物品,有几种方法可以装满容量为 sum
的背包。
第三步,根据「选择」,思考状态转移的逻辑。
回想刚才的 dp
数组含义,可以根据「选择」对 dp[i][j]
得到以下状态转移:
如果不把 nums[i]
算入子集,或者说你不把这第 i
个物品装入背包,那么恰好装满背包的方法数就取决于上一个状态 dp[i-1][j]
,继承之前的结果。
如果把 nums[i]
算入子集,或者说你把这第 i
个物品装入了背包,那么只要看前 i - 1
个物品有几种方法可以装满 j - nums[i-1]
的重量就行了,所以取决于状态 dp[i-1][j-nums[i-1]]
。
PS:注意我们说的 i
是从 1 开始算的,而数组 nums
的索引时从 0 开始算的,所以 nums[i-1]
代表的是第 i
个物品的重量,j - nums[i-1]
就是背包装入物品 i
之后还剩下的容量。
由于 dp[i][j]
为装满背包的总方法数,所以应该以上两种选择的结果求和,得到状态转移方程:
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
然后,根据状态转移方程写出动态规划算法:
/* 计算 nums 中有几个子集的和为 sum */
int subsets(int[] nums, int sum) {
int n = nums.length;
int[][] dp = new int[n + 1][sum + 1];
// base case
for (int i = 0; i <= n; i++) {
dp[i][0] = 1;
}
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= sum; j++) {
if (j >= nums[i-1]) {
// 两种选择的结果之和
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
} else {
// 背包的空间不足,只能选择不装物品 i
dp[i][j] = dp[i-1][j];
}
}
}
return dp[n][sum];
}
然后,发现这个 dp[i][j]
只和前一行 dp[i-1][..]
有关,那么肯定可以优化成一维 dp
:
/* 计算 nums 中有几个子集的和为 sum */
int subsets(int[] nums, int sum) {
int n = nums.length;
int[] dp = new int[sum + 1];
// base case
dp[0] = 1;
for (int i = 1; i <= n; i++) {
// j 要从后往前遍历
for (int j = sum; j >= 0; j--) {
// 状态转移方程
if (j >= nums[i-1]) {
dp[j] = dp[j] + dp[j-nums[i-1]];
} else {
dp[j] = dp[j];
}
}
}
return dp[sum];
}
对照二维 **dp
,只要把 **dp
数组的第一个维度全都去掉就行了,唯一的区别就是这里的 j
要从后往前遍历,原因如下:
因为二维压缩到一维的根本原理是,dp[j]
和 dp[j-nums[i-1]]
还没被新结果覆盖的时候,相当于二维 dp
中的 dp[i-1][j]
和 dp[i-1][j-nums[i-1]]
。
那么,我们就要做到:在计算新的 dp[j]
的时候,dp[j]
和 dp[j-nums[i-1]]
还是上一轮外层 for 循环的结果。
如果你从前往后遍历一维 dp
数组,dp[j]
显然是没问题的,但是 dp[j-nums[i-1]]
已经不是上一轮外层 for 循环的结果了,这里就会使用错误的状态,当然得不到正确的答案。
现在,这道题算是彻底解决了。
总结一下,回溯算法虽好,但是复杂度高,即便消除一些冗余计算,也只是「剪枝」,没有本质的改进。而动态规划就比较玄学了,经过各种改造,从一个加减法问题变成子集问题,又变成背包问题,经过各种套路写出解法,又搞出状态压缩,还得反向遍历。
现在我都搞不清楚自己是来干嘛的了。嗯,这也许就是动态规划的魅力吧。
接下来请阅读 分治算法:运算优先级。