基本技巧

解题套路框架

1
2
3
4
5
6
7
# 初始化 base case
dp[0][0][...] = base
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)

斐波那契数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int fib(int N) {
if(N < 1) {
return 0;
}
if(N == 1 || N == 2) {
return 1;
}
int pre = 1, curr = 1;
for(int i = 3; i <= N; i++) {
int sum = pre + curr;
pre = curr;
curr = sum;
}
return curr;
}
}

零钱兑换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.sort(coins);
Arrays.fill(dp,amount+1);
// base case
dp[0] = 0;
//外层 所有状态
for(int i = 0;i < dp.length;i++){
//内层取所有选择
for(int coin:coins){
if(i - coin < 0){
break;
}
dp[i] = Math.min(dp[i],dp[i-coin] + 1);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
}

最优子结构和遍历方向

最优子结构

「最优子结构」是某些问题的一种特定性质,并不是动态规划问题专有的。也就是说,很多问题其实都具有最优子结构,只是其中大部分不具有重叠子问题,所以我们不把它们归为动态规划系列问题而已。

我先举个很容易理解的例子:假设你们学校有 10 个班,你已经计算出了每个班的最高考试成绩。那么现在我要求你计算全校最高的成绩,你会不会算?当然会,而且你不用重新遍历全校学生的分数进行比较,而是只要在这 10 个最高成绩中取最大的就是全校的最高成绩。

我给你提出的这个问题就符合最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果。让你算每个班的最优成绩就是子问题,你知道所有子问题的答案后,就可以借此推出全校学生的最优成绩这个规模更大的问题的答案。

你看,这么简单的问题都有最优子结构性质,只是因为显然没有重叠子问题,所以我们简单地求最值肯定用不出动态规划。

再举个例子:假设你们学校有 10 个班,你已知每个班的最大分数差(最高分和最低分的差值)。那么现在我让你计算全校学生中的最大分数差,你会不会算?可以想办法算,但是肯定不能通过已知的这 10 个班的最大分数差推到出来。因为这 10 个班的最大分数差不一定就包含全校学生的最大分数差,比如全校的最大分数差可能是 3 班的最高分和 6 班的最低分之差。

这次我给你提出的问题就不符合最优子结构,因为你没办通过每个班的最优值推出全校的最优值,没办法通过子问题的最优值推出规模更大的问题的最优值。前文 动态规划详解 说过,想满足最优子结,子问题之间必须互相独立。全校的最大分数差可能出现在两个班之间,显然子问题不独立,所以这个问题本身不符合最优子结构。

那么遇到这种最优子结构失效情况,怎么办?策略是:改造问题。对于最大分数差这个问题,我们不是没办法利用已知的每个班的分数差吗,那我只能这样写一段暴力代码:

1
2
3
4
5
6
7
8
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;

再举个常见但也十分简单的例子,求一棵二叉树的最大值,不难吧(简单起见,假设节点中的值都是非负数):

1
2
3
4
5
6
7
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 往后推导吗,可以想象成一个链式反应,不断以小博大。但只有符合最优子结构的问题,才有发生这种链式反应的性质。

dp数组的遍历方向

我相信读者做动态规划问题时,肯定会对dp数组的遍历顺序有些头疼。我们拿二维dp数组来举例,有时候我们是正向遍历:

1
2
3
4
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
// 计算 dp[i][j]

有时候我们反向遍历:

1
2
3
for (int i = m - 1; i >= 0; i--)
for (int j = n - 1; j >= 0; j--)
// 计算 dp[i][j]

有时候可能会斜向遍历:

1
2
3
4
5
6
7
// 斜着遍历数组
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数组?肯定是正向遍历:

1
2
3
4
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 和最终结果的存储位置,保证遍历过程中使用的数据都是计算完毕的就行,有时候确实存在多种方法可以得到正确答案,可根据个人口味自行选择。

状态压缩

能够使用状态压缩技巧的动态规划都是二维 dp 问题,你看它的状态转移方程,如果计算状态 dp[i][j] 需要的都是 dp[i][j] 相邻的状态,那么就可以使用状态压缩技巧,将二维的 dp 数组转化成一维,将空间复杂度从 O(N^2) 降低到 O(N)。

什么叫「和 dp[i][j] 相邻的状态」呢,比如前文 最长回文子序列 中,最终的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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];
}

dp[i][j] 的更新,其实只依赖于 dp[i+1][j-1], dp[i][j-1], dp[i+1][j] 这三个状态

img

这就叫和 dp[i][j] 相邻,反正你计算 dp[i][j] 只需要这三个相邻状态,其实根本不需要那么大一个二维的 dp table 对不对?状态压缩的核心思路就是,将二维数组「投影」到一维数组

img

思路很直观,但是也有一个明显的问题,图中 dp[i][j-1]dp[i+1][j-1] 这两个状态处在同一列,而一维数组中只能容下一个,那么当我计算 dp[i][j] 时,他俩必然有一个会被另一个覆盖掉,怎么办?

这就是状态压缩的难点,下面就来分析解决这个问题,还是拿「最长回文子序列」问题距离,它的状态转移方程主要逻辑就是如下这段代码:

1
2
3
4
5
6
7
8
9
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 数组变成一维:

1
2
3
4
5
6
7
8
9
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 数组中得到:

1
2
3
4
5
6
7
8
9
10
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 循环遍历 ij 的顺序为从左向右,从下向上,所以可以发现,在更新一维 dp 数组的时候,dp[i+1][j-1] 会被 dp[i][j-1] 覆盖掉,图中标出了这四个位置被遍历到的次序:

img

那么如果我们想得到 dp[i+1][j-1]**,就必须在它被覆盖之前用一个临时变量 temp 把它存起来,并把这个变量的值保留到计算 dp[i][j] 的时候**。为了达到这个目的,结合上图,我们可以这样写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}
}

但注意到我们还有 base case 要处理呀:

1
2
3
4
5
// dp 数组全部初始化为 0
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case
for (int i = 0; i < n; i++)
dp[i][i] = 1;

img

二维 dp 数组中的 base case 全都落入了一维 dp 数组,不存在冲突和覆盖,所以说我们直接这样写代码就行了:

1
2
// 一维 dp 数组全部初始化为 1
vector<int> dp(n, 1);
1
2
3
4
5
int longestPalindromeSubseq(string s) {
int n = s.size();
// base case:一维 dp 数组全部初始化为 0
vector<int> dp(n, 1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (int i = n - 2; i >= 0; i--) {
int pre = 0;
for (int j = i + 1; j < n; j++) {
int temp = dp[j];
// 状态转移方程
if (s[i] == s[j])
dp[j] = pre + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
pre = temp;
}
}
return dp[n - 1];

动态规划和回溯算法

img

回溯

框架:

1
2
3
4
5
6
7
8
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
1
2
3
4
5
6
7
8
9
10
11
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)
撤销选择
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
private int result;
public int findTargetSumWays(int[] nums, int S) {
if(nums.length == 0){
return 0;
}
backtrack(nums,0,S);
return result;
}
private void backtrack(int[] nums, int i, int rest){
// 终止条件
if(i == nums.length){
if(rest == 0){
result++;
}
return;
}
// 选择+号
rest -= nums[i];
backtrack(nums,i+1,rest);
//回溯
rest += nums[i];

//选择-号
rest += nums[i];
backtrack(nums,i+1,rest);
//回溯
rest -= nums[i];
}
}

消除重叠子问题

动态规划之所以比暴力算法快,是因为动态规划技巧消除了重叠子问题。

如何发现重叠子问题?看是否可能出现重复的「状态」。对于递归函数来说,函数参数中会变的参数就是「状态」,对于 backtrack 函数来说,会变的参数为 irest

1
2
3
4
void backtrack(int i, int rest) {
backtrack(i + 1, rest - nums[i]);
backtrack(i + 1, rest + nums[i]);
}

举个简单的例子,如果 nums[i] = 0,会发生什么?

1
2
3
4
void backtrack(int i, int rest) {
backtrack(i + 1, rest);
backtrack(i + 1, rest);
}

你看,这样就出现了两个「状态」完全相同的递归函数,无疑这样的递归计算就是重复的。这就是重叠子问题,而且只要我们能够找到一个重叠子问题,那一定还存在很多的重叠子问题

因此,状态 (i, rest) 是可以用备忘录技巧进行优化的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
}

动态规划

首先,如果我们把 nums 划分成两个子集 AB,分别代表分配 + 的数和分配 - 的数,那么他们和 target 存在如下关系:

1
2
3
4
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**?

1
2
3
4
5
6
7
8
9
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] 为装满背包的总方法数,所以应该以上两种选择的结果求和,得到状态转移方程

1
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public int findTargetSumWays(int[] nums, int S) {
int sum = 0;
for(int num : nums) {
sum += num;
}
if(sum < S || (sum + S) % 2 == 1) {
return 0;
}
return subsets(nums, (sum + S) / 2);
}
private 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 {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n][sum];
}
}

