动态规划程序设计是对解最优化问题的一种途径、一种方法,而不是一种特殊算法。不像搜索或数值计算那样,具有一个标准的数学表达式和明确清晰的解题方法。动态规划程序设计往往是针对一种最优化问题,由于各种问题的性质不同,确定最优解的条件也互不相同,因而动态规划的设计方法对不同的问题,有各具特色的解题方法,而不存在一种万能的动态规划算法,可以解决各类最优化问题。
阶段:把所给求解问题的过程恰当地分成若干个相互联系的阶段,以便于求解,过程不同,阶段数就可能不同.描述阶段的变量称为阶段变量。
状态:状态表示每个阶段开始面临的自然状况或客观条件,它不以人们的主观意志为转移,也称为不可控因素。
无后效性:无后效性指如果在某个阶段上过程的状态已知,则从此阶段以后过程的发展变化仅与此阶段的状态有关,而与过程在此阶段以前的阶段所经历过的状态无关。
决策:给定一个状态,从该状态演变到下一个阶段的某个状态的一种选择称为决策。决策可表示为一个数或一组数,不同的决策对应不同的数值。因为满足无后效性,每个阶段选择决策时只需考虑当前的状态无需考虑历史状态。
策略:由每个阶段的决策组成的序列称为策略。
状态转移方程:给定 k 阶段状态变量 x(k),若 k+1 阶段状态变量 x(k+1) 也确定下来,这就是状态转移的规律,称为状态转移方程。
动态规划( dynamic programming )是解决多阶段决策过程最优化问题的一种常用方法。应用动态规划是要分析能否把大问题分解成若干小问题,而每个小问题都存在最优解。通过将小问题的最优解组合起来,就能得到最终大问题的解。对于重复出现的子问题,只在第一次遇到的时候对它进行求解,并把答案保存起来,让以后再次遇到时直接引用答案,不必重新求解。
动态规划与贪婪算法的区别在于:应用贪婪算法解题时,每一步都做出贪婪的选择,基于这一选择来得出最终解。而动态规划问题还需要考虑每个子问题是否得到最优解,再由子问题的最优解来组成整体问题的最优解。
不同算法的特征:
- 每个阶段只有一个状态 -> 递推;
- 每个阶段的最优状态都是由上一个阶段的最优状态得到的 -> 贪心;
- 每个阶段的最优状态是由之前所有阶段的状态的组合得到的 -> 搜索;
- 每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的 -> 动态规划。
转自知乎:什么是动态规划(Dynamic Programming)?动态规划的意义是什么?
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到,这个性质叫做最优子结构;而不管之前这个状态是如何得到的这个性质叫做无后效性。
对于动态规划问题,通常使用一维数组 dp[i] 或二维数组 dp[i][j] 来保存问题的状态。
通常,对于状态的不同定义会有不同的解法。对于动态规划问题,首先要明白有哪些「状态」,有哪些「选择」。
在 b 站视频:闫氏DP分析法,从此再也不怕DP问题! 讲解的动态规划问题是将其作为一个集合,状态就是将集合划分为若干个的子集,状态 f ( i ) f(i) f(i) 表示为所有满足某一条件的集合,但状态 f ( i ) f(i) f(i) 通常存储的是一个整数、浮点数或布尔值,即存储的是一个该集合的属性。
大部分的字符串问题都可以用二维数组定义。
当我们定义了状态数组后,接下来我们就要找出状态数组元素之间的关系式,即对集合进行划分。划分依据是:寻找最后一个不同点。例如青蛙跳台阶问题中的最后一个不同点就是,跳到最后一步的方法有两种:跳一阶或是跳两阶。
我们要计算 dp[n] 时,需要利用 dp[n-1],dp[n-2]……dp[1],来推出 dp[n] 的,非常像归纳法。也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,例如青蛙跳台阶问题中的状态转移方程为 dp[n] = dp[n-1] + dp[n-2],这个就是他们的关系式了。而这一步,也是最难的一步,需要尽量总结不同的动态规划模型。
类似于数学归纳法,我们在知道了状态转移方程后,例如 dp[n] = dp[n-1] + dp[n-2],我们可以使用 dp[i-1] 和 dp[i-2] 的值来计算新的元素 dp[i],但是以此向前类推后,我们总是需要知道一个最开始的值,既状态的一个初始值,根据这个初始值我们才能根据状态转移方程以此类推,得到最终需要求解的值。
关于动态规划的题目整理自 leetcode dynamic programming 和《剑指offer》 中做过的典型题目,希望复习的时候能快速回忆起解题的思路。一部分题目来自 b 站 闫氏DP分析法,从此再也不怕DP问题! 中的讲解,推荐大家有时间看看他的视频,视频时间较长,讲解的十分细致。
题目描述:有 n 件物品和一个容量是 v 的背包,每件物品只使用一次。第 i 件物品的体积是 v i v_i vi,价值是 w i w_i wi。求解将那些物品装入背包,是这些物品的体积不超过背包的容量,且总价值最大。
算法分析:
class Solution {
public:
int knapSack01(vector<int>& v, vector<int>& w, int n, int capacity) {
vector<vector<int>> dp(n+1, vector<int>(capacity+1));
for (int i = 1; i <= n; ++i)
{
int volume = v[i-1], worth = w[i-1];
for (int j = 0; j <= capacity; ++j)
{
dp[i][j] = dp[i-1][j];
if (j >= v[i]) dp[i][j] = max(dp[i][j], dp[i-1][j-volume] + worth);
}
}
return dp[n][capacity];
}
};
算法优化:时间复杂度上没有办法优化,我们可以在空间复杂度上优化,从我们得到的状态转移方程可以看到,当前的状态只与前一个状态有关,因此可以使用一维数组来进行滚动更新。即使用循环
for (int j = v; j >= vi; --j)
f(j) = max(f(j), f(j-vi) + wi);
使用从到到小的循环是要保证计算 f ( j ) f(j) f(j) 时使用的 f ( j − v i ) f(j-v_i) f(j−vi) 是上一层的数据,因为 j > j − v i j > j-v_i j>j−vi,所以总是先计算 f ( j ) f(j) f(j) 再计算 f ( j − v i ) f(j-v_i) f(j−vi)。最后得到的数组就是当前存储物品数量满足最大容量 j 的最大价值。
class Solution {
public:
int knapSack01(vector<int>& v, vector<int>& w, int n, int capacity) {
vector<int> dp(capacity+1);
for (int i = 1; i <= n; ++i)
{
int volume = v[i-1], worth = w[i-1]; // 第i个物品的体积和价值
for (int j = capacity; j >= volume; --j)
dp[j] = max(dp[j], dp[j-volume] + worth);
}
return dp[n][capacity];
}
};
题目描述:完全背包问题是在01背包问题的基础上,装入同一物品的个数没有限制。解决这类问题就是在01背包的基础上将循环的顺序改为正序即可,但是我们需要理解为什么。
算法分析:
算法优化:对于得到的状态转移方程我们可以观察到
f ( i , j − v ) = m a x ( f ( i − 1 , j − v i ) , f ( i − 1 , j − 2 v i ) + w i , . . . , f ( i − 1 , j − k v i ) + ( k − 1 ) w i , . . . ) f(i, j-v) = max(f(i-1, j-v_i) , f(i-1, j-2v_i) + w_i, ... , f(i-1, j-kv_i) + (k-1)w_i, ...) f(i,j−v)=max(f(i−1,j−vi),f(i−1,j−2vi)+wi,...,f(i−1,j−kvi)+(k−1)wi,...)
将上式带入到状态转方程中我们可以得到:
f ( i , j ) = m a x ( f ( i − 1 , j ) , f ( i , j − v ) + w i ) f(i, j) = max(f(i-1, j), f(i, j-v) + w_i) f(i,j)=max(f(i−1,j),f(i,j−v)+wi)
/*
* 01背包问题: f[i][j] = max(f[i-1][j], f[i-1][j-v] + w);
* 完全背包问题: f[i][j] = max(f[i-1][j], f[i][j-v] + w);
*/
class Solution {
public:
int knapSack01(vector<int>& v, vector<int>& w, int n, int capacity) {
vector<int> dp(capacity+1);
for (int i = 1; i <= n; ++i)
{
int volume = v[i-1], worth = w[i-1]; // 第i个物品的体积和价值
/* 完全背包问题就是在01背包问题的基础上改变循环顺序 */
for (int j = volume; j <= capacity; j++)
dp[j] = max(dp[j], dp[j-volume] + worth);
}
return dp[n][capacity];
}
};
问题描述:设有 N 堆石子排成一排,编号为 1,2,3,… ,N。
每堆石子都有一定的质量,可以用一个整数描述,现将这 N 堆石子合并成一堆。
每次只能合并相邻的两堆,合并的代价为这两对石子质量的总和,合并后这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不同。
例如有 4 堆石子,质量分别是 1 3 5 2,我们可以先合并1,2堆,合并的代价为 4,得到 4 5 2。又合并1,2堆,代价为 9,得到 9 2,再合并得到 11。总代价为 4+9+11 = 24。
找出一种方法使得总代价最小,输出最小总代价。
算法分析:将所有的石子堆合并一共有 ( n − 1 ) ! (n-1)! (n−1)! 种选择方法,当 n 较大时如果枚举显然会超时,因此我们可以使用 dp 方法。
class Solution {
public:
int mergeStones(vector<int>& stones) {
int n = stones.size(); // 石子个数
vector<int> stoneMass(n); // 前缀和
vector<vector<int>> dp(n, vector<int>(n));
stoneMass[0] = stones[0];
for (int i = 1; i < n; ++i)
stoneMass[i] = stoneMass[i-1] + stones[i];
for (int len = 2; len <= n; ++len)
{
for (int i = 1; i + len - 1 <= n; ++i)
{
int j = i + len - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; ++k)
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + stoneMass[j] - stoneMass[i-1]);
}
}
return dp[1][n];
}
};
问题描述:给定两个长度分别为 N 和 M 的字符串 A 和 B,求 A 和 B 的最长公共子序列的长度。
子序列(subsequence): 一个特定序列的子序列就是将给定序列中零个或多个元素去掉后得到的结果(不改变元素间相对次序)。例如序列 A,B,C,B,D,A,B 的子序列有:A,B、B,C,A、A,B,C,D,A等。
公共子序列(common subsequence): 给定序列 X 和Y,序列 Z 是 X 的子序列,也是 Y 的子序列,则 Z 是 X 和 Y 的公共子序列。例如X = A,B,C,B,D,A,B,Y = B,D,C,A,B,A,那么序列 Z = B,C,A 为 X 和 Y 的公共子序列,其长度为3。但 Z 不是 X 和 Y 的最长公共子序列,而序列 B,C,B,A 和 B,D,A,B 均为 X 和 Y 的最长公共子序列,长度为4。因为 X 和 Y 不存在长度大于等于5的公共子序列。
最长公共子序列(Longest Common Subsequence):一个序列 S ,如果分别是两个或多个已知序列的子序列,且是所有符合此条件序列中最长的,则 S 称为已知序列的最长公共子序列。
算法分析:
class Solution {
public:
int longestCommonSubsequence(string& a, string& b) {
int lenA = a.length(), lenB = b.length();
vector<vector<int>> dp(lenA+1, vector<int>(lenB+1));
for (int i = 1; i <= lenA; ++i)
{
for (int j = 1; j <= lenB; ++j)
{
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
if (a[i] == b[j])
dp[i][j] = max(dp[i][j], dp[i-1][j-1] + 1);
}
}
return dp[n][m];
}
};
题目来源:力扣(LeetCode) 《剑指offer》面试题14- I. 剪绳子
链接:https://leetcode-cn.com/problems/jian-sheng-zi-lcof
题目描述:给你一根长度为 n
的绳子,请把绳子剪成整数长度的 m
段(m、n
都是整数,n > 1
并且m > 1
),每段绳子的长度记为 k[0],k[1]...k[m]
。请问 k[0]*k[1]*...*k[m]
可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为 2、3、3
的三段,此时得到的最大乘积是18。
算法分析:首先定义 dp[n] 为长度为 n 的绳子剪成若干段长度乘积的最大值。在剪一刀后,我们有 n-1 中可能的选择,即剪出第一段的可能长度为 1,2,…,n-1。
由此可以推出状态转移方程 dp[n] = max(dp[i] * dp[i-1])
,其中 0 < i < n
。
此时问题分为两种情况,一种是长度小于等于3的情况下,长度为2只能剪成成都为1的两段。当绳子长度为3时,可能把绳子分成1和2的两段或者都为1的三段。另一种是长度大于3的情况下,最小分段长度为3后就不用再分段,应为3分段的最大乘积为2,而3不分段就比2大。所以在长度大于3的情况下最小分为长度为3后就不再继续分段。
class Solution {
public:
int cuttingRope(int n) {
/* 当长度小于等于3的情况 */
if (n < 2)
return 0;
if (n == 2)
return 1;
if (n == 3)
return 2;
int * dp = new int[n+1];
/* 当长度大于3的情况 */
dp[0] = 0;
dp[1] = 1;
dp[2] = 2;
dp[3] = 3;
for (int i = 4; i <= n; i++)
{
int maxVal = 0;
for (int j = 1; j <= i/2; j++)
maxVal = max(maxVal, dp[j] * dp[i-j]);
dp[i] = maxVal;
}
return dp[n];
}
};
题目来源:力扣(LeetCode)题号 62
链接:https://leetcode-cn.com/problems/unique-paths
题目描述:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
算法分析:定义 dp[i][j] 的含义为机器人从左上角走到位置 (i, j) 的路径数,则 dp[m-1][n-1] 就是问题的解了。而机器人到 (i, j) 位置只能是从该位置的 (i-1, j) 位置到达,或者从左边的位置 (i, j-1) 到达。因此可以得到状态状态方程为:dp[i][j] = dp[i-1][j] + dp[i][j-1]
当机器人走到第一行或第一列的位置只可能有一条路径,即dp[i][0] = 1
,dp[0][j] = 1
算法优化:可以发现机器人走到第i
行位置的路径只与上一行的路径数和左侧的路径数有关,因此我们可以使用一维数组作为状态数组。
class Solution {
public:
int uniquePaths(int m, int n) {
if (m <= 0 || n <= 0)
return 0;
/* 定义dp[i]为机器人在第i个位置的路径数 */
vector<int> dp(n, 0);
/* 初始化dp[i] */
for (int i = 0; i < n; ++i)
dp[i] = 1;
/* 动态转移方程:dp[i] = dp[i] + dp[i-1] */
for (int i = 1; i < m; ++i)
for (int j = 1; j < n; ++j)
dp[j] += dp[j-1];
return dp[n-1];
}
};
题目来源:力扣(LeetCode)题号 72
连接:https://leetcode-cn.com/problems/edit-distance
题目描述:给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符
算法分析:定义 dp[i][j]
的含义为单词 word1 的长度为 i,单词 word2 的长度为 j 时 word1 转换为 word2 所需要的最小操作数。
大部分情况下,dp[i][j]
和 dp[i-1][j]
、dp[i][j-1]
、dp[i-1][j-1]
肯定存在某种关系。因为我们的目标就是,从规模小的,通过一些操作,推导出规模大的。
word2 是由 word1 通过插入字符、删除字符和替换字符三种操作得到的
dp[i][j] = dp[i-1][j-1]
。dp[i][j] = dp[i-1][j-1] + 1
dp[i][j] = dp[i][j-1] + 1
dp[i][j] = dp[i-1][j] + 1
要得到 dp[i][j]
的最小值,显然有dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1
。
当 dp[i][j]
中,如果 i 或者 j 有一个为 0,那么还能使用关系式吗?答是不能的,因为这个时候把 i - 1 或者 j - 1,就变成负数了,数组就会出问题了,所以我们的初始值是计算出所有的 dp[0][0...n]
和所有的 dp[0...m][0]
。
class Solution {
public:
int minDistance(string word1, string word2) {
int n1 = word1.length(), n2 = word2.length();
vector<vector<int>> dp(n1+1, vector<int>(n2+1, 0));
dp[0][0] = 0;
for (int i = 1; i <= n1; ++i)
dp[i][0] = dp[i-1][0] + 1;
for (int i = 1; i <= n2; ++i)
dp[0][i] = dp[0][i-1] + 1;
for (int i = 1; i <= n1; ++i)
{
for (int j = 1; j <= n2; ++j)
{
if (word1[i-1] == word2[j-1])
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = min(min(dp[i-1][j-1], dp[i-1][j]), dp[i][j-1]) + 1;
}
}
return dp[n1][n2];
}
};