首先,我们需要确定适用动态规划这种算法的题目特征,毕竟笔试题不会好心地在旁边标上动态规划的标签。
维基百科:动态规划在查找有很多重叠子问题的情况的最优解时有效……只能应用于有最优子结构的问题。听起来非常云里雾里(专业总结都是这么抽象)。我们先来理解一下最优子结构。最优子结构是局部最优解能决定全局最优解。也就是说,动态规划其实和分治方法非常类似,都是通过组合子问题的解来求解原问题,但是分治方法划分的子问题互不相交,而动态规划的子问题则互相重叠。为了减少复杂度,保证每个子问题只求解一次,我们利用**历史记录(备忘录)**来避免重复计算,其实就是所谓的dp
数组(一维/二维/甚至有三维,不过一般会压缩到二维)
下面就正式进入求解三部曲:
在求解出正确答案的前提下,我们还常常进行dp数组的压缩。这部分将在后面具体展开。
练手题:53(经典) 70 213 198 413(可以直接用数学知识解) 650(有坑,解法很巧妙)
浅浅总结一下以上涉及到的dp
数组的含义
Q:求在不触发机关的情况下最多可以抢劫这n个房子的多少钱
dp[i]
:抢劫到第 i 个房子时,可以抢劫的最大数量(return dp[n]
)
Q:求给定数组中连续且等差的子数组一共有多少个
dp[i]
:以第i个数组元素结尾的子数组数目(return accumulate(dp.begin(), dp.end(), 0)
)
Q:给定整数数组 nums
,请找出一个具有最大和的连续子数组(最少包含一个元素),返回其最大和
dp[i]
:dp[i]表示以i结尾的连续数组的最大值,最后返回其中的最大值
我们发现,dp数组的含义大多和求解问题直接挂钩,当然后面会根据问题的不同需要部分变通
下面讲解一些个人认为比较有代表性的题:
213
打家劫舍 II:作为70题的plus版本,该题增加了一个限制:这个地方所有的房屋都 围成一圈 。这意味着第一个房屋和最后一个房屋是紧挨着的。本菜狗一开始一直试图通过改变dp数组的含义或者状态转移方程来解题,后面发现正确的思路应该是分治:分为不偷第一间和不偷最后一间的情况。**注意是不偷不是偷!**如果是偷的话就需要分三种情况了。想通后就豁然开朗了。
class Solution {
public:
//不要想着一次性解决,将情况分为两种,最后取max就好
int rob(vector& nums) {
if(nums.size()==1) return nums[0];
//不偷第一间!分情况不是分成偷第一间和偷最后一间,这样会出来三种情况
//动态规划中也是可以结合分治算法的
int ppre=0,pre=0,cur1,cur2;
for(int i=1;i
343
整数拆分:给定正整数 n
将其拆分为 k
个 正整数 的和( k >= 2
)并使这些整数的乘积最大化。
特点:本身不难,但是有很多小细节,以及转移方程不一定是根据dp的历史结果
int integerBreak(int n) {
vector dp(n+1,1);
for(int i=2;i<=n;i++)
for(int j=1;j
650
:最初记事本上只有一个 ‘A’ ,可以通过Copy All(复制当前全部)和Paste(粘贴上一次复制的字符),求最少的操作次数使记事本上输出恰好 n 个 ‘A’ 。
审题:是拷贝当前!的全部,粘贴也只能是上一次!复制的字符。不同于以往通过加减实现的动态规划,这里需要乘除法来计算位置,因为粘贴操作是倍数增加的。
dp[i]:延展到长度 i 的最少操作次数。对于每个位置 j,如果 j 可以被 i 整除,那么长度 i 就可以由长度 j 操作得到,其操作次数等价于把一个长度为 1 的 A 延展到长度为 i/j。因此递推公式是 dp[i] = dp[j] + dp[i/j]。
注意,官方给的题解状态转移方程是min(dp[i/j]+j-1,dp[j]+i/j-1),然后将所有的因子全部循环一遍。其实并不需要。我们可以将一个j长度或 i/j长度的数看作一个整体,然后执行dp[i/j]或dp[j]次操作,从而保证正确性
int minSteps(int n) {
vector dp(n + 1);
int h = sqrt(n);
for (int i = 2; i <= n; ++i) {
dp[i] = i;
for (int j = 2; j <= h; ++j) {
if (i % j == 0) {
dp[i] = dp[j] + dp[i/j];
break;
}
}
}
return dp[n];
}
练手题:
64(easy) 542(有多种解法) 221(状态转移方程是关键) 279 91(本身不难,但很考验耐心和细心)
139(just so so,我自己根据给定单词的尾字母建了个unorder_map,略微优化了一番) 300
646(此类题常常根据下一个元素排序,注意返回值)
下面讲解一些个人认为比较有代表性的题:
542:01 Matrix
:给定一个由 0 和 1 组成的二维矩阵,求每个位置到最近的 0 的距离。
特点:涉及四个方向的最近搜索。最直观的想法显然是广度优先搜索,但是复杂度会达到O(m2n2)。我们考虑将四个方向拆成两次,每次两个方向:从左上到右下进行一次动态搜索,再从右下到左上进行一次动态搜索
vector> updateMatrix(vector>& matrix) {
if (matrix.empty()) return {};
int n = matrix.size(), m = matrix[0].size();
vector> dp(n, vector(m, INT_MAX - 1));
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (matrix[i][j] == 0)
dp[i][j] = 0;
else {
if (j > 0)
dp[i][j] = min(dp[i][j], dp[i][j-1] + 1);
if (i > 0)
dp[i][j] = min(dp[i][j], dp[i-1][j] + 1);
}
}
}
for (int i = n - 1; i >= 0; --i) {
for (int j = m - 1; j >= 0; --j) {
if (matrix[i][j] != 0) {
if (j < m - 1)
dp[i][j] = min(dp[i][j], dp[i][j+1] + 1);
if (i < n - 1)
dp[i][j] = min(dp[i][j], dp[i+1][j] + 1);
}
}
return dp;
}
646
最长数对链
这道题的思路说实话并不是第一次见了,将数组根据后一个元素的值进行升序排列(借用STL的sort算法),然后简单dp一下即可。注意返回值不一定是最后一个元素。
int findLongestChain(vector>& pairs) {
//先根据pairs的第二个数字进行升序排列
sort(pairs.begin(),pairs.end(),[](vector &a,vectorb){
if(a[1]==b[1])
return a[0] dp(len,1);
for(int i=1;i=0;--j){
if(pairs[j][1]
练手题:1143(超重点!!) 583 72(HARD!) 10(转移方程很复杂)
下面进行具体讲解
1143
:求给定字符串的最长公共子序列
这是一个非常明显的动态规划问题。以测试数据text1=”abcde“,text2=”bde“为例,在有两个字符串/数组的问题中,我们常见的模拟策略是固定一个字符串text1,将text2的字符串从头开始依次尝试和整个text1匹配并填充dp数组,即先将”b“与text1全部匹配,填满dp[1],然后根据dp[1]与”bd“将dp[2]填充,循序渐进。总时间复杂度是O(mn)。边界情况直接当作一个字符串为空,按照常规思路去填充即可。
动态规划看起来是就某个中间状态进行分析,但是实际填充的时候也是从无到有,从最小的子问题开始逐步解决的,所以分析初始状态对求解是非常有帮助的。
int longestCommonSubsequence(string text1, string text2) {
int len1=text1.length();
int len2=text2.length();
vector > dp(len1+1,vector(len2+1,0));
for(int i=1;i<=len1;i++){
for(int j=1;j<=len2;j++){
if(text1[i-1]==text2[j-1])//注意要-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[len1][len2];
}
72
编辑距离:求将 word1 转换成 word2 所使用的最少操作数 。可执行操作:插入/删除/替换一个字符
坑:**插入一次和删除一次是可以合并成一个替换的!**想通这一步就很简单了。
dp[i] [j]:将第一个位置1到i的字符串转换成第二个位置1到j的字符串最多需要几步编辑。当第 i 位和第 j 位对应的字符相同时,dp[i] [j] 等于dp[i-1] [j-1];当二者对应的字符不同时,修改的消耗是 dp[i-1] [j-1]+1,插入 i 位置/删除 j 位置 的消耗是 dp[i] [j-1] + 1,插入 j 位置/删除 i 位置的消耗是 dp[i-1] [j] + 1。
此外,初始状况的初始化也非常重要!我们可以直接从原始意义出发进行处理。
int minDistance(string word1, string word2) {
int len1=word1.length();
int len2=word2.length();
vector > dp(len1+1,vector(len2+1,0));
for(int i=0;i<=len1;i++)
for(int j=0;j<=len2;j++){
if(i==0)
dp[i][j]=j;
else if(j==0)
dp[i][j]=i;
else{
dp[i][j]=min(
dp[i-1][j-1] + ((word1[i-1] == word2[j-1])? 0: 1),
min(dp[i-1][j] + 1, dp[i][j-1] + 1));
//583题改行改成
//dp[i+1][j+1]=min(dp[i+1][j],dp[i][j+1])+1;
}
}
return dp[len1][len2];
}
583
:把72题操作中的替换去掉。只需要浅浅修改一下状态转移方程即可。代码见上。
10
正则表达式匹配:给定字符串 s 和字符规律 p,要求实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。注意,‘.’ 匹配任意单个字符,’ *’ 匹配零个或多个前面的那一个元素
难点:其实构造出dp数组并不难,关键是数组的初始化和状态转移方程。当遇到*时,我们需要非常小心,不同的可能情况应该是前面的一次也没匹配过(dp[i] [j-2])或前面的已经匹配过一次且当前对应元素可匹配,即将 *和它前面的元素看作一个整体,一荣俱荣一损俱损。
画图!动态规划在想不清楚时可以通过一个实例来画图,辅助理清思路。
bool isMatch(string s, string p) {
int len1=s.length();
int len2=p.length();
vector> dp(len1+1,vector(len2+1,false));
//初始化
dp[0][0]=true;
for(int j=1;j<=len2;j++){
if(p[j-1]=='*')
dp[0][j]=dp[0][j-2];
}
//开始dp,可以压缩,从左到右
for(int i=1;i<=len1;++i)//注意:是从1开始,而p/s是从1开始
for(int j=1;j<=len2;++j)
{
if(p[j-1]=='*')//这个方程不好推,一个是0次,一个是一次以上
dp[i][j]=(dp[i][j-2]) | (dp[i-1][j] && (p[j-2]=='.' | p[j-2]==s[i-1]));
else
dp[i][j]=dp[i-1][j-1] && (p[j-1]=='.' | p[j-1]==s[i-1]);
}
return dp[len1][len2];
}
NP完全问题:N个物品和容量为W的背包,每个物品都有自己的体积 w 和价值 v,求哪些物品可使背包所装物品总价值最大。如果限定每种物品只能选择 0 个或 1 个,则问题称为 0-1 背包问题;如果不限定每种物品的数量,则问题称为无界背包问题或完全背包问题。
0-1背包
int knapsack(vector weights, vector values, int N, int W) {
vector> dp(N + 1, vector(W + 1, 0));
for (int i = 1; i <= N; ++i) {
int w = weights[i-1], v = values[i-1];
for (int j = 1; j <= W; ++j) {
if (j >= w)
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w] + v);
else
dp[i][j] = dp[i-1][j];
}
}
return dp[N][W];
}
int knapsack(vector weights, vector values, int N, int W) {
vector dp(W + 1, 0);
for (int i = 1; i <= N; ++i) {
int w = weights[i-1], v = values[i-1];
for (int j = W; j >= w; --j)//这里只需要到w之前即可
dp[j] = max(dp[j], dp[j-w] + v);
return dp[W];
}
完全背包
int knapsack(vector weights, vector values, int N, int W) {
vector> dp(N + 1, vector(W + 1, 0));
for (int i = 1; i <= N; ++i) {
int w = weights[i-1], v = values[i-1];
for (int j = 1; j <= W; ++j) {
if (j >= w)
dp[i][j] = max(dp[i-1][j], dp[i][j-w] + v);//区别
else
dp[i][j] = dp[i-1][j];
}
}
return dp[N][W];
}
int knapsack(vector weights, vector values, int N, int W) {
vector dp(W + 1, 0);
for (int i = 1; i <= N; ++i) {
int w = weights[i-1], v = values[i-1];
for (int j = w; j <=W; ++j)//区别
dp[j] = max(dp[j], dp[j-w] + v);
return dp[W];
}
练手题:
416(关键在于如何变更成背包问题)474(三维压缩成二维的典例)323(请非常注意初始化的边界问题!)
494(怎么把看似诡异的问题转换成0-1背包问题)
416
:给定一个正整数数组,求是否可以把这个数组分成和相等的两部分。
这道题可以换一种表述:给定一个只包含正整数的非空数组nums[0],判断是否可以从数组中选出一些数字,使得这些数字的和等于(传统背包问题是小于)整个数组的元素和的一半。
dp[i] [j]: 从数组的 [0,i]下标范围内选取若干个正整数(可以是 0 个),是否存在一种选取方案使得被选取的正整数的和等于 j。初始时dp 中的全部元素都是false。
边界情况:不选取任何正整数和只有一个正整数。这道题要非常关注dp数组的初始化!
class Solution {
public:
bool canPartition(vector& nums) {
int maxNum=INT_MIN;
int sum=0;
for(auto& num:nums){
sum+=num;
maxNum=max(num,maxNum);
}
//判断一些基本条件
if(sum%2 || maxNum>sum/2)
return false;
//正式开始动态规划,目标dp[n-1][target]
int target=sum/2;
int n=nums.size();
vector > dp(n,vector(target+1,false));
//初始化,当target=0时全部true
for(int i=0;i
474:Ones and Zeros
:给定 m 个数字 0 和 n 个数字 1,以及一些由 0-1 构成的字符串,求利用这些数字最多可以构 成多少个给定的字符串,字符串只可以构成一次。
三维压缩成二维的典例,思路总体不难。
int findMaxForm(vector& strs, int m, int n) {
vector> dp(m+1,vector(n+1,0));
for(auto& str:strs){
int x=count(str.begin(),str.end(),'0');
int y=count(str.begin(),str.end(),'1');
for(int i=m;i>=0;i--)
for(int j=n;j>=0;j--){
if(x<=i && y<=j)
dp[i][j]=max(dp[i][j],dp[i-x][j-y]+1);
}
}
return dp[m][n];
}
323:Coin Change
:给定一些硬币的面额,求最少可以用多少颗硬币组成给定的金额。
dp 数组应该初始化为 amount + 1, 而不是 -1 或INT_MAX:状态转移方程中有求最小值的操作,如果初始化成 -1 则会导致结果始终为-1,而INT_MAX会导致+1操作溢出。
int coinChange(vector& coins, int amount) {
int len=coins.size();
vector dp(amount+1,amount+1);//用INT_MAX会溢出
dp[0]=0;//初始化,保证放第一个正确,或者初始化
for(auto& coin:coins)
for(int i=0;i<=amount;i++){
if(i>=coin)
dp[i]=min(dp[i],dp[i-coin]+1);//取模运算同样会导致出问题
}
return dp[amount]==amount+1? -1 : dp[amount];
}
494
目标和:给定一个整数数组 nums 和一个整数 target 。向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个表达式 :例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。返回可以通过上述方法构造的、运算结果等于 target 的不同表达式的数目
**先来看看我华丽丽的错误解法吧(也是用了动态规划)。**定义dp[i] [j]:前i个数字能构造出的目标和为target的表达式数目。则dp[i+1] [j] =dp[i] [j-nums[i]] + dp[i] [j+nums[i]] ,并将dp[0] [0]初始化为1,其余为0。在计算过程中加一个防御,防止数组访问过界即可。
乍一看是不是完全没有没有问题!但是!别忘了j+nums[i] 在超过target以后是可以通过减法回退到target的(这就有了下面的1.0改进版本),同样的,j-nums[i] 在小于0之后也是有可能通过加法前进到target的。于是我们发现,dp数组需要根据测试数据整体平移,大大扩大需要测试的子问题target范围……悲伤.jpg
int findTargetSumWays(vector& nums, int target) {
int len=nums.size();
//右侧边界不能只设定成target,而应该是全部的和
//同理,当求和小于0时是不是也得扩展……要么把数组整体平移,要么寻找其他方法
//将整个问题转换,看成非常明显的0-1背包
int sum=accumulate(nums.begin(),nums.end(),0);
int col=max(sum,target);//最开始是target
vector > dp(len+1,vector(col+1,0));
dp[0][0]=1;
for(int i=0;i=nums[i])? dp[i][j-nums[i]] : 0;
dp[i+1][j] += (j+nums[i]<=col)? dp[i][j+nums[i]] : 0;
}
}
return dp[len][target];
}
下面让我们忘掉我愚蠢的解法,回到正解。我们知道整个数组的sum值和目标的target值是固定的,于是假设加了负号的值之和为negative,则有sum-2*negative=target,即negative=(sum-target)/2。**此时原问题就转换成了求数组中那些数字求和等于negative。**这不就是0-1背包吗!简简单单AC了……
int findTargetSumWays(vector& nums, int target) {
int sum = 0;
for (int& num : nums)
sum += num;
int diff = sum - target;
if (diff < 0 || diff % 2 != 0)
return 0;
int n = nums.size(), neg = diff / 2;
vector> dp(n + 1, vector(neg + 1));
dp[0][0] = 1;
for (int i = 1; i <= n; i++) {
int num = nums[i - 1];
for (int j = 0; j <= neg; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= num)
dp[i][j] += dp[i - 1][j - num];
}
}
return dp[n][neg];
}
另外动态规划还有一类经典题【股票问题】,详见同专栏下的博文《吃透“买卖股票”问题》