然后,发现这个 dp[i][j] 只和前一行 dp[i-1][..] 有关,那么肯定可以优化成一维 dp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public int findTargetSumWays(int[] nums, int S) {
int sum = 0;
for(int num : nums) {
sum += num;
}
if(sum < S || (sum + S) % 2 == 1) {
return 0;
}
return subsets(nums, (sum + S) / 2);
}
private 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++) {
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 循环的结果了,这里就会使用错误的状态,当然得不到正确的答案。

从大到小遍历,dp[j-nums[i-1]]还未被覆盖。!

子序列类型问题

经典动态规划:编辑距离

思路

设两个字符串分别为 “rad” 和 “apple”,为了把 s1 变成 s2,算法会这样进行:

img

img

分别用两个指针i,j指向字符串S1,S2的最后

ase case 是 i 走完 s1j 走完 s2,可以直接返回另一个字符串剩下的长度。

对于每对儿字符 s1[i]s2[j],可以有四种操作:

1
2
3
4
5
6
7
8
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)
1
2
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
1
2
3
4
5
6
7
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)

img

1
2
3
4
5
dp(i - 1, j) + 1,    # 删除
# 解释:
# 我直接把 s[i] 这个字符删掉
# 前移 i,继续跟 j 对比
# 操作数加一

img

1
2
3
4
5
dp(i - 1, j - 1) + 1 # 替换
# 解释:
# 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了
# 同时前移 i,j 继续对比
# 操作数加一

img

现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。

优化

怎么能一眼看出存在重叠子问题呢?前文「动态规划之正则表达式」有提过,这里再简单提一下,需要抽象出本文算法的递归框架:

1
2
3
4
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) -> #1dp(i, j) -> #2 -> #3。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题

对于重叠子问题呢,前文「动态规划详解」详细介绍过,优化方法无非是备忘录或者 DP table。

备忘录很好加,原来的代码稍加修改即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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[..][0]dp[0][..] 对应 base case,dp[i][j] 的含义和之前的 dp 函数类似:

1
2
3
4
5
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离

dp[i-1][j-1]
# 存储 s1[0..i] 和 s2[0..j] 的最小编辑距离

img

1
2
3
4
5
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 是自底向上求解,递归解法是自顶向下求解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
dp[i][0] = i;
}
for (int i = 0; i <= n; i++) {
dp[0][i] = i;
}
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];
} else {
dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1])) + 1;
}
}
}
return dp[m][n];
}
}

延申

一般来说,处理两个字符串的动态规划问题,都是按本文的思路处理,建立 DP table。为什么呢,因为易于找出状态转移的关系,比如编辑距离的 DP table:

img

还有一个细节,既然每个 dp[i][j] 只和它附近的三个状态有关,空间复杂度是可以压缩成 O(min(M, N)) 的(M,N 是两个字符串的长度)。不难,但是可解释性大大降低,读者可以自己尝试优化一下。

你可能还会问,这里只求出了最小的编辑距离,那具体的操作是什么?你之前举的修改公众号文章的例子,只有一个最小编辑距离肯定不够,还得知道具体怎么修改才行。

这个其实很简单,代码稍加修改,给 dp 数组增加额外的信息即可:

1
2
3
4
5
6
7
8
9
10
11
// int[][] dp;
Node[][] dp;

class Node {
int val;
int choice;
// 0 代表啥都不做
// 1 代表插入
// 2 代表删除
// 3 代表替换
}

val 属性就是之前的 dp 数组的数值,choice 属性代表操作。在做最优选择时,顺便把操作记录下来,然后就从结果反推具体操作。

我们的最终结果不是 dp[m][n] 吗,这里的 val 存着最小编辑距离,choice 存着最后一个操作,比如说是插入操作,那么就可以左移一格:

img

重复此过程,可以一步步回到起点 dp[0][0],形成一条路径,按这条路径上的操作进行编辑,就是最佳方案。

img

信封嵌套问题

题目概述

img

这道题目其实是最长递增子序列(Longes Increasing Subsequence,简写为 LIS)的一个变种,因为很显然,每次合法的嵌套是大的套小的,相当于找一个最长递增的子序列,其长度就是最多能嵌套的信封个数。

但是难点在于,标准的 LIS 算法只能在数组中寻找最长子序列,而我们的信封是由 (w, h) 这样的二维数对形式表示的,如何把 LIS 算法运用过来呢?

解法

这道题的解法是比较巧妙的:

先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序。之后把所有的 h 作为一个数组,在这个数组上计算 LIS 的长度就是答案。

img

然后在 h 上寻找最长递增子序列:

img

这个解法的关键在于,对于宽度 w 相同的数对,要对其高度 h 进行降序排序。因为两个宽度相同的信封不能相互包含的,逆序排序保证在 w 相同的数对中最多只选取一个。

(同宽度时h降序可以保证对h取递增序列不会取到宽度相同的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
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];
}
});
int piles = 0;
int[] top = new int[n];
for(int i = 0; i < n; i++){
int poker = envelopes[i][1];
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;
}
return piles;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public int maxEnvelopes(int[][] envelopes) {
int n = envelopes.length;
if(n == 0){
return 0;
}
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];
}
});
int[] height = new int[n];
for(int i = 0; i < n; i++){
height[i] = envelopes[i][1];
}
return lengthOfLIS(height);
}
private int lengthOfLIS(int[] nums){
int n = nums.length;
int[] dp = new int[n];
dp[0] = 1;
for(int i = 0; i < n; i++) {
int temp = 1;
for(int j = i - 1;j >= 0; j--){
if(nums[i] > nums[j]){
temp = Math.max(temp,dp[j] + 1);
}
}
dp[i] = temp;
}
return Arrays.stream(dp).max().getAsInt();
}
}

总结

这个问题是个 Hard 级别的题目,难就难在排序,正确地排序后此问题就被转化成了一个标准的 LIS 问题,容易解决一些。

其实这种问题还可以拓展到三维,比如说现在不是让你嵌套信封,而是嵌套箱子,每个箱子有长宽高三个维度,请你算算最多能嵌套几个箱子?

我们可能会这样想,先把前两个维度(长和宽)按信封嵌套的思路求一个嵌套序列,最后在这个序列的第三个维度(高度)找一下 LIS,应该能算出答案。

实际上,这个思路是错误的。这类问题叫做「偏序问题」,上升到三维会使难度巨幅提升,需要借助一种高级数据结构「树状数组」,有兴趣的读者可以自行搜索。

最大子数组

img

思路分析

解决这个问题需要动态规划技巧,但是 dp 数组的定义比较特殊。按照我们常规的动态规划思路,一般是这样定义 dp 数组:

nums[0..i] 中的「最大的子数组和」为 dp[i]

如果这样定义的话,整个 nums 数组的「最大子数组和」就是 dp[n-1]。如何找状态转移方程呢?按照数学归纳法,假设我们知道了 dp[i-1],如何推导出 dp[i] 呢?

如下图,按照我们刚才对 dp 数组的定义,dp[i] = 5 ,也就是等于 nums[0..i] 中的最大子数组和:

img

那么在上图这种情况中,利用数学归纳法,你能用 dp[i] 推出 dp[i+1] 吗?

实际上是不行的,因为子数组一定是连续的,按照我们当前 dp 数组定义,并不能保证 nums[0..i] 中的最大子数组与 nums[i+1] 是相邻的,也就没办法从 dp[i] 推导出 dp[i+1]

所以说我们这样定义 dp 数组是不正确的,无法得到合适的状态转移方程。对于这类子数组问题,我们就要重新定义 dp 数组的含义:

nums[i] 为结尾的「最大子数组和」为 dp[i]

这种定义之下,想得到整个 nums 数组的「最大子数组和」,不能直接返回 dp[n-1],而需要遍历整个 dp 数组:

1
2
3
4
5
int res = Integer.MIN_VALUE;
for (int i = 0; i < n; i++) {
res = Math.max(res, dp[i]);
}
return res;

依然使用数学归纳法来找状态转移关系:假设我们已经算出了 dp[i-1],如何推导出 dp[i] 呢?

可以做到,dp[i] 有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组;要么不与前面的子数组连接,自成一派,自己作为一个子数组。

如何选择?既然要求「最大子数组和」,当然选择结果更大的那个啦:

1
2
// 要么自成一派,要么和前面的子数组合并
dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public 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], dp[i - 1] + nums[i]);
}
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] 的状态有关,那么我们可以进行「状态压缩」,将空间复杂度降低:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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] 建立起联系,利用数学归纳法写出状态转移方程。

最长公共子序列

最长公共子序列

image-20201218153747861

比如说输入s1 = "zabcde", s2 = "acez",它俩的最长公共子序列是lcs = "ace",长度为 3,所以算法返回 3。

如果没有做过这道题,一个最简单的暴力算法就是,把s1s2的所有子序列都穷举出来,然后看看有没有公共的,然后在所有公共子序列里面再寻找一个长度最大的。

显然,这种思路的复杂度非常高,你要穷举出所有子序列,这个复杂度就是指数级的,肯定不实际。

正确的思路是不要考虑整个字符串,而是细化到s1s2的每个字符。前文 子序列解题模板 中总结的一个规律:

