动态规划
动态规划
数字三角形问题
LeetCode 120.Triangle
尝试使用分治法
Given a triangle, find the minimum path sum from top to bottom.
Each step you may move to adjacent numbers on the row below.
For example, given the following triangle
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
The minimum path sum from top to bottom is 11 (i.e., 2 + 3 + 5 + 1 = 11).
假设第 n 层第 i 个元素的最短路径长度为\(m_{[n][i]}\),那么应该有
根据这个公式可以方便的写出一个递归形式的算法,这里把该算法命名为Triangle_Recursion
。算法实现如下:
class Solution {
public:
int minimumTotal(vector< vector< int>>& triangle) {
return Triangle_Recursion(triangle, 0, 0);
}
private:
int Triangle_recursion(vector< vector< int>>& t, int r, int c){
if(r >= t.size() || c >= t.at(r).size()) return 0;
return min(Triangle_recursion(t, r+1, c), Triangle_recursion(t, r+1, c+1)) + t[r][c];
}
};
运行时,LeecCode 上超出了时间限制。
分析这个算法的时间复杂度:
注意,这里的数字三角形和普通的二叉树有个本质区别:二叉树的子树之间没有交集,数字三角形的子路径之间有交集。 这样一个规模为 n 的问题就被分解成了两个规模为 n-h 的问题,h 为三角形的高度,容易得到,可以写出递归算法的代价表达式:
可以求得\(T(n)=O(2^{n})\),所以这是一个指数数量级的算法。该算法之所以性能这么差,就是因为子问题之间有重复部分。以给的例子为例,计算 Triangle_recursion(1,0)
和Triangle_recursion(1,1)
时都调用了Triangle_recursion(2,1)
,这就相当于将
重复计算了两遍。而分治法的思想是将一个问题分解为两个不相干的子问题再分别求解,所以说分治思想不适合本问题,或者说,我们需要对这种方法进行改进。
改进的思路就是:想个办法避免重复计算。最简单的方式就是设立一种标记,用来表示已经计算过该位置的最短路径,这样当我们遇到这种节点时,就不再计算而是直接返回该值。这就是动态规划的本质,动态规划本质是一种查表法。
利用备忘录的递归算法
原先的数字三角形中的节点只包含本身的代价,不记录其子路径的信息,所以我们需要再额外开辟一个表memo
用来记录已经计算过的最短路径。代码如下:
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
auto memo = Create_Memo(triangle);
return Triangle_T2B_Memo(triangle, *memo, 0, 0);
}
private:
vector<vector<int>> memo;
shared_ptr<vector<vector<int>>> Create_Memo(const vector<vector<int>> t);
int Triangle_T2B_Memo(const vector<vector<int>> &, vector<vector<int>>&, size_t, size_t);
...
};
shared_ptr<vector<vector<int>>> Create_Memo(const vector<vector<int>> t){
size_t rows = t.size();
auto res = make_shared<vector<vector<int>>>();
for(int i=0; i<rows; i++){
size_t cols = t[i].size();
vector<int> row(cols, INT_MAX);
res->push_back(row);
}
vector<int> bottom(t[rows-1].size() + 1, 0);
res->push_back(bottom);
return res;
}
int Solution::Triangle_T2B_Memo(const vector<vector<int>> &triangle, vector<vector<int>>& memo, size_t r, size_t c){
if(r == memo.size()) return 0; // reach bottom
if(memo[r][c] < INT_MAX) return memo[r][c];
memo[r][c] = min(Triangle_T2B_Memo(triangle, memo, r+1, c), Triangle_T2B_Memo(triangle, memo, r+1, c+1)) + triangle[r][c];
return memo[r][c];
}
在对memo
初始化时,首先将所有未被计算过的位置初始化为INT_MAX
,然后额外添加一行作为bottom
,bottom
对应于triangle
最下面一行的下方,用来作为递归边际FLAG
,所以bottom
中的元素都为0。
分析带有备忘录的递归算法,我们知道所有的节点只会被计算一次,当第二次被访问时只需要一个\(\Theta(1)\)的常数访问。那么最差情况下,可以构造出一个高度为n,除了根位置外其他位置都只有两个数字的递归结构。这时候
所以\(T(n)=O(n^2)\),利用备忘录我们省去了不必要的计算,将一个原来为\(O(2^n)\)的算法加速到了\(O(n^2)\)。这时,我们可以说Triangle_T2B_Memo
是一个采用了动态规划思想的算法。
自底向上的动态规划
Triangle_T2B_Memo
是自顶向下构造备忘录Memo
的,我们当然可以自底向上来构造Memo
。代码如下:
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
_memo = Create_Memo(triangle);
return Triangle_B2T_Memo(triangle, *_memo);
}
private:
shared_ptr<vector<vector<int>>> _memo;
shared_ptr<vector<vector<int>>> Create_Memo(const vector<vector<int>> t){
size_t rows = t.size();
auto res = make_shared<vector<vector<int>>>();
for(int i=0; i<rows; i++){
size_t cols = t[i].size();
vector<int> row(cols, INT_MAX);
res->push_back(row);
}
vector<int> bottom(t[rows-1].size() + 1, 0);
res->push_back(bottom);
return res;
}
int Solution::Triangle_B2T_Memo(const vector<vector<int>> &t, vector<vector<int>>& memo){
if(memo.size() == 0 || memo.size() == 1) return memo[0][0];
int row = memo.size() - 1;
while(row >= 0){ //当循环的截至条件为和0作比较时,慎用 size_t 类型
if(row == memo.size() - 1) { // bottom
row--;
continue;
}
size_t col = 0;
while(col < memo[row].size()){
memo[row][col] = min(memo[row+1][col], memo[row+1][col+1]) + t[row][col];
col++;
}
row--;
}
return memo[0][0];
}
};
Triangle_B2T_Memo
从底向上遍历所有位置,得到memo
。
矩阵链乘问题
矩阵链乘问题是《算法导论》动态规划一章的第二个例子。
给定由n个要相乘的矩阵构成的序列( \(A_1 , A_2 , ... A_n\) ),要计算乘积
可将两个矩阵相乘的标准算法作为一个子程序,根据括号给出的计算顺序做全部的矩阵乘法。矩阵的乘法满足结合律,如果给定矩阵链为( \(A_1 , A_2 , A_3 , A_4\) ),乘积\(A_1A_2A_3A_4\)可用五种不同的方式加括号:
矩阵链乘加括号的顺序对求积运算的总代价具有很大的影响。矩阵链乘问题就是对于一个给定的矩阵链\(A_1 , A_2... A_n\) , 矩阵\(A_{i}\)的维数为\(p_{i-1}*p_{i}\) , 确定一种加全部括号的方式,使得标量乘法次数最少。
对这道题我们使用标准的动态规划分析问题的步骤来做:
步骤1:寻找最优子结构
所有动态规划问题分析的第一步都是寻找问题的最优子结构,什么是问题的最优子结构?要想得到原问题的最优解,那么需要利用子问题的最优解。
对于矩阵链乘问题,乘积\(A_{i}A_{i+1}...A_{j}\)必有一个分开点\(k(i<=k<j)\),将原链乘分解为\(A_{i}...A_{k}\)和\(A_{k+1}...A_{j}\)的乘积,可以使得原链乘具有最小的标量乘法次数,并且\(A_{i}...A_{k}\)和\(A_{k+1}...A_{j}\)也是各自的最优解。证明的方法这里不详细说明。
这个性质很明显是一个递归的定义,因此我们可以根据这个最优子结构性质找到一个最优解。
步骤2:一个递归解
设m[i][j]
表示矩阵链乘\(A_{i}...A_{j}\)的最少标量乘法数,则当i==j
时,m[i][j]
应该等于0。在非平凡情况下,m[i][j]
应该是其子问题最优解的组合。假设i<=k<j
,则m[i][j]
应该等于所有以k为分解点的组合中最小的代价。所以有:
步骤3:重构子问题
仔细分析前面的递归表达式可以发现,和数字三角形问题一样, 这个递归式中也会将许多相同的问题重复计算多次,即存在子问题的重叠。解决这个问题的方法和数字三角形问题一样,自顶向下或者自底向上地构造一个备忘录。由于本题需要得到最终的具体加括号方式,因此除了需要一个用来记录代价的备忘录m
之外还需要一个辅助备忘录s
记录具体的加括号方式。
#include<vector>
#include<iostream>
using namespace std;
void Init(vector<vector<unsigned int>> &m, vector<vector<unsigned int>> &s, size_t n);
void ShowMetrix(const vector<vector<unsigned int>> &m);
void Matrix_chain_order(vector<int> &q,vector<vector<unsigned int>> &m,vector<vector<unsigned int>> &s){//bottom to top
auto n = q.size() - 1;
for (decltype(n) l = 2; l <= n; l++){ //l represents the len of sub-chain
for (size_t i = 1; i <= n - l + 1; i++){
size_t j = i + l - 1;
for (size_t k = i; k <= j - 1; k++){
unsigned int p = m[i][k] + m[k + 1][j] + q[i - 1] * q[k] * q[j];
if (p < m[i][j]){
m[i][j] = p;
s[i][j] = k;
}
}
}
}
}
void Init(vector<vector<unsigned int>> &m,vector<vector<unsigned int>> &s,size_t n){
for (int i = 0; i <= n; i++)
{
vector<unsigned int> temp(n + 1, UINT32_MAX);
m.push_back(temp);
}
for (int i = 0; i <= n - 1; i++)
{
vector<unsigned int> temp(n + 1, UINT32_MAX);
s.push_back(temp);
}
for (int i = 0; i <= n; i++)
{
for (int j = 0; j <= n; j++)
{
if (i==j)
{
m[i][j] = 0;
}
}
}
}
unsigned int Recursice_Matrix_Chain(const vector<int> &q,vector<vector<unsigned int>> &m, int i, int j)//top to bottom
{
if (m[i][j]!=UINT32_MAX) {
return m[i][j];
}
if (i==j){
m[i][j] = 0;
return 0;
}
for (int k = i; k <= j - 1; k++){
unsigned int x = Recursice_Matrix_Chain(q, m, i, k) + Recursice_Matrix_Chain(q, m, k + 1, j) + q[i - 1] * q[k] * q[j];
if(x<m[i][j]){
m[i][j] = x;
}
}
return m[i][j];
}
int main(){
vector<int> q{5,10,3,12,5,50,6};
vector<vector<unsigned int>> m1, m2, s;
Init(m1, s, q.size() - 1);
Init(m2, s, q.size() - 1);
Recursice_Matrix_Chain(q, m1, 1, q.size() - 1);
Matrix_chain_order(q, m2, s);
cout << "m1: ";
ShowMetrix(m1);
cout << endl;
cout << "m2: ";
ShowMetrix(m2);
cout << endl;
ShowMetrix(s);
}
void ShowMetrix(const vector<vector<unsigned int>> &m)
{
for(auto i:m)
{
for(auto j:i)
{
if(j!=UINT32_MAX)
{
cout << j << ' ';
}
}
cout << endl;
}
}
最长公共子序列LCS(Longest Common Sequence)
给定一个序列 \(X\{x_{1},x_{2}...x_{m}\}\)与\(Y\{y_{1},y_{2},...,y_{n}\}\),另一个序列\(Z\{z_{1},z_{2},...z_{k}\}\)满足如下条件时为X与Y的公共子序列:严格递增的X的下标序列\(<i_{1},i_{2},...,i_{k}>\)对所有\(j=1,2,...,k\),满足\(x_{i_{j}}==z_{j}\)。当序列Z
是X
与Y
所有LCS
中具有最长长度的LCS
时,称Z
为最长公共子序列。
构造LCS问题的最优子结构
LCS的最优子结构,另\(X=\{x_{1},x_{2}...x_{m}\}\),\(Y=\{y_{1},y_{2},...,y_{n}\}\),有\(Z=\{z_{1},z_{2},...z_{k}\}\)为X
和Y
的任意LCS
则:
- 若\(x_{m}==y_{n}\),则\(x_{m}==y_{n}==z_{k}\);
- 若\(x_{m}!=y_{n}\),那么\(z_{k}!=x_{m}\)就说明
Z
为\(X_{m-1}\)和\(Y_{n}\)的LCS
- 若\(x_{m}!=y_{n}\),那么\(z_{k}!=y_{n}\)就说明
Z
为\(X_{m}\)和\(Y_{n-1}\)的LCS
具体的证明这里省略。
构造递归解
构造递归解的关键是写出递归形式的公式。往往第一步首先需要一个记号来表示非平凡情况下的值。前面矩阵链乘使用m[i][j]
表示\(A_{i}...A_{j}\)的最少标量计算数量,这里我们使用c[i][j]
表示\(X_{i}\)和\(Y_{j}\)的LCS
的长度。那么就有:
根据这个递归表达式很容易发现子问题中会有重叠部分,比如计算c[i][j-1]
和c[i-1][j]
时都需要计算c[i-1][j-1]
,这也说明该问题确实需要使用动态规划来降低计算复杂度。
重构子问题
重构子问题就是重新设计计算备忘录c
的方法。一般来说就是两种:
- 自顶向下的递归方式
- 自底向上的迭代方式
采用自顶向下的递归方式比较好理解。代码:
int LCS(const string &s1, const string &s2, vector<vector<int>> &c, int i, int j){
if(i == -1 || j == -1)
return 0;
if(c[i][j] != INT_MAX)
return c[i][j];
if(s1[i] == s2[j])
c[i][j] = LCS(s1, s2, c, i - 1, j - 1) + 1;
else{
int t1 = LCS(s1, s2, c, i, j - 1);
int t2 = LCS(s1, s2, c, i - 1, j);
c[i][j] = (t1 >= t2 ? t1 : t2);
}
return c[i][j];
}
其中备忘录c被初始化为全部为INT_MAX
的矩阵。当i==-1
或者j==-1
时说明s1
或s2
无元素,此时的LCS
长度为0。当c[i][j]!=INT_MAX
时说明\(X_{i}\)与\(Y_{j}\)的LCS
已经计算过,直接返回避免重复计算。
最优二叉搜索树
对于一个普通的二叉搜索树,我们可以使用红黑树或者其他的平衡二叉搜索树来保证每个单词都具有O(lg(n))
的搜索时间,但是对于某一个二叉搜索树来说,其总的预期搜索时间并不一定是最小的。比如远离根节点的叶子节点虽然本身关键值较小,但是其在某一情境下出现的频率却很高,靠近根节点的节点出现的频率却比较低。这就导致总的预期查找时间变高。因此我们需要重新组织一颗二叉搜索树,使得所有的搜索访问的节点数量最少。这就是一颗最优二叉搜索树,也叫最优二叉查找树。
最优二叉查找树
:给定一个由n个互异的关键字组成的序列\(K=\{k_{1},k_{2},...,k_{n}\}\) 且关键字有序,我们想从这些关键字中构造一棵二叉查找树。对每个关键字\(k_{i}\),一次搜索为\(k_{i}\)的概率为\(p_{i}\),某些搜索的值还可能不在关键字中,因此有n+1个dummy关键字,\(\{d_{0},d_{1},...,d_{n}\}\)代表不在K内的值。具体地,\(d_{0}\)表示小于\(k_{0}\)的非关键字查找对象,\(d_{i}\)表示所有位于\(k_{i}\)和\(k_{i+1}\)之间的非关键字查找对象,\(d_{n}\)表示所有大于\(k_{n}\)的非关键字查找对象。对于\(d_{i}\)其查找概率为\(q_{i}\)。
很明显应该有
对于一个已经构建好了的二叉搜索树,其一次搜索的预期代价将为:
使用动态规划来解决这个问题。
最优二叉搜索树的最优子结构
假设二叉搜索树T
为最优二叉搜索树,那么其子树也为最优二叉搜索树。给定关键字\(\{k_{i},k_{i+1},...,k_{j}\}\),设\(k_{r}(i<=r<=j)\),将是包含这些键的一棵最优子树的根。那么根\(k_{r}\)的左子树包含关键字\(\{k_{i},k_{i+1},...,k_{k-1}\}\)和虚拟关键字\(\{d_{i},d_{i+1},...,d_{k-1}\}\),右子树将包含关键字\(\{k_{r+1},...,k_{j}\}\)和虚拟关键字\(\{d_{k+1},...,d_{j}\}\)。只要遍历所有候选根\(k_{r}\)就一定可以得到这样一个根节点。
构造递归解
假设某最优二叉搜索树的搜索代价为e[i][j]
,那么当这棵数作为子树后,其每个节点的深度加1,那么总的搜索代价增量应该为子树中所有节点概率之和w[i][j]
。
得到最终的递归式为:
利用备忘录实现动态规划
面试中常见的动态规划
什么情况下使用动态规划:
- 求最大、最小
- 求方案个数
- 判断是否可行
则极有可能使用动态规划。
坐标型动态规划
LeetCode 64. Minimum Path Sum
/*
*LeetCode 64.Minimum Path Sum
*
*使用DP,从下向上迭代计算memo
*
*/
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int rows = grid.size();
int cols = grid[0].size();
if(rows==0 || cols==0)
return 0;
vector<vector<int>> memo ;
initMemo(memo, rows, cols);
return DP_minPathSum(memo, grid, rows, cols);
}
void initMemo(vector<vector<int>> &memo, int rows, int cols){
for(int i=0; i<rows; i++){
vector<int> temp;
for(int j=0; j<cols; j++){
if(i==0 || j==0)
temp.push_back(0);
else
temp.push_back(INT_MAX);
}
memo.push_back(temp);
}
}
int DP_minPathSum(vector<vector<int>> &memo, const vector<vector<int>> &grid, int row, int col){
memo[0][0] = grid[0][0];
for(int i=1; i<row; i++)
memo[i][0] = memo[i-1][0] + grid[i][0];
for(int j=1; j<col; j++)
memo[0][j] = memo[0][j-1] + grid[0][j];
for(int i=1; i<row; i++){
for(int j=1; j<col; j++)
memo[i][j] = min(memo[i-1][j] , memo[i][j-1]) + grid[i][j];
}
return memo[row-1][col-1];
}
};
LeetCode 62.Unique Path
class Solution {
public:
int uniquePaths(int m, int n) {
if(m==0 || n==0)
return 0;
vector<vector<int>> memo;
initMemo(memo,m,n);
for(int i=1; i<m; i++){
for(int j=1; j<n; j++)
memo[i][j] = memo[i-1][j] + memo[i][j-1];
}
return memo[m-1][n-1];
}
void initMemo(vector<vector<int>> &memo, int row, int col){
for(int i=0; i<row; i++){
vector<int> temp;
for(int j=0; j<col; j++){
if(i==0 || j==0)
temp.push_back(1);
else
temp.push_back(INT_MAX);
}
memo.push_back(temp);
}
}
};
LeetCode 63. Unique Paths II
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int row = obstacleGrid.size();
int col = obstacleGrid[0].size();
if(row==0 || col==0)
return 0;
vector<vector<unsigned int>> memo(row, vector<unsigned int>(col, 0));
initMemo(memo, obstacleGrid, row, col);
for(int i=1; i<row; i++){
for(int j=1; j<col; j++){
if(obstacleGrid[i][j] == 0)
memo[i][j] = memo[i-1][j] + memo[i][j-1];
else
memo[i][j] = 0;
}
}
return memo[row-1][col-1];
}
void initMemo(vector<vector<unsigned int>> &memo, const vector<vector<int>> &map, int row, int col){
if(map[0][0] == 0)
memo[0][0] = 1;
else
memo[0][0] = 0;
for(int i=1; i<row; i++){
if(map[i][0] == 1)
memo[i][0] = 0;
else
memo[i][0] = memo[i-1][0];
}
for(int j=1; j<col; j++){
if(map[0][j] == 1)
memo[0][j] = 0;
else
memo[0][j] = memo[0][j-1];
}
}
};
LeetCode 300. Longest Increasing Subsequence
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int l = nums.size();
int ans = 0;
if(l == 0)
return 0;
vector<int> res(l, 1);
DP_LIS(res, nums, l);
for(int i=0; i<l; i++){
ans = max(res[i], ans);
}
return ans;
}
int DP_LIS(vector<int> &res, const vector<int> &nums, int l){
for(int i=0; i<l; i++){
for(int j=0; j<i; j++){
if(nums[i] > nums[j]){
res[i] = max(res[i], res[j] + 1);
}
}
}
return 0;
}
};
单序列动态规划
LeetCode 139. Word Break
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
if(wordDict.size() == 0) return false;
vector<bool> memo(s.size() + 1, false);
memo[0] = true;
for(int i=1; i<=s.size(); i++){
for(int k=i-1; k>=0; k--){
if(memo[k]){
string word = s.substr(k,i-k);
if(find(wordDict.begin(), wordDict.end(), word) != wordDict.end()){
memo[i] = true;
break;
}
}
}
}
return memo[s.size()];
}
};
LeetCode 132. Palindrome Partition II
class Solution {
public:
int minCut(string s) {
//initialization
vector<int> memo(s.size()+1, 0);
for(int i=1; i<=s.size(); i++)//minCut in worst case is i-1 for a string of i characters
memo[i] = i-1;
memo[0] = -1;
//main loop, DP
for(int i=1; i<=s.size(); i++){
for(int j=i-1; j>=0; j--){
//if word isn't a palindrome, we don't need to upgrade memo[i], because we have initialized memo[i] to its worst case
if(is_palindrome(s, j, i-1)){
memo[i] = min(memo[i], memo[j]+1);
}
}
}
return memo[s.size()];
}
bool is_palindrome(const string& word, int start, int end){
int i = start;
int j = end;
for(; i<=j; i++, j--){
if(word[i] != word[j])
break;
}
return i>j;
}
};
单序列动态规划的题目,通常用 memo[i]
表示从头到 i
的子问题的最优结构,对于每一个 memo[i]
, 取\(k\in\{0,...,i-1\}\), 计算每一种k时 memo[i]
的值,然后取最优值。
比如Work Break
中memo[i]
表示从string头到第 i
个元素的 substr 能否被成功break,再判断从 \(k\in\{0,...,i-1\}\) 到 i 的字串是否在字典中,如果 memo[k]
为 true,并且 substr( k, i )
也在字典中,那么memo[i]
为 true。
在Palindrome Partition II
中,思路是一样的。
LeetCode 53. Maximum Subarray
这道题是求一个数组中具有最大元素和的连续子数组,返回这个最大和。按照单序列动态规划的思路,定义memo[i]
为前 i 个元素的Maximum Subarray
,那么可以得到memo[i]
的计算公式
按照此思路可以得到代码:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> memo(nums.size()+1, INT_MIN);
memo[0] = nums[0];
for(int i=1; i<=nums.size(); i++){
int sum_t = 0;
memo[i] = memo[i-1];//in worst case, memo[i] == memo[i-1]
for(int k=i-1; k>=0; k--){
sum_t += nums[k];
memo[i] = memo[i] >= sum_t ?
memo[i] : sum_t;
}
}
return memo[nums.size()];
}
};
这段代码的时间复杂度为\(O(n^2)\)。
重新修改memo[i]
的定义,设memo[i]
表示以nums[i]
结尾的连续子数组的最大和,这时有公式:
据此可以得到代码:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> memo(nums.size()+1, INT_MIN);
memo[0] = 0;
int res = INT_MIN;
for(int i=1; i<=nums.size(); i++){
memo[i] = nums[i-1] + (memo[i-1] >= 0 ? memo[i-1] : 0);
res = max(res, memo[i]);
}
return res;
}
};
这段代码的时间复杂度为\(O(n)\)。可见对于备忘录中项的合理定义可以优化算法运行速度。备忘录中的项不一定非得直接记录答案(如前一段代码中memo[i]
直接记录了前 i 个元素组的最优解),有时候选择记录一些辅助信息(后一种方法中memo[i]
记录的是以nums[i]
结尾的连续子数组的最大和)反倒可以更快速的得到答案。