目录
一、基本概念
本文内容主要讲述动态规划解题的主要思路,但并不是解题步骤,具体问题还要具体分析,但是基本思路都是如此。
动态规划解题的一个标志就是题目具有重叠子问题的性质,因此动态规划是典型的空间换时间的算法设计技术。
1.1. 自顶向下(递归+哈希)
依据动态规划重叠子问题的特性,在每次递归求解原问题时,都要重复的使用子问题的解,因此在每次求解成功之后,将解保存在一个哈希表中。之后再遇到相同的子问题时,直接查表即可。
举例说明。
-
斐波那契数列(F(N)=F(N-1)+F(N-2))
以求F(20)为例,对其进行递归求解,发现会出现重复的子问题。因此如果递归调用的话运算会非常慢。
如果在计算F(19)的时候将F(18)记录下来,就可以直接查表调用F(18),进而直接加和得到F(20)。 -
最长上升子序列LIS(给定一个无序的整数数组,找到其中最长的元素递增的子序列的长度。)
递归求解的思路是在所有比前一元素大的子数组中算一次LIS,将数组中的每一个元素都如此算一次为止。
以[10,9,2,5,3,7,101,18]为例。先计算开头为10的LIS,那么上升序列第二个元素可能是101,也可能是18,然后依次计算101为起始的LIS和18为起始的LIS。下次计算开头为9的LIS,以此类推直到数组末尾。
看图比较直观(图中“[9,… ”表示以9开头的LIS,圆圈标注的是相同的子问题)
从图中可以发现,计算过程中出现了好多重复子问题。因此如果在计算过程中用哈希表存储的话,就可以避免子问题重复运算。 -
零钱兑换(给定不同面额的硬币 coins 和一个总金额 amount。计算可以凑成amount所需的最少的硬币个数。认为每种面额的硬币有无数个。如果没有任何一种硬币组合能组成总金额,返回 -1。)
注意此题不能用贪心算法做,如果每次都用最大额的硬币兑换的话,可能出现不能兑换的情况。例如coins=[2,5],amount=18,显然结果不是-1。
使用递归思路做,对于每一个coins[i],计算amount的找零问题,可以变成计算amount-coins[i],i=0,…,coins.size的找零问题,在所有不是-1的结果中取最小即可。然后考察重叠子问题,本题以coins=[1,2,5],amount=11 为例
重叠子问题如下图所示,其中Coinchange(11)表示求解amount=11的零钱兑换问题
部分重叠子问题如图所示,因此如果再计算coinchange(9)时把问题解存起来后面就可以直接查表即可。
那么如何具体操作呢?
自顶向下的动态规划一般遵循以下过程:
- 得到子问题间的递推关系。上述几个问题都是在知道递推关系的基础上,画图进行子问题分析的。
- 写程序
- 添加哈希表的访问与存储部分。在原代码的基础上,加上哈希表的查询(哈希表中是否存在本问题的解)、存储(如果不存在,则计算并存储)。
针对第三点,写个例子
以找零问题为例,以递归的方式写出问题求解伪代码。
coinChange(coins,amount) {
if (amount == 0)
return 0;
result = INT_MAX;
for c in coins{
if (amount - c < 0)
continue;
subProb = coinChange(coins, amount - c);
if (subProb == -1)
continue;
result = min(result, subProb + 1);
}
return result;
}
然后考虑加入哈希表记忆结果。其实非常简单,在函数开头加上判断哈希表中是否存在解,在函数结尾将解填入哈希表即可。改变之后的代码如下
coinChange(coins,amount,hashmap[]) {
if(hashmap[amount] is valid)
return hashmap[amount];
if (amount == 0)
return 0;
result = INT_MAX;
for c in coins{
if (amount - c < 0)
continue;
subProb = coinChange(coins, amount - c,hashmap[]);
if (subProb == -1)
continue;
result = min(result, subProb + 1);
}
hashmap[amount]=result;
return result;
}
1.2. 自底向上(分类讨论)
其实大多数动态规划解题都是按照自底向上来设计的,因为递归对计算机需求较大,因此常常通过自底向上(即迭代)来实现。
自底向上的含义是,从问题的最平凡的情况入手(例如斐波那契F(1)和F(0)),依据递推式边计算边记录。直到最后的问题F(n)
那么如何具体操作呢?
自底向上的动态规划一般遵循以下过程:
- 确定状态变量,就是说哈希表hashmap[i]的含义是什么。一般来讲状态变量即为题目所求
一维状态变量:例如斐波那契数列中hashmap[i]的值表示第i个斐波那契数的值,最长上升子序列LIS问题中hashmap[i]表示以i开头的的LIS,找零问题中的hashmap[i]表示amount=i时最少金币数,最大子序列和问题中hashmap[i]表示序列{num[0],…,num[i]}最大子序列和。
二维状态变量:可能表区间,例如最长回文子序列问题中hashmap[i][j]表示子串s.substr(i,j-i+1)是否是回文,最长公共子序列问题中hashmap[i][j]表示s1.sub(0,i+1)和s2.sub(0,j+1)的公共子串个数,不同路径问题中hashmap[i][j]表示坐标(i,j),最大正方形问题中hashmap[i][j]表示以(i,j)为右下角的面积最大的正方形的面积。还可能表状态,例如打家劫舍系列问题中hashmap[i][j]表示第i天交易结束是否持有股票(j=1持有,j=0未持有),例如石子游戏中hashmap[i][j][k]表示石子排布为{num[i],…,num[j]}时是否是亚历克斯先手(k=1是,k=0否)。
选择状态变量的含义和维数之后,如果不能顺利写出递推式,则需要作出改变。
- 写出状态转移方程(递推式),即子问题之间的关系方程,即hashmap[i]与hashmap[i-1],…,hashmap[0]以及各元素num[i],…,nums[0[之间的关系,需要根据题意作出计算,同时一般也是动态规划解题中的困难所在。
但是状态转移方程的写法有一个重要的思想——分类讨论思想。
- 如果是最优化问题,则考虑所有互斥的情况。
例如最长上升子序列LIS问题,dp[i]表示{nums[0],…,nums[i]}的最长上升子序列长度,分为两种情况——不包括nums[i]的LIS或包括nums[i]的LIS。不包括nums[i]的LIS其实就是dp[i-1],而包括nums[i]的LIS分为多种情况——所有num[k]<num[i]的dp[k]+1。从而得到递推式
ans=max(dp[k])+1, k为所有满足nums[k]<nums[i]且属于{0,1,…,i-1}的值
dp[i]=max(dp[i-1],ans);
- 如果是可行性问题,则考虑所有可能的情况。
例如不同路径系列问题中,hashmap[i][j]表示由起点到达坐标(i,j)的路径种数。假设终点坐标是(fa,fb)因此到达终点的可能情况有两种,(fa-1,fb)→(fa,fb)以及(fa,fb-1)→(fa,fb),这样就完整地考虑了所有可能的情况。得到递推式
dp[fa][fb]=dp[fa-1][fb]+dp[fa][fb-1];
- 确定遍历方式,写出程序。在知道递推方程的情况下,如何实现从平凡情况推算到最后的问题F(n)呢(如何实现自底向上)?
本问题在多维动态规划中需要仔细思考。
以最长回文子串(找到给定字符串中最长的回文字符串。回文是指字符串正序倒序相同,如abccba就是,bd就不是)为例。
定义状态变量dp[i][j]表示字符串中下标i到下标j构成的子串是否是回文,因此状态变量为bool型变量。可以得到递推式
dp[i][j]=dp[i+1][j-1] && (s[i]==s[j]);
意思是当且仅当下标i+1到j-1构成的子串是回文且下标i对应的字符和下标j对应的字符相同时,dp[i][j]=True。
比如string s=“asdffdsb“,s[2,5] == “dffd” 显然是回文,又由于s[1] = = s[6] = =‘s’因此s[1,6]是回文。
有了递推式关键在于如何遍历最终得到dp[0][n]的结果。显然从i=0,j=0到i=0,j=n是不可以的。可以发现递推式中dp[i][j]是对长度为j-i+1的某个子串进行判断,而它与dp[i+1][j-1],即长度为j-i-1的子串有关,因此遍历可以按照长度进行遍历。
从而得到伪代码
for length=1 to string.size():
for start=0 to string.size()-length:
end=start+length-1;
dp[start][end]=dp[start+1][end-1] && (string[start]==string[end]);
1.3. 总结
动态规划实现方式两种
- 自顶向下:用递归方式写出原问题的代码+添加哈希表查询和添加部分
- 自底向上:确定状态变量+利用分类讨论思想写出状态转移方程+画表确定遍历方法
二、解题思路
2.1. 子数组子串问题
一维子数组(元素必须相邻的子集)问题
状态变量dp[i]:表示下标0到i的题目所求
(例如,最大子序和问题中dp[i]表示nums[0,…,i]的最大子序列和)
状态转移方程:dp[i]=f(dp[i-1],以i结尾的题目所求)
(例如最大子序问题状态转移方程为dp[i]=max(dp[i-1],sum[i]),其中sum[i]表示以i结尾的最大子序和)
二维子数组(元素必须相邻的子集)问题
状态变量dp[i][j]:表示下标i到j的题目所求
状态转移方程:dp[i][j]=f(dp[i+1][j-1],dp[i][j-1],dp[i+1][j],元素i和元素j)
2.2. 子序列问题
子序列(元素可以不相邻)问题
状态变量dp[i]:表示以i结尾的题目所求
(例如,最长上升子序列问题中dp[i]表示以nums[i]结尾的最长子序列)
状态转移方程:dp[i]=f(dp[i-1],dp[i-2],…,dp[0],nums[i])
(例如最长上升子序列问题状态转移方程为 dp[i]=max({dp[k]+1|nums[k]<nums[i],k∈0,1,…,i}) )
2.3. 零一背包问题
问题:有同样大小的数组weight和value,其中weight[i]表示第i种物品重量,value[i]表示第i种物品价值,每种物品仅有1个,现有背包承重amount,求背包能装的最大价值。
思路:使用动态规划方法,对于没一件物品,分为两类——装与不装,因此可以直接得到算法。
状态变量:dp[j]表示背包承重为j时的最大价值
状态转移方程:dp[j]=max(dp[j-weight[i]]+value[i]),i=0,1,…,weightsize-1;
给出简化后的代码
for i=0 to weight.size-1:
for j=amount to weight[i]:
dp[j]=max(dp[j],dp[j-weight[i]]+value[i];
2.4. 完全背包问题
问题:有同样大小的数组weight和value,其中weight[i]表示第i种物品重量,value[i]表示第i种物品价值,每种物品有无数个,现有背包承重amount,求背包能装的最大价值。
思路:使用动态规划方法,对于没一件物品,分为两类——装与不装,但是可能装多个。
状态变量:dp[j]表示背包承重为j时的最大价值
状态转移方程:dp[j]=max(dp[j-k*weight[i]]+k-value[i]) ,i=0,1,…,weightsize-1 ,k=0,…,j/weight[i];
给出简化后的代码
for i=0 to weight.size-1:
for j=weight[i] to amount:
dp[j]=max(dp[j],dp[j-weight[i]]+value[i];
2.4. 状态机问题
三、相关题目
3.1. 子数组子串问题
数组
-
最大子序列积
注意max和min的运用 -
买卖股票的最佳时机
只能买卖一次 -
买卖股票的最佳时机III
最多买卖两次 -
目标和
给定一个非负整数数组和一个目标数。数组中任意一个数都只可使用+或-添加到其前面,使式子的最终结果恰好等于目标数,返回符号添加种数
例 :
输入: nums: [1, 1, 1, 1, 1], S: 3
-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3 则输出5 -
连续子数组和
判断在给定数组中是否存在一个元素连续的子数组(长度>1),其总和为给定目标数的倍数。
例如[4,8,6],k=6 输出True,因为4+8=2*6
字符串
-
括号生成
给出 n 代表生成括号的对数,请你写出一个函数,使其能够生成所有可能的并且有效的括号组合。 -
最长有效括号
给定一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长的包含有效括号的子串的长度。
提示:设定状态变量dp[i]表示以s[i]结尾的有效括号的长度,或是用栈 -
最长公共子序列(LCS)
i和j表示长度,因此创建的数组大小为s.size()+1,并注意初始化。
for(int i=1;i<=word1size;i++)
for(int j=1;j<=word2size;j++)
{
if(word1[i-1]==word2[j-1])
dp[i][j]=dp[i-1][j-1]+1;
else
dp[i][j]=max(dp[i][j-1],dp[i-1][j]);
}
-
给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符
二叉树、图
-
打家劫舍III
所有房屋的排列类似于一棵二叉树”。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。
计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。
思路:注意动态规划可以自底向上实现,也要注意自顶向下的实现(递归+哈希) -
最大正方形
注意二维DP问题是如何分类讨论的。 -
不同路径
由左上角出发,终点为右下角,求总的可行路径数量 -
不同路径II
由左上角出发,中间有障碍物,终点为右下角,求总的可行路径数量 -
出界的路径数
提示:结果数目较大因此最终结果要mod(1000000007)
数学
- 计算各个位数不同的数字个数
给定位数n,计算[0,10^n)之内各位数字均不相同的数字个数。例如n=2,输出91
3.2. 子序列问题
数组
-
摆动序列
序列中连续数字的差严格在正数与负数之间变化,这样的序列为摆动序列。认为一个元素和两个元素的序列均为摆动序列,出现相等则不是摆动序列。输出给定数组的最长摆动序列的长度。
例如 [1,17,5,10,13,15,10,5,16,8] 输出 7
其中一个可为[1,17,10,13,10,16,8]。 -
最大整除集h
给出一个由无重复的正整数组成的集合,找出其中最大的整除子集,子集中任意一对 (Si,Sj) 都要满足:Si % Sj = 0 或 Sj % Si = 0。
如果有多个目标子集,返回其中任何一个均可。 -
最长上升子序列
给定一个无序的整数数组,找到其中最长上升子序列的长度。
字符串
- 单词拆分
给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
数学
- 丑数II
求第n个丑数,丑数是指只能被2,3,5整除的正整数。
提示:虽然本题不是二叉树、图,但是本题蕴含着重要的分类讨论的思想——任何一个丑数一定是某个丑数乘2或乘3或乘5的结果。这非常类似于路径问题中,到达终点有上下左右这四种情况分类、买股票问题中的自动机原理。
如果不使用动态规划,可以使用基于优先队列的贪心算法。 - 完全平方数
给定正整数n,将n表示成完全平方数的加和,输出加数的最小个数。例如n=12,可以写12=9+1+1+1和12=4+4+4,但是后者个数最少,输出3。
3.3. 零一背包问题
-
分割等和子集
给定一个只包含正整数的非空数组,判断是否能够将数组分成和相同的两个子集。 -
一和零
给定0的个数m和1的个数n,以及一个只由0和1构成的字符串数组,求数组中0的个数≤m且1的个数≤n的最大字符串个数。
例如: strs = {“10”, “0001”, “111001”, “1”, “0”}, m = 5, n = 3
输出: 4
因为strs[0]、strs[1]、strs[3]和strs[4]中0的个数≤5且1的个数≤3,并且字符串个数达到最大 -
目标和
给定一个非负整数数组和一个目标数。数组中任意一个数都只可使用+或-添加到其前面,使式子的最终结果恰好等于目标数,返回符号添加种数
例 :
输入: nums: [1, 1, 1, 1, 1], S: 3
-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3 则输出5