对于两个字符串求子序列的问题,都是用两个指针ij分别在两个字符串上移动,大概率是动态规划思路

最长公共子序列的问题也可以遵循这个规律,我们可以先写一个dp函数:

1
2
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j)

这个dp函数的定义是:dp(s1, i, s2, j)计算s1[i..]s2[j..]的最长公共子序列长度

根据这个定义,那么我们想要的答案就是dp(s1, 0, s2, 0),且 base case 就是i == len(s1)j == len(s2)时,因为这时候s1[i..]s2[j..]就相当于空串了,最长公共子序列的长度显然是 0:

1
2
3
4
5
6
7
8
9
10
11
int longestCommonSubsequence(String s1, String s2) {
return dp(s1, 0, s2, 0);
}

/* 主函数 */
int dp(String s1, int i, String s2, int j) {
// base case
if (i == s1.length() || j == s2.length()) {
return 0;
}
// ...

接下来,咱不要看s1s2两个字符串,而是要具体到每一个字符,思考每个字符该做什么

图片

我们只看s1[i]s2[j]如果s1[i] == s2[j],说明这个字符一定在lcs

图片

这样,就找到了一个lcs中的字符,根据dp函数的定义,我们可以完善一下代码:

1
2
3
4
5
6
7
8
9
10
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
if (s1.charAt(i) == s2.charAt(j)) {
// s1[i] 和 s2[j] 必然在 lcs 中,
// 加上 s1[i+1..] 和 s2[j+1..] 中的 lcs 长度,就是答案
return 1 + dp(s1, i + 1, s2, j + 1)
} else {
// ...
}
}

刚才说的s1[i] == s2[j]的情况,但如果s1[i] != s2[j],应该怎么办呢?

s1[i] != s2[j]意味着,s1[i]s2[j]中至少有一个字符不在lcs

图片

如上图,总共可能有三种情况,我怎么知道具体是那种情况呢?

其实我们也不知道,那就把这三种情况的答案都算出来,取其中结果最大的那个呗,因为题目让我们算「最长」公共子序列的长度嘛。

这三种情况的答案怎么算?回想一下我们的dp函数定义,不就是专门为了计算它们而设计的嘛!

代码可以再进一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
if (s1.charAt(i) == s2.charAt(j)) {
return 1 + dp(s1, i + 1, s2, j + 1)
} else {
// s1[i] 和 s2[j] 中至少有一个字符不在 lcs 中,
// 穷举三种情况的结果,取其中的最大结果
return max(
// 情况一、s1[i] 不在 lcs 中
dp(s1, i + 1, s2, j),
// 情况二、s2[j] 不在 lcs 中
dp(s1, i, s2, j + 1),
// 情况三、都不在 lcs 中
dp(s1, i + 1, s2, j + 1)
);
}
}

这里就已经非常接近我们的最终答案了,还有一个小的优化,情况三「s1[i]s2[j]都不在 lcs 中」其实可以直接忽略

因为我们在求最大值嘛,情况三在计算s1[i+1..]s2[j+1..]lcs长度,这个长度肯定是小于等于情况二s1[i..]s2[j+1..]中的lcs长度的,因为s1[i+1..]s1[i..]短嘛,那从这里面算出的lcs当然也不可能更长嘛。

同理,情况三的结果肯定也小于等于情况一。说白了,情况三被情况一和情况二包含了,所以我们可以直接忽略掉情况三,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 备忘录,消除重叠子问题
int[][] memo;

/* 主函数 */
int longestCommonSubsequence(String s1, String s2) {
int m = s1.length(), n = s2.length();
// 备忘录值为 -1 代表未曾计算
memo = new int[m][n];
for (int[] row : memo)
Arrays.fill(row, -1);
// 计算 s1[0..] 和 s2[0..] 的 lcs 长度
return dp(s1, 0, s2, 0);
}

// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
// base case
if (i == s1.length() || j == s2.length()) {
return 0;
}
// 如果之前计算过,则直接返回备忘录中的答案
if (memo[i][j] != -1) {
return memo[i][j];
}
// 根据 s1[i] 和 s2[j] 的情况做选择
if (s1.charAt(i) == s2.charAt(j)) {
// s1[i] 和 s2[j] 必然在 lcs 中
memo[i][j] = 1 + dp(s1, i + 1, s2, j + 1);
} else {
// s1[i] 和 s2[j] 至少有一个不在 lcs 中
memo[i][j] = Math.max(
dp(s1, i + 1, s2, j),
dp(s1, i, s2, j + 1)
);
}
return memo[i][j];
}

以上思路完全就是按照我们之前的爆文 动态规划套路框架 来的,应该是很容易理解的。至于为什么要加memo备忘录,我们之前写过很多次,为了照顾新来的读者,这里再简单重复一下,首先抽象出我们核心dp函数的递归框架:

1
2
3
4
5
int dp(int i, int j) {
dp(i + 1, j + 1); // #1
dp(i, j + 1); // #2
dp(i + 1, j); // #3
}

你看,假设我想从dp(i, j)转移到dp(i+1, j+1),有不止一种方式,可以直接走#1,也可以走#2 -> #3,也可以走#3 -> #2

这就是重叠子问题,如果我们不用memo备忘录消除子问题,那么dp(i+1, j+1)就会被多次计算,这是没有必要的。

至此,最长公共子序列问题就完全解决了,用的是自顶向下带备忘录的动态规划思路,我们当然也可以使用自底向上的迭代的动态规划思路,和我们的递归思路一样,关键是如何定义dp数组,我这里也写一下自底向上的解法吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int longestCommonSubsequence(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
// 定义:s1[0..i-1] 和 s2[0..j-1] 的 lcs 长度为 dp[i][j]
// 目标:s1[0..m-1] 和 s2[0..n-1] 的 lcs 长度,即 dp[m][n]
// base case: dp[0][..] = dp[..][0] = 0

for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 现在 i 和 j 从 1 开始,所以要减一
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
// s1[i-1] 和 s2[j-1] 必然在 lcs 中
dp[i][j] = 1 + dp[i - 1][j - 1];
} else {
// s1[i-1] 和 s2[j-1] 至少有一个不在 lcs 中
dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
}
}
}

return dp[m][n];
}

字符串的删除操作

函数签名如下:

1
int minDistance(String s1, String s2);

题目让我们计算将两个字符串变得相同的最少删除次数,那我们可以思考一下,最后这两个字符串会被删成什么样子?

删除的结果不就是它俩的最长公共子序列嘛!

那么,要计算删除的次数,就可以通过最长公共子序列的长度推导出来:

1
2
3
4
5
6
int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
// 复用前文计算 lcs 长度的函数
int lcs = longestCommonSubsequence(s1, s2);
return m - lcs + n - lcs;
}

这道题就解决了!

最小ASCII删除和

图片

这道题,和上一道题非常类似,这回不问我们删除的字符个数了,问我们删除的字符的 ASCII 码加起来是多少。

那就不能直接复用计算最长公共子序列的函数了,但是可以依照之前的思路,稍微修改 base case 和状态转移部分即可直接写出解法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 备忘录
int memo[][];
/* 主函数 */
int minimumDeleteSum(String s1, String s2) {
int m = s1.length(), n = s2.length();
// 备忘录值为 -1 代表未曾计算
memo = new int[m][n];
for (int[] row : memo)
Arrays.fill(row, -1);

return dp(s1, 0, s2, 0);
}

// 定义:将 s1[i..] 和 s2[j..] 删除成相同字符串,
// 最小的 ASCII 码之和为 dp(s1, i, s2, j)。
int dp(String s1, int i, String s2, int j) {
int res = 0;
// base case
if (i == s1.length()) {
// 如果 s1 到头了,那么 s2 剩下的都得删除
for (; j < s2.length(); j++)
res += s2.charAt(j);
return res;
}
if (j == s2.length()) {
// 如果 s2 到头了,那么 s1 剩下的都得删除
for (; i < s1.length(); i++)
res += s1.charAt(i);
return res;
}

if (memo[i][j] != -1) {
return memo[i][j];
}

if (s1.charAt(i) == s2.charAt(j)) {
// s1[i] 和 s2[j] 都是在 lcs 中的,不用删除
memo[i][j] = dp(s1, i + 1, s2, j + 1);
} else {
// s1[i] 和 s2[j] 至少有一个不在 lcs 中,删一个
memo[i][j] = Math.min(
s1.charAt(i) + dp(s1, i + 1, s2, j),
s2.charAt(j) + dp(s1, i, s2, j + 1)
);
}
return memo[i][j];
}

base case 有一定区别,计算lcs长度时,如果一个字符串为空,那么lcs长度必然是 0;但是这道题如果一个字符串为空,另一个字符串必然要被全部删除,所以需要计算另一个字符串所有字符的 ASCII 码之和。

关于状态转移,当s1[i]s2[j]相同时不需要删除,不同时需要删除,所以可以利用dp函数计算两种情况,得出最优的结果。其他的大同小异,就不具体展开了。

至此,三道子序列问题就解决完了,关键在于将问题细化到字符,根据每两个字符是否相同来判断他们是否在结果子序列中,从而避免了对所有子序列进行穷举。

