给定一个无序的整数数组,找到其中最长上升子序列的长度。
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
if(nums[i]>nums[j]) dp[i]=dp[i-1]+1;
else dp[i] =dp[i-1];
// 加上base case
dp[0]=1, 即当nums[0]时,你本身也可以作为一个子序列
综上,代码如下:
int lengthOfLIS(vector<int>& nums){
// base case
vector<int> dp(nums.size(), 1);
// 自底而上的迭代d[i]=?
for(int i =0; i<nums.size();i++){
for(int j =0; j<i; j++){
if(num[i]>nums[j]) dp[i]=max(dp[i],dp[j]+1);
}
}
// 更新dp[i]后,冒泡得到最大值
int res=0;
for(int i =0; i<nums.size();i++){
res=max(res, dp[i]);
}
return res;
}
nums[0..i] 中的「最大的子数组和」为 dp[i]
这样的话状态转移方程不好写
所以dp[i]定义为:
以nums[i]为结尾的最大子数组和为 dp[i]
这种定义之下,想得到整个 nums 数组的「最大子数组和」,不能直接返回
dp[n-1],而需要遍历整个 dp 数组:
int res = MIN
for(int i =0; i
依然使用数学归纳法来找状态转移关系:假设我们已经算出了 dp[i-1],如何推导出 dp[i] 呢?
可以做到,dp[i] 有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组;
要么不与前面的子数组连接,自成一派,自己作为一个子数组。
如何选择?既然要求「最大子数组和」,当然选择结果更大的那个啦:
// 要么自成一派,要么和前面的子数组合并
dp[i] = max.(nums[i], nums[i]+dp[i-1]); // 有点不对
综上,我们已经写出来状态方程,就可以直接写出解法:
int maxSubArray(vector<int> nums){
int n = nums.size();
if(n==0) return 0;
vector<int> dp(n, 0);
// base case
// 第一个元素前面没有子数组
dp[0]=nums[0];
// 状态转移方程
for(int i =1; i<n; i++){
dp[i]=max.(num[i], num[i]+dp[i-1]);
}
// 得到nums的最大子数组, 需要遍历整个dp[i]
# 即根据状态的定义:dp[i]定义为:以nums[i]为结尾的[最大子数组和]
# 那么在这种定义之下,我们的求解问题整个nums数组的最大子数组和,
# 不一定是以nums[i]结尾的,因为若要回答我们的问题,
# 需要遍历整个dp数组
int res = MIN;
for(int i = 0; i<n; i++){
res = max(res, dp[i]);
}
return res;
}
主要思想:
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...]=择优(选择1, 选择2,...)
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(把物品装进背包,不把物品装进背包)
return dp[N][W]
这一步要结合对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。
==显然,你应该寻求剩余重量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 已初始化 dp[0][...]=0 dp[...][0]=0
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];
}
对于这个问题,看起来和背包没有任何关系,为什么说它是背包问题呢?
首先回忆一下背包问题大致的描述是什么:
给你一个可装载重量为 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[N][sum/2],base case是d[…][0]=true, d[0][…]=false
第三步 根据选择,思考状态转移的逻辑
回想到dp数组的含义,可以根据选择对dp[i][j]得到以下状态转移:
如果不把 nums[i] 算入子集,或者说你不把这第 i 个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态 dp[i-1][j],继承之前的结果。
dp[i - 1][j-nums[i-1]] 也很好理解:你如果装了第 i 个物品,就要看背包的剩余重量 j - nums[i-1] 限制下是否能够被恰好装满。
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]];
}
}
}
// 返回容量sum/2的背包是否装满的情况
// 根据算法图解,最大背包有两个子背包
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];
}
// 唯一需要注意的是 j 应该从后往前反向遍历,因为每个物品
//(或者说数字)只能用一次,以免之前的结果影响其他的结果
# 怎么理解这句话:
主要是因为dp[j] = dp[j] || dp[j - nums[i]];这行代码。
dp[j - nums[i]]的结果也会影响当前的dp[j],所以这个值不
能被先被覆盖要倒过来赋值它。简单来说,就是答主所解释的,
因为每个物品(或者说数字)只能用一次,以免之前的结果影响
其他的结果。
有一个背包,最大容量为 amount,有一系列物品 coins,每个物品的重量为 coins[i],每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?
这个问题和我们前面讲过的两个背包问题,有一个最大的区别就是,每个物品的数量是无限的,这也就是传说中的「完全背包问题」,没啥高大上的,无非就是状态转移方程有一点变化而已。
下面就以背包问题的描述形式,继续按照流程来分析。
解题思路:
dp[0][..] = 0, dp[..][0] = 1。
因为如果不使用任何硬币面值,就无法凑出任何金额;如果凑出的目标金额为 0,那么“无为而治”就是唯一的一种凑法。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]
最后一步,把伪码翻译成代码,处理一些边界情况
int change(int amount, vector<int> coints){
int n = coins.size();
vector<vector<int>> dp(n+1, vector<int>(amount+1));
// base case
for(int i = 0; i<=n; i++){
dp[i][0]=1; // 背包为0,有多少中凑发,无为而治唯一的解法
}
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[i][..]
和 dp[i-1][..]
有关,所以可以压缩状态,进一步降低算法的空间复杂度:一、主要思路:
编辑距离问题就是给我们两个字符串 s1
和 s2
,只能用三种操作,让我们把 s1
变成s2
,求最少的操作数。需要明确的是,不管是把 s1
变成 s2
还是反过来,结果都是一样的,所以后文就以 s1
变成 s2
举例
前文「」说过,解决两个字符串的动态规划问题,一般都是用两个指针 i,j
分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模。
设两个字符串分别为"rad"和"apple",为了把s1
变成s2
,算法会这样进行:
当i==j时,可以skip
当i!=j时,可以insert/replace/delete
还有一个很容易处理的情况:
就是 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): # 返回 s1[0..i] 和 s2[0..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) # 啥都不做
# 解释:
# 本来就相等,不需要任何操作
# s1[0..i-1] 和 s2[0..j-1] 的最小编辑距离
# 也就是说 dp(i, j) 等于 dp(i-1, j-1)
else:
return min(
dp(i, j-1), # 插入
# 我直接在 s1[i] 插入一个和 s2[j] 一样的字符
# 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比
# 别忘了操作数加一
dp(i-1, j)+1, # 删除
dp(i-1, j-1))+1 # 替换
)
# i,j 初始化指向最后一个索引
return dp(len(s1)-1, len(s2)-1)
怎么能一眼看出存在重叠子问题呢?这里再简单提一下,需要抽象出本文算法的递归框架:
def dp(i, j):
dp(i - 1, j - 1) #1
dp(i, j - 1) #2 insert j已经被匹配
dp(i - 1, j) #3 delete i
对于子问题 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[..][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();
vector<vector<int>> dp(m+1, vector<int>(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++){
# 字符串第i个字符相等
if(s1[i-1]==s2[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 Min(a, Min(b, c));
}
三、扩展延伸
最近做的这几道字符串的好像都是这个状态转移方程:
参考:labuladong
一、解析题目
题目是这样:你面前有一栋从 1 到 N 共 N 层的楼,然后给你 K 个鸡蛋(K 至少为 1)。现在确定这栋楼存在楼层 0 <= F <= N,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎(高于 F 的楼层都会碎,低于 F 的楼层都不会碎)。现在问你,最坏情况下,你至少要扔几次鸡蛋,才能确定这个楼层 F 呢?
也就是让你找摔不碎鸡蛋的最高楼层 F,但什么叫「最坏情况」下「至少」要扔几次呢?我们分别举个例子就明白了。
最坏情况:最原始的方式就是线性扫描:我先在 1 楼扔一下,没碎,我再去 2 楼扔一下,没碎,我再去 3 楼…
至少:现在再来理解一下什么叫「至少」要扔几次。依然不考虑鸡蛋个数限制,同样是 7 层楼,我们可以优化策略。
二分思路显然可以得到最少尝试的次数,但问题是,现在给你了鸡蛋个数的限制 K,直接使用二分思路就不行了。(后面的思路直接看帖子)
二、思路分析(dp函数表示状态转移)
对动态规划问题,直接套我们以前多次强调的框架即可:这个问题有什么「状态」,有什么「选择」,然后穷举。
「状态」很明显,就是当前拥有的鸡蛋数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)
「动态规划」的两个思考方向:
1. 自顶向下求解,称之为「记忆化递归」:初学的时候,建议先写「记忆化递归」的代码,然后把代
码改成「自底向上」的「递推」求解;(就是labuladong的解法,递归+备忘录)
2. 自底向上求解,称之为「递推」或者就叫「动态规划」:在基础的「动态规划」问题里,绝大多数都可以从这个角度入手,做多了以后建议先从这个角度先思考,实在难以解决再考虑「记忆化递归」。
第一步,选择和状态,略
第二步,状态转移方程
dp[i][j]=min(max(dp[K-1][i-1], dp[K][N-i])+1)
第三步,考虑初始化
一般而言,需要 0 这个状态的值,这里 0 层楼和 0 个鸡蛋是需要考虑进去的,它们的值会被后来的值所参考,并且也比较容易得到。因此表格需要 N + 1 行,K + 1 列。
第四步:考虑输出
输出就是表格的最后一个单元格的值 dp[N][K]
第五步:思考状态压缩
看状态转移方程,当前单元格的值只依赖之前的行,当前列和它左边一列的值。可以状态压缩,让「列」滚动起来。但是「状态压缩」的代码增加了理解的难度,我们这里不做。
一、回溯思路
先来顺一下解决这种问题的套路:
我们前文多次强调过,很显然只要涉及求最值,没有任何奇技淫
巧,一定是穷举所有可能的结果,然后对比得出最值。
所以说,只要遇到求最值的算法问题,首先要思考的就是:如何穷举出所有可能的结果?
穷举主要有两种算法,就是回溯算法和动态规划,前者就是暴力穷举,而后者是根据状态转移方程推导「状态」。
如何将我们的扎气球问题转化成回溯算法呢?这个应该不难想到的,我们其实就是想穷举戳气球的顺序,不同的戳气球顺序可能得到不同的分数,我们需要把所有可能的分数中最高的那个找出来,对吧。
那么,这不就是一个「全排列」问题嘛,我们前文 回溯算法框架套路详解 中有全排列算法的详解和代码,其实只要稍微改一下逻辑即可,伪码思路如下:
int res = INT_MIN;
// 输入一组气球,返回戳破它们获得的最大分数
int maxCoins(vector nums){
backtrack(nums, 0);
return res;
}
// 回溯算法的伪码解法
void backtrack(vector nums, int score){
if(nums为空){
res=max(res, score);
return;
}
for(int i =0; i
回溯算法就是这么简单粗暴,但是相应的,算法的效率非常低。这个解法等同于全排列,所以时间复杂度是阶乘级别,非常高,题目说了nums的大小n最多为 500,所以回溯算法肯定是不能通过所有测试用例的。
二、动态规划思路
这个动态规划问题和我们之前的动态规划系列文章相比有什么特别之处?为什么它比较难呢?
原因在于,这个问题中我们每戳破一个气球 nums[i],得到的分
数和该气球相邻的气球 nums[i-1] 和 nums[i+1] 是有相关性的。
我们前文动态规划套路框架详解 说过运用动态规划算法的一个重要条件:==子问题必须独立。==所以对于这个戳气球问题,如果想用动态规划,必须巧妙地定义 dp 数组的含义,避免子问题产生相关性,才能推出合理的状态转移方程。
如何定义 dp 数组呢,这里需要对问题进行一个简单地转化。题目说可以认为 nums[-1] = nums[n] = 1,那么我们先直接把这两个边界加进去,形成一个新的数组 points:
int maxCoins(vector<int> nums){
int n = nums.size();
// 两端加入两个虚拟气球
vector<int> points(n+2);
points[0] = points[n+1] = 1; // 两端
for(int i =1; i<=n;i++){
points[i]=nums[i-1];
}
// ...
}
那么我们可以改变问题:在一排气球 points 中,请你戳破气球 0
和气球 n+1 之间的所有气球(不包括 0 和 n+1),使得最终只
剩下气球 0 和气球 n+1 两个气球,最多能够得到多少分?
现在可以定义dp数组的含义:
dp[i][j]=x
表示,戳破气球 i 和气球 j 之间(开区间,不包括 i 和 j)的所有气球,可以获得的最高分数为 x。而 base case 就是 dp[i][j] = 0(i==j)
,其中 0 <= i <= n+1, j <= i+1,因为这种情况下,开区间 (i, j) 中间根本没有气球可以戳。
vector> dp(n+2, vector(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, (i,i)
和气球j (j,j)
,这也恰好满足了dp数组开区间的定义。
那么,对于一组给定的i
和j
, 我们只要穷举i
k
, 选择得分最高的作为dp[i][j]
的值即可, 这也就是状态转移方程:
// 最后戳破的气球是哪个?
for(int k =i+1; k
写出状态转移方程就完成这道题的一大半了,但是还有问题:对于k的穷举仅仅是在做选择,到那时如何穷举状态i和j呢:
for(int i = ...; ;)
for(int j = ...; ;)
for(int k = i+1; k
三、写出代码
关于「状态」的穷举,最重要的一点就是:状态转移所依赖的状态必须被提前计算出来。
拿这道题举例,dp[i][j]
所依赖的状态是 dp[i][k]
和 dp[k][j]
,那么我们必须保证:在计算 dp[i][j]
时,dp[i][k]
和 dp[k][j]
已经被计算出来了(其中 i < k < j)。
那么应该如何安排 i 和 j 的遍历顺序,来提供上述的保证呢?我们前文 动态规划答疑篇 写过处理这种问题的一个鸡贼技巧:根据 base case 和最终状态进行推导。
PS:最终状态就是指题目要求的结果,对于这道题目也就是 dp[0][n+1]。
我们先把 base case 和最终的状态在 DP table 上画出来:
对于任一dp[i][j]
,我们希望所有dp[i][k]和dp[k][j]
已经被计算,画在图上就是这种情况:
那么,为了达到这个要求,可以有两种遍历方法,要么斜着遍历,要么从下到上从左到右遍历:
斜着遍历有一点难写,所以一般我们就从下往上遍历,下面看完整代码:
int maxCoins(vector<int> nums){
int n = nums.size();
// 添加两侧的虚拟气球
vector<int> points(n+2);
points[0]=points[n+1]=1;
for(int i = 1;i<=n;i++){
points[i]=nums[i-1];
}
// base case 已经都被初始化为0
vector<vector<int>> dp(n+2, vector<int>(n+2));
// 开始状态转移
// i应该从下往上
for(int i = n; i>=0;i--){
// j 应该从左往右(从对角线右旁边一个位置)
for(int j = i+1; j<n+2; j++){
// 最后戳破的气球是哪个?
for(int k = 1+1; k<j;k++){
// 择优做选择
dp[i][j]=max(dp[i][j], dp[i][k]+dp[k][j]+points[i]*points[j]*points[k]);
};
}
}
return dp[0][n+1];
}
labuladong
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
最长公共子序列(Longest Common Subsequence,简称 LCS)是一
道非常经典的面试题目,因为它的解法是典型的二维动态规划,大部分
比较困难的字符串问题都和这个问题一个套路,比如说 ==编辑距离==
肯定有读者会问,为啥这个问题就是动态规划来解决呢?因为子序列类型的问题,穷举出所有可能的结果都不容易,而动态规划算法做的就是穷举 + 剪枝,它俩天生一对儿。所以可以说只要涉及子序列问题,十有八九都需要动态规划来解决,往这方面考虑就对了。
一、动态规划思路
比如说对于字符串 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中,需要丢弃一个。先看一下递归解法,比较容易理解:
```python
def longestCommonSubsequence(str1, str2)->int:
def dp(i, j):
if i == -1 or j == -1:
return 0;
if str[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)
其实这段代码就是暴力解法,我们可以通过备忘录或者 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[m-1][n-1]
二、疑难解答
对于 s1[i] 和 s2[j] 不相等的情况,至少有一个字符不在 lcs 中,会不会两个字符都不在呢?比如下面这种情况:
所以代码是不是应该考虑这种情况,改成这样:
if str1[i-1]==str2[j-1]:
# ...
else:
dp[i][j]= max(dp[i][j], dp[i-1][j], dp[i][j-1])
我一开始也有这种怀疑,其实可以这样改,也能得到正确答案,但是多此一举,因为 dp[i-1][j-1] 永远是三者中最小的,max 根本不可能取到它。
原因在于我们对 dp 数组的定义:对于 s1[1…i] 和 s2[1…j],它们的 LCS 长度是 dp[i][j]。
三、总结
对于两个字符串的动态规划问题,一般来说都是像本文一样定义 DP table,因为这样定义有一个好处,就是容易写出状态转移方程,dp[i][j] 的状态可以通过之前的状态推导出来:
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.length(), n = text2.length();
//1. 选择与状态
// 选择是text1[i]与text[j]是否相等
// 状态:对text1[0..i-1]与text2[0..j-1],他们的之间的LCS为dp[i][j]
// 画dp[][]表知道base case及如何得到最终答案 dp[i][j]
//2. base case dp[..][0]=0, dp[0][..]=0
vector<vector<int>> dp(m+1, vector<int>(n+1));
for(int i = 1; i<m+1; i++){
for(int j = 1; j<n+1; j++){
// 选择相等
if(text1[i-1]==text2[j-1]){
// 最常见的状态转移方程
dp[i][j]=dp[i-1][j-1]+1;
}
// 选择不相等
else{
dp[i][j]=max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[m][n];
}
};
子序列问题是常见的算法问题,而且并不好解决。
首先,子序列问题本身就相对子串、子数组更困难一些,因为前者是不连续的序列,而后两者是连续的,就算穷举你都不一定会,更别说求解相关的算法问题了。
而且,子序列问题很可能涉及到两个字符串,比如前文「最长公共子序列」,如果没有一定的处理经验,真的不容易想出来。所以本文就来扒一扒子序列问题的套路,其实就有两种模板,相关问题只要往这两种思路上想,十拿九稳。
一般来说,这类问题都是让你求一个最长子序列,因为最短子序列就是一个字符嘛,没啥可问的。一旦涉及到子序列和最值,那几乎可以肯定,考察的是动态规划技巧,时间复杂度一般都是 O(n^2)。
原因很简单,你想想一个字符串,它的子序列有多少种可能?起码是指数级的
吧,这种情况下,不用动态规划技巧,还想怎么着?
what are you wanna 弄啥呢?
既然要用动态规划,那就要定义 dp 数组,找状态转移关系。我们说的两种思路模板,就是 dp 数组的定义思路。不同的问题可能需要不同的 dp 数组定义来解决。
一、两种思路
1、第一种思路模板是一个一维的dp数组:
int n = array.length();
// base case
vector<int> dp(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();
// base case
vector<vector<int>> (n, vector<int>(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]中,最长回文子序列的长度呢?)
可以,这取决于s[i]和s[j]的字符:
如果它俩相等,那么它俩加上s[i+1…j-1]中的最长回文子序列就是s[i…j]的最长回文子序列:
以上两种情况写成代码就是这样:
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(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]);
}
}
}
return dp[0][n-1];
}