一、采用动态规划处理待解决问题的特征
- 简单的子问题(Simple problem):有某种方式将全局优化问题分解为子问题,每个子问题都与原问题类似。有一种定义子问题的简单方式,如几个下标i,j,k等。
- 子问题最优性(Subproblem Optimality):问题的最优解 = 子问题最优解的组合。
- 子问题重叠(Subproblem Overlap):不相关子问题的最优解可以包含公共子问题。因此可以通过存储中间过程值避免重复递归调用。
二、动态规划常见应用
- 矩阵连乘(Matrix Chain-Product):在多个矩阵相乘的表达式A中,如何插入括号使得乘积个数最小。
- 望远镜调度(Telescope Scheduling):对于给定观察请求列表,如何在不冲突的情况下安排这些观察请求,使得被列入观察日程的观测的总效益达到最大。
- 博弈策略:每个博弈方在进行决策时可以选择多种做法的一类问题。
- 最长公共子序列(Longest Common Subsequence,LCS):每个字符串中按照字符出现的相对顺序选出的最长子串(这里的子序列不一定连续,子串(substring)一定连续)。
- 0-1背包问题(0-1 Knapsack Problem):如何在总重量不超过重量限制的前提下,最大化所携带物品的总价值。
- 最大子数组和(Max SubArray):给定一个整数数组,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
- 传递闭包:给定一个有向图/无向图,如果结点、连通但不直接相连,就建立一个边。
- 所有结点对之间的最短路径(All-Paris Shortest Paths)。
- ……
三、矩阵连乘问题
- 求的乘积,其中是阶矩阵,。
- 定义子问题:子问题为,表示计算这个表达式所需的最小乘积数,原矩阵连乘问题=计算的值。
- 刻画最优解:。
- 设计动态规划算法:,。
- 算法效率:,表示矩阵为阶时、矩阵为阶时,任意的单个元素需要个数量乘积,所有项的计算需要个数量乘积。
四、望远镜调度问题
- 观察请求列表,列表中每个申请记为,申请包含了请求观察开始时间,完成观察时间,完成观察后的科学效益指标。对于一个观察请求,必须用望远镜完成从到的整个观察,才能得到效益。对于两个观察请求和,如果时间区间和相交,则称两个请求有冲突。
- 定义子问题:观察请求的次序问题,按照结束时间排序和按照效益排序。定义前驱(predecessor)为,表示与不冲突的最大,如果不存在这样的下标,则定义的前驱为0。排序后的第个观察请求的效益为。定义最优值为。
- 刻画最优解:分两种情况,达到最优值时包含观察请求,有; 达到最优值不包含观察请求,有。
- 设计动态规划算法:,。
- 算法效率:,包括为了求前驱对完成观察时间排序需要,遍历观察请求列表需要。
五、博弈策略
- 硬币行游戏(Coins-in-a-line):偶数个硬币排成一行,两名玩家轮流从剩余的硬币行的两端取走一个硬币。最后收集到总面值最大的玩家胜利。
- 定义子问题:硬币编号为,面值编号为,为在编号到硬币行中当前玩家与另一位玩家面值之差的最大值。
- 刻画最优解:给定编号从到的硬币行,当,有;当,当前玩家可取走或,有,最后判断,如果大于0,则第一位玩家胜利;等于0,平局,否则,第二位玩家胜利。
- 设计动态规划算法:
- 算法效率:,需要计算每个子数组对应的的值,包括从1到,共计算个子数组。
实例5-1 【LeetCode】877. 石子游戏
- Alice 和 Bob 用几堆石子在做游戏。一共有偶数堆石子,排成一行;每堆都有 正 整数颗石子,数目为 piles[i] 。游戏以谁手中的石子最多来决出胜负。石子的 总数 是 奇数 ,所以没有平局。Alice 和 Bob 轮流进行,Alice 先开始 。 每回合,玩家从行的 开始 或 结束 处取走整堆石头。这种情况一直持续到没有更多的石子堆为止,此时手中 石子最多 的玩家 获胜 。假设 Alice 和 Bob 都发挥出最佳水平,当 Alice 赢得比赛时返回 true ,当 Bob 赢得比赛时返回 false 。
- 2 <= piles.length <= 500
- piles.length 是 偶数
- 1 <= piles[i] <= 500
- sum(piles[i]) 是 奇数
- 输入:piles = [5,3,4,5]
- 输出:true
- 解释:Alice 先开始,只能拿前 5 颗或后 5 颗石子 。假设他取了前 5 颗,这一行就变成了 [3,4,5] 。如果 Bob 拿走前 3 颗,那么剩下的是 [4,5],Alice 拿走后 5 颗赢得 10 分。如果 Bob 拿走后 5 颗,那么剩下的是 [3,4],Alice 拿走后 4 颗赢得 9 分。这表明,取前 5 颗石子对 Alice 来说是一个胜利的举动,所以返回 true 。
示例代码:
class Solution {
public:
bool stoneGame(vector<int>& piles) {
int dp[piles.size()][piles.size()];
memset(dp, 0, sizeof(dp));
for(int i = 0; i < piles.size() - 1; i++){
dp[i][i] = piles[i];
}
for(int i = piles.size() - 2; i >= 0; i--){
for(int j = i + 1; j < piles.size(); j++){
dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1]);
}
}
return dp[0][piles.size() - 1] > 0;
}
};
六、最长公共子序列
- 给定长度为和的字符串和,求既是的子序列又是的子序列的最长子序列。
- 定义子问题:为和的最长公共子序列。
- 刻画最优解:如果,可以断定公共子序列以结尾,有;如果,公共子序列的结尾可能是或者,或者两者都不是,有。
- 设计动态规划算法:定义一个长度为的数组,初始化和,从头迭代,直到求出。
- 算法效率:,分别需要遍历俩字符串,和的长度
- 如何输出最长公共子序列:从后往前重建最长公共子序列,如果,则存储该字符,然后转到;如果,则转到和中的较大者;当遇到边界值或时算法终止。
实例6-1:【LeetCode】1143. 最长公共子序列
- 给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列的长度。如果不存在公共子序列,返回0。
- 1 <= text1.length, text2.length <= 1000
- text1 和 text2 仅由小写英文字符组成。
- 输入:text1 = "abcde", text2 = "ace"
- 输出:3
- 解释:最长公共子序列是 "ace" ,它的长度为 3 。
- 输入:text1 = "abc", text2 = "def"
- 输出:0
- 解释:两个字符串没有公共子序列,返回 0 。
示例代码:
#include <cstring>
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int len1 = text1.length(), len2 = text2.length();
//dp[i][j]表示以i结尾的text1和以j结尾的text2的最长公共子序列
int dp[len1 + 1][len2 + 1];
memset(dp, 0, sizeof(dp));
for(int i = 0; i < len1; i++){
for(int j = 0; j < len2; j++){
if(text1[i] == text2[j]){
dp[i + 1][j + 1] = dp[i][j] + 1;
}else{
dp[i + 1][j + 1] = max(dp[i][j + 1], dp[i + 1][j]);
}
}
}
return dp[len1][len2];
}
};
实例6-2:【LeetCode】300. 最长递增子序列
- 给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
- 1 <= nums.length <= 2500
- -10^4 <= nums[i] <= 10^4
- 输入:nums = [10,9,2,5,3,7,101,18]
- 输出:4
- 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
- 输入:nums = [7,7,7,7,7,7,7]
- 输出:1
示例代码:
方法一:动态规划法,,时间复杂度:
int lengthOfLIS(vector<int>& nums) {
//dp[i]表示以i结尾的串的最长递增子序列的长度
int dp[nums.size()];
int result = 1;
for(int i = 0; i < nums.size(); i++){
dp[i] = 1;
for(int j = 0; j < i; j++){
if(nums[i] > nums[j])
dp[i] = max(dp[i], dp[j] + 1);
}
result = max(dp[i], result);
}
return result;
}
方法二:贪心法,希望每次在上升子序列中最后加上的那个数尽可能小,表示长度为i的最长递增子序列末尾元素的最小值,如果,直接加入到数组末尾;否则,找到第一个小的数,更新,时间复杂度:
int lengthOfLIS(vector<int>& nums) {
int index;
vector<int> lcs;
lcs.push_back(nums[0]);
for(int i = 0; i < nums.size(); i++){
if(nums[i] > lcs[lcs.size() - 1]){
lcs.push_back(nums[i]);
}else{
for(index = lcs.size() - 1; index >= 0; index--){
if(nums[i] > lcs[index]){
lcs[index + 1] = nums[i];
break;
}
}
if(index == -1){
lcs[0] = nums[i];
}
}
}
return lcs.size();
}
方法三:贪心法 + 二分查找,由于数组的单调性,引入二分查找,优化时间复杂度,时间复杂度:
int lengthOfLIS(vector<int>& nums) {
vector<int> lcs;
lcs.push_back(nums[0]);
int left = 0, right = 0, middle = 0;
for(int i = 0; i < nums.size(); i++){
if(nums[i] > lcs[lcs.size() - 1]){
lcs.push_back(nums[i]);
}
left = 0, right = lcs.size();
while(left < right){
middle = (right - left) / 2 + left;
// 找到第一个nums[i]大于的数
if(nums[i] > lcs[middle]){
left = middle + 1;
}else{
right = middle;
}
}
lcs[left] = nums[i];
}
return lcs.size();
}
七、0-1背包问题
- 伪多项式时间算法(Pseudo-Polynomial-Time):运行时间是输入中某个量的多项式(依赖于参数),而不是整个输入规模的多项式。
- 设背包可承受最大总重量为,可能携带的物品来自件不同的有用物品构成的集合S,每件物品有一个整数重量和实用价值。如何在不超过重量限制的情况下携带最大价值的物品。
- 定义子问题:物品编号,表示编号为的物品集合,表示在的所有子集中,总重量不超过的子集的最大价值
- 刻画最优解:当时,的总重量不超过的最优子集再加上号物品,或者的总重量不超过的最优子集,有;当时,。
- 设计动态规划算法:
- 算法效率:
- 引申:完全背包问题
八、其他问题示例
实例8-1 【LeetCode】剑指 Offer 47. 礼物的最大价值
- 在一个 m*n 的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于 0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值,请计算你最多能拿到多少价值的礼物?
- 0 < grid.length <= 200
- 0 < grid[0].length <= 200
- 输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
] - 输出: 12
- 解释: 路径 1→3→5→2→1 可以拿到最多价值的礼物
方法:动态规划,
示例代码:
#include <cstring>
class Solution {
public:
int maxValue(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
int dp[m][n];
memset(dp, 0, sizeof(dp));
dp[0][0]=grid[0][0];
for(int i = 1; i < n; i++){
dp[0][i] = dp[0][i-1] + grid[0][i];
}
for(int i = 1; i < m; i++){
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for(int i = 1; i < m; i++){
for(int j = 1; j < n; j++){
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m-1][n-1];
}
};
实例8-2 【LeetCode】198. 打家劫舍
- 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
- 1 <= nums.length <= 100
- 0 <= nums[i] <= 400
- 输入:[2,7,9,3,1]
- 输出:12
- 解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。
方法:动态规划,
示例代码:
class Solution {
public:
int rob(vector<int>& nums) {
//dp[i]表示以i房屋为结尾的最高金额
//dp[0] = nums[0], dp[1] = max{nums[0], nums[1]}
//dp[i] = max(dp[i-2] + nums[i], dp[i-1])
if(nums.size() == 1){
return nums[0];
}
int dp[nums.size()];
dp[0] = nums[0], dp[1] = max(nums[0], nums[1]);
for(int i = 2; i < nums.size(); i++){
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[nums.size() - 1];
}
};
参考文献
[1]Michael T.Goodrich, Roberto Tamassia. 算法设计与应用. [M]北京:机械工业出版社,2018.01;