动态规划之子序列问题模板

两种思路

1.一维dp数组

1
2
3
4
5
6
7
8
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]中,以\array[i]*结尾的目标子序列(最长递增子序列)的长度是dp[i]*

2.二维dp数组

1
2
3
4
5
6
7
8
9
10
11
int n = arr.length;
int[][] dp = new dp[n][n];

for (int i = 0; i < n; i++) {
for (int j = 1; 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[i][j],假设你知道了子问题dp[i+1][j-1]的结果(s[i+1..j-1]中最长回文子序列的长度),你是否能想办法算出dp[i][j]的值(s[i..j]中,最长回文子序列的长度)呢?

图片

可以!这取决于s[i]s[j]的字符

如果它俩相等,那么它俩加上s[i+1..j-1]中的最长回文子序列就是s[i..j]的最长回文子序列:

图片

如果它俩不相等,说明它俩不可能同时出现在s[i..j]的最长回文子序列中,那么把它俩分别加入s[i+1..j-1]中,看看哪个子串产生的回文子序列更长即可:

图片

以上两种情况写成代码就是这样:

1
2
3
4
5
6
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],左、下、左下三个方向的位置已经被计算出来,只能斜着遍历或者反着遍历

图片

反着遍历,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 - 1; 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];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
// base case
for(int i = 0; i < n; i++) {
dp[i][i] = 1;
}
for(int i = n - 1; 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]);
}
}
}
return dp[0][n - 1];
}
}

背包类型问题

0-1背包问题

给你一个可装载重量为W的背包和N个物品,每个物品有重量和价值两个属性。其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,最多能装的价值是多少?

举个简单的例子,输入如下:

1
2
3
N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]

算法返回 6,选择前两件物品装进背包,总重量 3 小于W,可以获得最大价值 6。

题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这也许就是 0-1 背包这个名词的来历。

解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们 动态规划套路详解 中的套路,直接走流程就行了。

动态规划标准套路

第一步**要明确两点,「状态」和「选择」**。

先说状态,如何才能描述一个问题局面?只要给定几个可选物品和一个背包的容量限制,就形成了一个背包问题,对不对?所以状态有两个,就是「背包的容量」和「可选择的物品」

再说选择,也很容易想到啊,对于每件物品,你能选择什么?选择就是「装进背包」或者「不装进背包」嘛

明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:

1
2
3
4
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。

细化上面的框架:

1
2
3
4
5
6
7
8
9
10
11
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 开始的,所以对valwt的取值是i-1

dp[i-1][w-wt[i-1]]也很好理解:你如果想装第i个物品,你怎么计算这时候的最大价值?换句话说,在装第**i个物品的前提下,背包能装的最大价值是多少?**

显然,你应该寻求剩余重量w-wt[i-1]限制下能装的最大价值,加上第i个物品的价值val[i-1],这就是装第i个物品的前提下,背包可以装的最大价值。

综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:

1
2
3
4
5
6
7
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]

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int knapSack(int W, int N, int[] wt, int[] val) {
int[][] dp = new int[N + 1][W + 1];
for (int i = 1; i <= N; i++) {
for (int j = 1; j <= W; j++) {
if (j - wt[i - 1] < 0) {
// 装不下
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - wt[i - 1]] + val[i - 1]);
}
}
}
return dp[N][W];
}

子集背包问题

问题分析

img

算法的函数签名如下:

1
2
// 输入一个集合,返回是否能够分割成和相等的两个子集
bool canPartition(vector<int>& nums);

对于这个问题,看起来和背包没有任何关系,为什么说它是背包问题呢?

首先回忆一下背包问题大致的描述是什么:

给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?

那么对于这个问题,我们可以先对集合求和,得出 sum,把问题转化为背包问题:

给一个可装载重量为 sum / 2 的背包和 N 个物品,每个物品的重量为 nums[i]**。现在让你装物品,是否存在一种装法,能够恰好将背包装满**?

你看,这就是背包问题的模型,甚至比我们之前的经典背包问题还要简单一些,下面我们就直接转换成背包问题,开始套前文讲过的背包问题框架即可。

解法分析

第一步要明确两点,「状态」和「选择」

这个前文 经典动态规划:背包问题 已经详细解释过了,状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。

第二步要明确 dp 数组的定义

按照背包问题的套路,可以给出如下定义:

dp[i][j] = x 表示,对于前 i 个物品,当前背包的容量为 j 时,若 xtrue,则说明可以恰好将背包装满,若 xfalse,则说明不能恰好将背包装满。

比如说,如果 dp[4][9] = true,其含义为:对于容量为 9 的背包,若只是用前 4 个物品,可以有一种方法把背包恰好装满。

或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进行选择,存在一个子集的和可以恰好凑出 9。

根据这个定义,我们想求的最终答案就是 dp[N][sum/2],base case 就是 dp[..][0] = truedp[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 肯定是装不满的。

最后一步,把伪码翻译成代码,处理一些边界情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for(int num : nums) {
sum += num;
}
if(sum % 2 != 0) {
return false;
}
int n = nums.length;
sum = sum /= 2;
boolean[][] dp = new boolean[n + 1][sum + 1];
// 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]){
// 装不下
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 数组压缩为一维,节约空间复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public boolean canPartition(int[] nums) {
/**
* 判断dp[w]是否为true
* dp[i]表示是否容量为i的背包能刚好装下,如果i-num能装下,即dp[i-num]为true则加上num dp[i]=true
*/
int sum = 0;
for (int num : nums) {
sum += num;
}
if (sum % 2 != 0) {
return false;
}
int w = sum / 2;
boolean[] dp = new boolean[w + 1];
dp[0] = true;
for (int num : nums) {
for (int i = w; i >= num; i--) {
dp[i] = dp[i] || dp[i - num];
}
}
return dp[w];
}
}

唯一需要注意的是 j 应该从后往前反向遍历,因为每个物品(或者说数字)只能用一次,以免之前的结果影响其他的结果

完全背包问题

img

我们可以把这个问题转化为背包问题的描述形式

有一个背包,最大容量为 amount,有一系列物品 coins,每个物品的重量为 coins[i]每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?

这个问题和我们前面讲过的两个背包问题,有一个最大的区别就是,每个物品的数量是无限的,这也就是传说中的「完全背包问题」,没啥高大上的,无非就是状态转移方程有一点变化而已。

解题思路

第一步要明确两点,「状态」和「选择」

状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」嘛,背包问题的套路都是这样。

明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:

1
2
3
4
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],其中 Ncoins 数组的大小。

大致的伪码思路如下:

1
2
int dp[N+1][amount+1]dp[0][..] = 0dp[..][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] 的值应该是以上两种选择的结果之和

1
2
3
4
5
6
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 写的代码,把上面的思路完全翻译了一遍,并且处理了一些边界问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public int change(int amount, int[] coins) {
int n = coins.length;
int[][] dp = new int[n + 1][amount + 1];
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) {
// 从前i-1个 然后不选i 或者从前i个能凑j - coins[i - 1] 再加coins[i - 1]就行
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];
}
}

状态压缩:

1
2
3
4
5
6
7
8
9
10
11
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];
}

贪心类型问题

贪心算法之区间调度问题

什么是贪心算法呢?贪心算法可以认为是动态规划算法的一个特例,相比动态规划,使用贪心算法需要满足更多的条件(贪心选择性质),但是效率比动态规划要高。

比如说一个算法问题使用暴力解法需要指数级时间,如果能使用动态规划消除重叠子问题,就可以降到多项式级别的时间,如果满足贪心选择性质,那么可以进一步降低时间复杂度,达到线性级别的。

什么是贪心选择性质呢,简单说就是:每一步都做出一个局部最优的选择,最终的结果就是全局最优。注意哦,这是一种特殊性质,其实只有一部分问题拥有这个性质。

比如你面前放着 100 张人民币,你只能拿十张,怎么才能拿最多的面额?显然每次选择剩下钞票中面值最大的一张,最后你的选择一定是最优的。

然而,大部分问题明显不具有贪心选择性质。比如打斗地主,对手出对儿三,按照贪心策略,你应该出尽可能小的牌刚好压制住对方,但现实情况我们甚至可能会出王炸。这种情况就不能用贪心算法,而得使用动态规划解决,参见前文「动态规划解决博弈问题」。

问题概述

言归正传,本文解决一个很经典的贪心算法问题 Interval Scheduling(区间调度问题)。给你很多形如 [start, end] 的闭区间,请你设计一个算法,算出这些区间中最多有几个互不相交的区间

贪心解法

这个问题有许多看起来不错的贪心思路,却都不能得到正确答案。比如说:

也许我们可以每次选择可选区间中开始最早的那个?但是可能存在某些区间开始很早,但是很长,使得我们错误地错过了一些短的区间。或者我们每次选择可选区间中最短的那个?或者选择出现冲突最少的那个区间?这些方案都能很容易举出反例,不是正确的方案。

正确的思路其实很简单,可以分为以下三步:

  1. 从区间集合 intvs 中选择一个区间 x,这个 x 是在当前所有区间中结束最早的(end 最小)。
  2. 把所有与 x 区间相交的区间从区间集合 intvs 中删除。
  3. 重复步骤 1 和 2,直到 intvs 为空为止。之前选出的那些 x 就是最大不相交子集。

把这个思路实现成算法的话,可以按每个区间的 end 数值升序排序,因为这样处理之后实现步骤 1 和步骤 2 都方便很多:

img

现在来实现算法,对于步骤 1,由于我们预先按照 end 排了序,所以选择 x 是很容易的。关键在于,如何去除与 x 相交的区间,选择下一轮循环的 x 呢?

由于我们事先排了序,不难发现所有与 x 相交的区间必然会与 x 的 end 相交;如果一个区间不想与 x 的 end 相交,它的 start 必须要大于(或等于)x 的 end

img

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public int intervalSchedule(int[][] intvs) {
if (intvs.length == 0) return 0;
// 按 end 升序排序
Arrays.sort(intvs, new Comparator<int[]>() {
public int compare(int[] a, int[] b) {
return a[1] - b[1];
}
});
// 至少有一个区间不相交
int count = 1;
// 排序后,第一个区间就是 x
int x_end = intvs[0][1];
for (int[] interval : intvs) {
int start = interval[0];
if (start >= x_end) {
// 找到下一个选择的区间了
count++;
x_end = interval[1];
}
}
return count;
}

应用距离

1.无重叠区间

img

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public int eraseOverlapIntervals(int[][] intervals) {
if(intervals.length == 0) {
return 0;
}
Arrays.sort(intervals,new Comparator<int[]>(){
public int compare(int[] a, int[] b){
return a[1] - b[1];
}
});
int count = 0;
int x_end = intervals[0][1];
for(int i = 1;i < intervals.length; i++){
if(intervals[i][0] >= x_end){
x_end = intervals[i][1];
}else{
count++;
}
}
return count;
}
}

452题:

img

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int findMinArrowShots(int[][] points) {
if(points.length == 0) {
return 0;
}
Arrays.sort(points, (a, b) -> (Integer.compare(a[1], b[1])));
int count = 1;
int xend = points[0][1];
for(int[] point : points) {
if(point[0] > xend) {
count++;
xend = point[1];
}
}
return count;
}
}

利用贪心玩跳跃游戏

Jump Game 1

title1

不知道读者有没有发现,有关动态规划的问题,大多是让你求最值的,比如最长子序列,最小编辑距离,最长公共子串等等等。这就是规律,因为动态规划本身就是运筹学里的一种求最值的算法。

那么贪心算法作为特殊的动态规划也是一样,也一定是让你求个最值。这道题表面上不是求最值,但是可以改一改:

请问通过题目中的跳跃规则,最多能跳多远?如果能够越过最后一格,返回 true,否则返回 false。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
int farthest = 0;
for(int i = 0; i < n - 1; i++) {
farthest = Math.max(farthest,i + nums[i]);
if(farthest <= i){
return false;
}
}
return farthest >= n - 1;
}
}

Jump Game 2

title2

现在的问题是,保证你一定可以跳到最后一格,请问你最少要跳多少次,才能跳过去

我们先来说说动态规划的思路,采用自顶向下的递归动态规划,可以这样定义一个 dp 函数:

1
2
// 定义:从索引 p 跳到最后一格,至少需要 dp(nums, p) 步
int dp(vector<int>& nums, int p);

我们想求的结果就是 dp(nums, 0),base case 就是当 p 超过最后一格时,不需要跳跃:

1
2
3
if (p >= nums.size() - 1) {
return 0;
}

根据前文 动态规划套路详解 的动规框架,就可以暴力穷举所有可能的跳法,通过备忘录 memo 消除重叠子问题,取其中的最小值最为最终答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
vector<int> memo;
// 主函数
int jump(vector<int>& nums) {
int n = nums.size();
// 备忘录都初始化为 n,相当于 INT_MAX
// 因为从 0 调到 n - 1 最多 n - 1 步
memo = vector<int>(n, n);
return dp(nums, 0);
}

int dp(vector<int>& nums, int p) {
int n = nums.size();
// base case
if (p >= n - 1) {
return 0;
}
// 子问题已经计算过
if (memo[p] != n) {
return memo[p];
}
int steps = nums[p];
// 你可以选择跳 1 步,2 步...
for (int i = 1; i <= steps; i++) {
// 穷举每一个选择
// 计算每一个子问题的结果
int subProblem = dp(nums, p + i);
// 取其中最小的作为最终结果
memo[p] = min(memo[p], subProblem + 1);
}
return memo[p];
}

这个动态规划应该很明显了,按照前文 动态规划套路详解 所说的套路,状态就是当前所站立的索引 p,选择就是可以跳出的步数。

该算法的时间复杂度是 递归深度 × 每次递归需要的时间复杂度,即 O(N^2),在 LeetCode 上是无法通过所有用例的,会超时

贪心算法比动态规划多了一个性质:贪心选择性质。我知道大家都不喜欢看严谨但枯燥的数学形式定义,那么我们就来直观地看一看什么样的问题满足贪心选择性质。

刚才的动态规划思路,不是要穷举所有子问题,然后取其中最小的作为结果吗?核心的代码框架是这样:

1
2
3
4
5
6
7
int steps = nums[p];
// 你可以选择跳 1 步,2 步...
for (int i = 1; i <= steps; i++) {
// 计算每一个子问题的结果
int subProblem = dp(nums, p + i);
res = min(subProblem + 1, res);
}

for 循环中会陷入递归计算子问题,这是动态规划时间复杂度高的根本原因。

但是,真的需要【递归地】计算出每一个子问题的结果,然后求最值吗?直观地想一想,似乎不需要递归,只需要判断哪一个选择最具有【潜力】即可

img

比如上图这种情况,我们站在索引 0 的位置,可以向前跳 1,2 或 3 步,你说应该选择跳多少呢?

显然应该跳 2 步调到索引 2,因为 nums[2] 的可跳跃区域涵盖了索引区间 [3..6],比其他的都大。如果想求最少的跳跃次数,那么往索引 2 跳必然是最优的选择。

你看,这就是贪心选择性质,我们不需要【递归地】计算出所有选择的具体结果然后比较求最值,而只需要做出那个最有【潜力】,看起来最优的选择即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int[] memo;
public int jump(int[] nums) {
int n = nums.length;
int end = 0, fathest = 0;
int jumps = 0;
for(int i = 0; i < n - 1; i++) {
fathest = Math.max(nums[i] + i,fathest);
if(end == i) {
// 到最远就跳
jumps++;
end = fathest;
}
}
return jumps;
}
}

img

end 标记了可以选择的跳跃步数,farthest 标记了所有选择 [i..end] 中能够跳到的最远距离,jumps 记录了跳跃次数。

本算法的时间复杂度 O(N),空间复杂度 O(1),可以说是非常高效,动态规划都被吊起来打了。

至此,两道跳跃问题都使用贪心算法解决了。

其实对于贪心选择性质,是可以有严格的数学证明的,有兴趣的读者可以参看《算法导论》第十六章,专门有一个章节介绍贪心算法。这里限于篇幅和通俗性,就不展开了。

使用贪心算法的实际应用还挺多,比如赫夫曼编码也是一个经典的贪心算法应用。更多时候运用贪心算法可能不是求最优解,而是求次优解以节约时间,比如经典的旅行商问题。

不过我们常见的贪心算法题目,就像本文的题目,大多一眼就能看出来,大不了就先用动态规划求解,如果动态规划都超时,说明该问题存在贪心选择性质无疑了。

其他经典问题

高楼扔鸡蛋

解析题目

题目是这样:你面前有一栋从 1 到NN层的楼,然后给你K个鸡蛋(K至少为 1)。现在确定这栋楼存在楼层0 <= F <= N,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎(高于F的楼层都会碎,低于F的楼层都不会碎)。现在问你,最坏情况下,你至少要扔几次鸡蛋,才能确定这个楼层F呢?

PS:F 可以为 0,比如说鸡蛋在 1 层都能摔碎,那么 F = 0。

也就是让你找摔不碎鸡蛋的最高楼层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 循环来遍历所有选择,择最优的选择更新结果 :

1
2
3
4
5
6
7
# 当前状态为 (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层楼。

图片

因为我们要求的是最坏情况下扔鸡蛋的次数,所以鸡蛋在第i层楼碎没碎,取决于那种情况的结果更大

1
2
3
4
5
6
7
8
9
10
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

我们必须保证 鸡蛋碎了之后接下来需要的步数鸡蛋没碎之后接下来需要的步数 二者的 最大值 最小,这样就保证了在 最坏情况下(也就是无论 FF 的值如何) dp(K, N)dp(K,N) 的值最小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
int[][] memo;
public int superEggDrop(int K, int N) {
memo = new int[K + 1][N + 1];
for(int i = 0;i <= K; i++) {
for(int j = 0; j <= N; j++) {
memo[i][j] = -1;
}
}
return dp(K,N);
}
private int dp(int k, int n) {
if(k == 1){
return n;
}
if(n == 0){
return 0;
}
if(memo[k][n] != -1) {
return memo[k][n];
}
int res = Integer.MAX_VALUE;
for(int i = 1;i <= n; i++) {
// n - i是因为没碎, 把第i层楼视为0楼,检测i+1 到N
// # 最坏情况下的最少扔鸡蛋次数 最坏情况始要确保确定F值
res = Math.min(res,Math.max(dp(k - 1,i - 1),dp(k,n - i)) + 1);
}
memo[k][n] = res;
return res;

}
}

这样写超时

这个算法的时间复杂度是多少呢?动态规划算法的时间复杂度就是子问题个数 × 函数本身的复杂度

函数本身的复杂度就是忽略递归部分的复杂度,这里dp函数中有一个 for 循环,所以函数本身的复杂度是 O(N)。

子问题个数也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。

所以算法的总时间复杂度是 O(K*N^2), 空间复杂度为子问题个数,即 O(KN)。

二分搜索优化

之前提到过这个解法,核心是因为状态转移方程的单调性,这里可以具体展开看看。

首先简述一下原始动态规划的思路:

1、暴力穷举尝试在所有楼层 1 <= i <= N 扔鸡蛋,每次选择尝试次数最少的那一层;

2、每次扔鸡蛋有两种可能,要么碎,要么没碎;

3、如果鸡蛋碎了,F 应该在第 i 层下面,否则,F 应该在第 i 层上面;

4、鸡蛋是碎了还是没碎,取决于哪种情况下尝试次数更多,因为我们想求的是最坏情况下的结果。

核心的状态转移代码是这段:

1
2
3
4
5
6
7
8
9
10
11
12
# 当前状态为 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 循环就是下面这个状态转移方程的具体代码实现:

img

首先我们根据 dp(K, N) 数组的定义(有 K 个鸡蛋面对 N 层楼,最少需要扔几次),很容易知道 K 固定时,这个函数随着 N 的增加一定是单调递增的,无论你策略多聪明,楼层增加测试次数一定要增加。

那么注意 dp(K - 1, i - 1)dp(K, N - i) 这两个函数,其中 i 是从 1 到 N 单增的,如果我们固定 KN把这两个函数看做关于 i 的函数,前者随着 i 的增加应该也是单调递增的,而后者随着 i 的增加应该是单调递减的

img

时候求二者的较大值,再求这些最大值之中的最小值,其实就是求这两条直线交点,也就是红色折线的最低点嘛。

我们前文「二分查找只能用来查找元素吗」讲过,二分查找的运用很广泛,形如下面这种形式的 for 循环代码:

1
2
3
4
for (int i = 0; i < n; i++) {
if (isOK(i))
return i;
}

都很有可能可以运用二分查找来优化线性搜索的复杂度,回顾这两个 dp 函数的曲线,我们要找的最低点其实就是这种情况:

1
2
3
4
for (int i = 1; i <= N; i++) {
if (dp(K - 1, i - 1) == dp(K, N - i))
return dp(K, N - i);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
Map<Integer,Integer> memo;
public int superEggDrop(int K, int N) {
memo = new HashMap<>();
return dp(K,N);
}
private int dp(int k, int n) {
if(k == 1){
return n;
}
if(n == 0){
return 0;
}
if(memo.containsKey(n * 100 + k)){
return memo.get(n * 100 + k);
}
int res = Integer.MAX_VALUE;
// for(int i = 1;i <= n; i++) {
// // n - i是因为没碎, 把第i层楼视为0楼,检测i+1 到N
// // # 最坏情况下的最少扔鸡蛋次数 最坏情况始要确保确定F值
// res = Math.min(res,Math.max(dp(k - 1,i - 1),dp(k,n - i)) + 1);
// }
int lo = 1, hi = n;
while(lo <= hi) {
int mid = (lo + hi) / 2;
int broken = dp(k - 1, mid - 1);
int not_broken = dp(k, n - mid);
if(broken > not_broken) {
hi = mid - 1;
res = Math.min(res, broken + 1);
} else {
lo = mid + 1;
res = Math.min(res, not_broken + 1);
}
}
memo.put(n * 100 + k,res);
return res;
}
}

戳气球问题

回溯思路

先来顺一下解决这种问题的套路:

我们前文多次强调过,很显然只要涉及求最值,没有任何奇技淫巧,一定是穷举所有可能的结果,然后对比得出最值

所以说,只要遇到求最值的算法问题,首先要思考的就是:如何穷举出所有可能的结果?

穷举主要有两种算法,就是回溯算法和动态规划,前者就是暴力穷举,而后者是根据状态转移方程推导「状态」。

如何将我们的扎气球问题转化成回溯算法呢?这个应该不难想到的,我们其实就是想穷举戳气球的顺序,不同的戳气球顺序可能得到不同的分数,我们需要把所有可能的分数中最高的那个找出来,对吧。

那么,这不就是一个「全排列」问题嘛,我们前文 回溯算法框架套路详解 中有全排列算法的详解和代码,其实只要稍微改一下逻辑即可,伪码思路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

1
2
3
4
5
6
7
8
9
10
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];
}
// ...
}

现在气球的索引变成了从1npoints[0]points[n+1]可以认为是两个「虚拟气球」。

那么我们可以改变问题:在一排气球points中,请你戳破气球0和气球n+1之间的所有气球(不包括0n+1),使得最终只剩下气球0和气球n+1两个气球,最多能够得到多少分

现在可以定义dp数组的含义:

dp[i][j] = x表示,戳破气球i和气球j之间(开区间,不包括ij)的所有气球,可以获得的最高分数为x

那么根据这个定义,题目要求的结果就是dp[0][n+1]的值,而 base case 就是dp[i][j] = 0,其中0 <= i <= n+1, j <= i+1,因为这种情况下,开区间(i, j)中间根本没有气球可以戳。

1
2
// base case 已经都被初始化为 0
int[][] dp = new int[n + 2][n + 2];

现在我们要根据这个dp数组来推导状态转移方程了,根据我们前文的套路,所谓的推导「状态转移方程」,实际上就是在思考怎么「做选择」,也就是这道题目最有技巧的部分:

不就是想求戳破气球i和气球j之间的最高分数吗,如果「正向思考」,就只能写出前文的回溯算法;我们需要「反向思考」,想一想气球i和气球j之间最后一个被戳破的气球可能是哪一个

其实气球i和气球j之间的所有气球都可能是最后被戳破的那一个,不防假设为k。回顾动态规划的套路,这里其实已经找到了「状态」和「选择」:ij就是两个「状态」,最后戳破的那个气球k就是「选择」。

根据刚才对dp数组的定义,如果最后一个戳破气球kdp[i][j]的值应该为

1
2
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数组开区间的定义。

那么,对于一组给定的ij,我们只要穷举i < k < j的所有气球k,选择得分最高的作为dp[i][j]的值即可,这也就是状态转移方程:

1
2
3
4
5
6
7
8
// 最后戳破的气球是哪个?
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的穷举仅仅是在做「选择」,但是应该如何穷举「状态」ij呢?

1
2
3
4
5
6
7
8
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)。

那么应该如何安排ij的遍历顺序,来提供上述的保证呢?我们前文 动态规划答疑篇 写过处理这种问题的一个鸡贼技巧:根据 base case 和最终状态进行推导

PS:最终状态就是指题目要求的结果,对于这道题目也就是dp[0][n+1]

我们先把 base case 和最终的状态在 DP table 上画出来:

图片

对于任一dp[i][j],我们希望所有dp[i][k]dp[k][j]已经被计算,画在图上就是这种情况:

图片

那么,为了达到这个要求,可以有两种遍历方法,要么斜着遍历,要么从下到上从左到右遍历:

图片

图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public 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];
}
// dp[i][j]表示(i,j)区间最大的结果
int[][] dp = new int[n + 2][n + 2];
// 从下到上 从左到右
for(int i = n; i >= 0; i--) {
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[k] * points[i] * points[j]
);
}
}
}
return dp[0][n + 1];
}
}

关键在于dp数组的定义,需要避免子问题互相影响,所以我们反向思考,将dp[i][j]的定义设为开区间,考虑最后戳破的气球是哪一个,以此构建了状态转移方程。

对于如何穷举「状态」,我们使用了小技巧,通过 base case 和最终状态推导出i,j的遍历方向,保证正确的状态转移。

博弈问题

你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。所有石头被拿完后,谁拥有的石头多,谁获胜。

石头的堆数可以是任意正整数,石头的总数也可以是任意正整数,这样就能打破先手必胜的局面了。比如有三堆石头 piles = [1, 100, 3],先手不管拿 1 还是 3,能够决定胜负的 100 都会被后手拿走,后手会获胜。

假设两人都很聪明,请你设计一个算法,返回先手和后手的最后得分(石头总数)之差。比如上面那个例子,先手能获得 4 分,后手会获得 100 分,你的算法应该返回 -96。

这样推广之后,这个问题算是一道 Hard 的动态规划问题了。博弈问题的难点在于,两个人要轮流进行选择,而且都贼精明,应该如何编程表示这个过程呢?

还是强调多次的套路,首先明确 dp 数组的含义,然后和股票买卖系列问题类似,只要找到「状态」和「选择」,一切就水到渠成了。

定义dp数组的含义

dp 数组最终的样子:

img

下文讲解时,认为元组是包含 first 和 second 属性的一个类,而且为了节省篇幅,将这两个属性简写为 fir 和 sec。比如按上图的数据,我们说 dp[1][3].fir = 10dp[0][1].sec = 3

先回答几个读者可能提出的问题:

这个二维 dp table 中存储的是元组,怎么编程表示呢?这个 dp table 有一半根本没用上,怎么优化?很简单,都不要管,先把解题的思路想明白了再谈也不迟。

以下是对 dp 数组含义的解释:

1
2
dp[i][j].fir 表示,对于 piles[i...j] 这部分石头堆,先手能获得的最高分数。dp[i][j].sec 表示,对于 piles[i...j] 这部分石头堆,后手能获得的最高分数。
举例理解一下,假设 piles = [3, 9, 1, 2],索引从 0 开始dp[0][1].fir = 9 意味着:面对石头堆 [3, 9],先手最终能够获得 9 分。dp[1][3].sec = 2 意味着:面对石头堆 [9, 1, 2],后手最终能够获得 2 分。

我们想求的答案是先手和后手最终分数之差,按照这个定义也就是 dp[0][n-1].fir - dp[0][n-1].sec,即面对整个 piles,先手的最优得分和后手的最优得分之差。

状态转移方程

根据前面对 dp 数组的定义,状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的人。

1
2
3
4
dp[i][j][fir or sec]
其中:
0 <= i < piles.length
i <= j < piles.length

对于这个问题的每个状态,可以做的选择有两个:选择最左边的那堆石头,或者选择最右边的那堆石头。 我们可以这样穷举所有状态:

1
2
3
4
5
n = piles.length
for 0 <= i < n:
for j <= i < n:
for who in {fir, sec}:
dp[i][j][who] = max(left, right)

上面的伪码是动态规划的一个大致的框架,股票系列问题中也有类似的伪码。这道题的难点在于,两人是交替进行选择的,也就是说先手的选择会对后手有影响,这怎么表达出来呢?

根据我们对 dp 数组的定义,很容易解决这个难点,写出状态转移方程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec)
dp[i][j].fir = max( 选择最左边的石头堆 , 选择最右边的石头堆 )
# 解释:我作为先手,面对 piles[i...j] 时,有两种选择:
# 要么我选择最左边的那一堆石头,然后面对 piles[i+1...j]
# 但是此时轮到对方,相当于我变成了后手;
# 要么我选择最右边的那一堆石头,然后面对 piles[i...j-1]
# 但是此时轮到对方,相当于我变成了后手。

if 先手选择左边:
dp[i][j].sec = dp[i+1][j].fir
if 先手选择右边:
dp[i][j].sec = dp[i][j-1].fir
# 解释:我作为后手,要等先手先选择,有两种情况:
# 如果先手选择了最左边那堆,给我剩下了 piles[i+1...j]
# 此时轮到我,我变成了先手;
# 如果先手选择了最右边那堆,给我剩下了 piles[i...j-1]
# 此时轮到我,我变成了先手。

根据 dp 数组的定义,我们也可以找出 base case,也就是最简单的情况:

1
2
3
4
5
6
dp[i][j].fir = piles[i]
dp[i][j].sec = 0
其中 0 <= i == j < n
# 解释:i 和 j 相等就是说面前只有一堆石头 piles[i]
# 那么显然先手的得分为 piles[i]
# 后手没有石头拿了,得分为 0

img

这里需要注意一点,我们发现 base case 是斜着的,而且我们推算

1
dp[i][j] 时需要用到 dp[i+1][j] 和 dp[i][j-1]:

img

所以说算法不能简单的一行一行遍历 dp 数组,而要斜着遍历数组:

img

代码实现

如何实现这个 fir 和 sec 元组呢,你可以用 python,自带元组类型;或者使用 C++ 的 pair 容器;或者用一个三维数组 dp[n][n][2],最后一个维度就相当于元组;或者我们自己写一个 Pair 类:

1
2
3
4
5
6
7
class Pair {
int fir, sec;
Pair(int fir, int sec) {
this.fir = fir;
this.sec = sec;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Pair {
int fir, sec;
Pair(int fir, int sec) {
this.fir = fir;
this.sec = sec;
}
}
class Solution {
public boolean stoneGame(int[] piles) {
int n = piles.length;
Pair[][] dp = new Pair[n][n];
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j++){
dp[i][j] = new Pair(0, 0);
}
}
for(int i = 0; i < n; i++){
//只有一堆石头
dp[i][i].fir = piles[i];
dp[i][i].sec = 0;
}
// 斜着遍历。。
for (int l = 2; l <= n; l++) {
for (int i = 0; i <= n - l; i++) {
int j = l + i - 1;
// 先手选择最左边或最右边的分数
int left = piles[i] + dp[i+1][j].sec;
int right = piles[j] + dp[i][j-1].sec;
// 套用状态转移方程
if (left > right) {
dp[i][j].fir = left;
dp[i][j].sec = dp[i+1][j].fir;
} else {
dp[i][j].fir = right;
dp[i][j].sec = dp[i][j-1].fir;
}
}
}
Pair res = dp[0][n - 1];
return (res.fir - res.sec) > 0;
}
}

image-20201228153246491

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public boolean stoneGame(int[] piles) {
int length = piles.length;
int[][] dp = new int[length][length];
for (int i = 0; i < length; i++) {
dp[i][i] = piles[i];
}
for (int i = length - 2; i >= 0; i--) {
for (int j = i + 1; j < length; j++) {
dp[i][j] = Math.max(piles[i] - dp[i + 1][j], piles[j] - dp[i][j - 1]);
}
}
return dp[0][length - 1] > 0;
}
}

有限状态机之kmp

实现 strStr()

暴力解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public int strStr(String haystack, String needle) {
int m = haystack.length(), n = needle.length();
if(n == 0) {
return 0;
}
for(int i = 0; i <= m - n; i++) {
int j;
for(j = 0; j < n; j++) {
if(haystack.charAt(i + j) != needle.charAt(j)) {
break;
}
}
if(j == n) {
return i;
}
}

return -1;
}
}

对于暴力算法,如果出现不匹配字符,同时回退 txtpat 的指针,嵌套 for 循环,时间复杂度 O(MN),空间复杂度O(1)。最主要的问题是,如果字符串中重复的字符比较多,该算法就显得很蠢。

比如 txt = “aaacaaab” pat = “aaab”:

brutal

很明显,pat 中根本没有字符 c,根本没必要回退指针 i,暴力解法明显多做了很多不必要的操作。

KMP 算法的不同之处在于,它会花费空间来记录一些信息,在上述情况中就会显得很聪明:

kmp1

再比如类似的 txt = “aaaaaaab” pat = “aaab”,暴力解法还会和上面那个例子一样蠢蠢地回退指针 i,而 KMP 算法又会耍聪明:

kmp2

因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要比较字符 b 是否被匹配就行了。

KMP 算法永不回退 txt 的指针 i,不走回头路(不会重复扫描 txt),而是借助 dp 数组中储存的信息把 pat 移到正确的位置继续匹配,时间复杂度只需 O(N),用空间换时间,所以我认为它是一种动态规划算法。

KMP 算法的难点在于,如何计算 dp 数组中的信息?如何根据这些信息正确地移动 pat 的指针?这个就需要确定有限状态自动机来辅助了,别怕这种高大上的文学词汇,其实和动态规划的 dp 数组如出一辙,等你学会了也可以拿这个词去吓唬别人。

还有一点需要明确的是:计算这个 dp 数组,只和 pat 串有关。意思是说,只要给我个 pat,我就能通过这个模式串计算出 dp 数组,然后你可以给我不同的 txt,我都不怕,利用这个 dp 数组我都能在 O(N) 时间完成字符串匹配。

具体来说,比如上文举的两个例子:

1
2
3
4
txt1 = "aaacaaab" 
pat = "aaab"
txt2 = "aaaaaaab"
pat = "aaab"

我们的 txt 不同,但是 pat 是一样的,所以 KMP 算法使用的 dp 数组是同一个。

只不过对于 txt1 的下面这个即将出现的未匹配情况:

img

dp 数组指示 pat 这样移动:

img

PS:这个j 不要理解为索引,它的含义更准确地说应该是状态(state),所以它会出现这个奇怪的位置,后文会详述。

而对于 txt2 的下面这个即将出现的未匹配情况:

img

dp 数组指示 pat 这样移动:

img

明白了 dp 数组只和 pat 有关,那么我们这样设计 KMP 算法就会比较漂亮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class KMP {
private int[][] dp;
private String pat;

public KMP(String pat) {
this.pat = pat;
// 通过 pat 构建 dp 数组
// 需要 O(M) 时间
}

public int search(String txt) {
// 借助 dp 数组去匹配 txt
// 需要 O(N) 时间
}
}

这样,当我们需要用同一 pat 去匹配不同 txt 时,就不需要浪费时间构造 dp 数组了:

1
2
3
KMP kmp = new KMP("aaab");
int pos1 = kmp.search("aaacaaab"); //4
int pos2 = kmp.search("aaaaaaab"); //4

状态机概述

为什么说 KMP 算法和状态机有关呢?是这样的,我们可以认为 pat 的匹配就是状态的转移。比如当 pat = “ABABC”:

img

如上图,圆圈内的数字就是状态,状态 0 是起始状态,状态 5(pat.length)是终止状态。开始匹配时 pat 处于起始状态,一旦转移到终止状态,就说明在 txt 中找到了 pat。比如说当前处于状态 2,就说明字符 “AB” 被匹配:

img

另外,处于不同状态时,pat 状态转移的行为也不同。比如说假设现在匹配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转移到状态 5,如果遇到字符 B 就应该转移到状态 0:

img

具体什么意思呢,我们来一个个举例看看。用变量 j 表示指向当前状态的指针,当前 pat 匹配到了状态 4:

img

如果遇到了字符 “A”,根据箭头指示,转移到状态 3 是最聪明的:

img

如果遇到了字符 “B”,根据箭头指示,只能转移到状态 0(一夜回到解放前):

img

如果遇到了字符 “C”,根据箭头指示,应该转移到终止状态 5,这也就意味着匹配完成:

img

当然了,还可能遇到其他字符,比如 Z,但是显然应该转移到起始状态 0,因为 pat 中根本都没有字符 Z:

img

这里为了清晰起见,我们画状态图时就把其他字符转移到状态 0 的箭头省略,只画 pat 中出现的字符的状态转移:

img

KMP 算法最关键的步骤就是构造这个状态转移图。要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。

下面看一下 KMP 算法根据这幅状态转移图匹配字符串 txt 的过程:

img

请记住这个 GIF 的匹配过程,这就是 KMP 算法的核心逻辑

为了描述状态转移图,我们定义一个二维 dp 数组,它的含义如下:

1
2
3
4
5
6
7
8
9
10
11
12
dp[j][c] = next
0 <= j < M,代表当前的状态
0 <= c < 256,代表遇到的字符(ASCII 码)
0 <= next <= M,代表下一个状态

dp[4]['A'] = 3 表示:
当前是状态 4,如果遇到字符 A,
pat 应该转移到状态 3

dp[1]['B'] = 2 表示:
当前是状态 1,如果遇到字符 B,
pat 应该转移到状态 2

根据我们这个 dp 数组的定义和刚才状态转移的过程,我们可以先写出 KMP 算法的 search 函数代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public int search(String txt) {
int M = pat.length();
int N = txt.length();
// pat 的初始态为 0
int j = 0;
for (int i = 0; i < N; i++) {
// 当前是状态 j,遇到字符 txt[i],
// pat 应该转移到哪个状态?
j = dp[j][txt.charAt(i)];
// 如果达到终止态,返回匹配开头的索引
if (j == M) return i - M + 1;
}
// 没到达终止态,匹配失败
return -1;
}

到这里,应该还是很好理解的吧,dp 数组就是我们刚才画的那幅状态转移图,如果不清楚的话回去看下 GIF 的算法演进过程。下面讲解:如何通过 pat 构建这个 dp 数组?

构建状态转移图

回想刚才说的:要确定状态转移的行为,必须明确两个变量,一个是当前的匹配状态,另一个是遇到的字符,而且我们已经根据这个逻辑确定了 dp 数组的含义,那么构造 dp 数组的框架就是这样:

1
2
3
for 0 <= j < M: # 状态
for 0 <= c < 256: # 字符
dp[j][c] = next

这个 next 状态应该怎么求呢?显然,如果遇到的字符 cpat[j] 匹配的话,状态就应该向前推进一个,也就是说 next = j + 1,我们不妨称这种情况为状态推进

img

如果字符 cpat[j] 不匹配的话,状态就要回退(或者原地不动),我们不妨称这种情况为状态重启

img

那么,如何得知在哪个状态重启呢?解答这个问题之前,我们再定义一个名字:影子状态(我编的名字),用变量 X 表示。所谓影子状态,就是和当前状态具有相同的前缀。比如下面这种情况:

img

当前状态 j = 4,其影子状态为 X = 2,它们都有相同的前缀 “AB”。因为状态 X 和状态 j 存在相同的前缀,所以当状态 j 准备进行状态重启的时候(遇到的字符 cpat[j] 不匹配),可以通过 X 的状态转移图来获得最近的重启位置

比如说刚才的情况,如果状态 j 遇到一个字符 “A”,应该转移到哪里呢?首先只有遇到 “C” 才能推进状态,遇到 “A” 显然只能进行状态重启。状态 j 会把这个字符委托给状态 X 处理,也就是 dp[j]['A'] = dp[X]['A']

img

为什么这样可以呢?因为:既然 j 这边已经确定字符 “A” 无法推进状态,只能回退,而且 KMP 就是要尽可能少的回退,以免多余的计算。那么 j 就可以去问问和自己具有相同前缀的 X,如果 X 遇见 “A” 可以进行「状态推进」,那就转移过去,因为这样回退最少。

img

当然,如果遇到的字符是 “B”,状态 X 也不能进行「状态推进」,只能回退,j 只要跟着 X 指引的方向回退就行了:

img

你也许会问,这个 X 怎么知道遇到字符 “B” 要回退到状态 0 呢?因为 X 永远跟在 j 的身后,状态 X 如何转移,在之前就已经算出来了。动态规划算法不就是利用过去的结果解决现在的问题吗?

这样,我们就细化一下刚才的框架代码:

1
2
3
4
5
6
7
8
9
10
int X # 影子状态
for 0 <= j < M:
for 0 <= c < 256:
if c == pat[j]:
# 状态推进
dp[j][c] = j + 1
else:
# 状态重启
# 委托 X 计算重启位置
dp[j][c] = dp[X][c]

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class KMP {
private int[][] dp;
private String pat;

public KMP(String pat) {
this.pat = pat;
int M = pat.length();
// dp[状态][字符] = 下个状态
dp = new int[M][256];
// base case
dp[0][pat.charAt(0)] = 1;
// 影子状态 X 初始为 0
int X = 0;
// 当前状态 j 从 1 开始
for (int j = 1; j < M; j++) {
for (int c = 0; c < 256; c++) {
if (pat.charAt(j) == c)
dp[j][c] = j + 1;
else
dp[j][c] = dp[X][c];
}
// 更新影子状态
X = dp[X][pat.charAt(j)];
}
}

public int search(String txt) {...}
}

先解释一下这一行代码:

1
2
// base case
dp[0][pat.charAt(0)] = 1;

这行代码是 base case,只有遇到 pat[0] 这个字符才能使状态从 0 转移到 1,遇到其它字符的话还是停留在状态 0(Java 默认初始化数组全为 0)。

影子状态 X 是先初始化为 0,然后随着 j 的前进而不断更新的。下面看看到底应该如何更新影子状态 X

1
2
3
4
5
6
7
8
int X = 0;
for (int j = 1; j < M; j++) {
...
// 更新影子状态
// 当前是状态 X,遇到字符 pat[j],
// pat 应该转移到哪个状态?
X = dp[X][pat.charAt(j)];
}

更新 X 其实和 search 函数中更新状态 j 的过程是非常相似的:

1
2
3
4
5
6
7
int j = 0;
for (int i = 0; i < N; i++) {
// 当前是状态 j,遇到字符 txt[i],
// pat 应该转移到哪个状态?
j = dp[j][txt.charAt(i)];
...
}

其中的原理非常微妙,注意代码中 for 循环的变量初始值,可以这样理解:后者是在 txt 中匹配 pat,前者是在 pat 中匹配 pat[1..end],状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前缀。所以我把 X 比喻为影子状态,似乎也有一点贴切。

另外,构建 dp 数组是根据 base case dp[0][..] 向后推演。这就是我认为 KMP 算法就是一种动态规划算法的原因。

下面来看一下状态转移图的完整构造过程,你就能理解状态 X 作用之精妙了:

img

至此,KMP 算法的核心终于写完啦啦啦啦!看下 KMP 算法的完整代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class KMP {
private int[][] dp;
private String pat;

public KMP(String pat) {
this.pat = pat;
int M = pat.length();
// dp[状态][字符] = 下个状态
dp = new int[M][256];
// base case
dp[0][pat.charAt(0)] = 1;
// 影子状态 X 初始为 0
int X = 0;
// 构建状态转移图(稍改的更紧凑了)
for (int j = 1; j < M; j++) {
for (int c = 0; c < 256; c++)
dp[j][c] = dp[X][c];
dp[j][pat.charAt(j)] = j + 1;
// 更新影子状态
X = dp[X][pat.charAt(j)];
}
}

public int search(String txt) {
int M = pat.length();
int N = txt.length();
// pat 的初始态为 0
int j = 0;
for (int i = 0; i < N; i++) {
// 计算 pat 的下一个状态
j = dp[j][txt.charAt(i)];
// 到达终止态,返回结果
if (j == M) return i - M + 1;
}
// 没到达终止态,匹配失败
return -1;
}
}