动态规划
理解
动态规划是分治思想的延伸,通俗一点来说就是大事化小,小事化无的艺术
在将大问题化解为小问题的分治过程中,保存对这些小问题已经处理好的结果,供后面使用这些结果
动态规划具备了以下三个特点
- 把原来的问题分解成了几个相似的子问题
- 所有的子问题都只需要解决一次
- 储存子问题的解
动态规划的本质,是对问题状态的定义和状态转移方程的定义(状态以及状态之间的递推关系)
动态规划问题一般从以下四个角度考虑:
- 状态定义【定义的状态一定要形成递推关系】
- 状态间的转移方程定义
- 状态的初始化
- 返回结果
习题
Fibonacci了解动态规划与递归的区别
如果使用递归求解,会重复计算一些子问题:计算 f(4) 需要计算 f(3) 和 f(2),计算 f(3) 需要计算 f(2) 和 f(1),可以看到 f(2) 被重复计算
int Fib1(int n)
{
if(n==0)
return 0;
if(n==1)
return 1;
return Fib1(n-1)+Fib(n-2);
}
递归是将一个问题划分成多个子问题求解,动态规划也是如此,但是动态规划会把子问题的解缓存起来,从而避免重复求解子问题:
状态:F(n)
状态递推:F(n)=F(n-1)+F(n-2)
初始值:F(1)=F(2)=1
返回结果:F(N)
int Fib2(int n)
{
if(n<=1)
return n;
int *fib=new int[n+1];
fib[0]=0,fib[1]=1;
for(int i=2;i<=n;i++)
{
fib[i]=fib[i-1]+fib[i-2];
}
return fib[n];
delete[] fib;
}
考虑到第 i 项只与第 i-1 和第 i-2 项有关,因此只需要存储前两项的值就能求解第 i 项,从而将空间复杂度由 O(N) 降低为 O(1):
int Fib3(int n)
{
if(n<=1)
return n;
int res=0;
int a=0,b=1;
for(int i=2;i<=n;i++)
{
res=a+b;
a=b;
b=res;
}
return res;
}
青蛙跳台阶
一只青蛙一次可以跳上1级台阶,也可以跳上2级
求该青蛙跳上一个 n 级的台阶总共有多少种跳法
n=1:一种
n=2两种:
跳 n 阶台阶,可以先跳 1 阶台阶,再跳 n-1 阶台阶;或者先跳 2 阶台阶,再跳 n-2 阶台阶。而 n-1 和 n-2 阶台阶的跳法可以看成子问题,该问题的递推公式为
int JumpFloor(int n)
{
if(n<=2)
return n;
int a=1,b=2;
int res;
for(int i=3;i<=n;i++)
{
res=a+b;
a=b;
b=res;
}
return res;
}
变态蛙跳台阶
一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级… 它也可以跳上 n 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法
动态规划
状态:
- 子状态:跳上1级,2级,3级,…,n级台阶的跳法数
- f(n):还剩n个台阶的跳法数
状态递推:
- n级台阶,第一步有n种跳法:跳1级、跳2级、到跳n级
- 跳1级,剩下n-1级,则剩下跳法是f(n-1)
- 跳2级,剩下n-2级,则剩下跳法是f(n-2)
f(n) = f(n-1)+f(n-2)+…+f(n-n)
f(n) = f(n-1)+f(n-2)+…+f(0)
f(n-1) = f(n-2)+…+f(0)
f(n) = 2*f(n-1)初始值:
f(1) = 1
f(2) = 2f(1) = 2
f(3) = 2f(2) = 4
f(4) = 2*f(3) = 8
所以它是一个等比数列
f(n) = 2^(n-1)
返回结果:
f(N)
int JumpFloorII(int n)
{
if(n<=1)
return n;
int s=1,res=0;
for(int i=2;i<=n;i++)
{
res=2*s;
s=res;
}
return res;
}
排列
每个台阶看成一个位置,除过最后一个位置,其它位置都有两种可能
所以总的排列数为2 ^ (n-1) *1 = 2 ^ (n-1)
int jumpFloorII(int n) {
if(n <= 0)
return 0;
int total = 1;
for(int i = 1;i < number;i++)
total *= 2;
return total;
}
};
/*
降低时间复杂度
上述实现的时间复杂度:O(N)
O(1)的实现:使用移位操作
*/
int jumpFloorII(int number) {
if(number <= 0)
return 0;
return 1<<(number-1);
}
矩形覆盖
我们可以用 2 * 1 的小矩形横着或者竖着去覆盖更大的矩形。请问用 n 个 2 * 1 的小矩形无重叠地覆盖一个 2 * n 的大矩形,总共有多少种方法
n=1时一种:
n=2时两种:
要覆盖 2 * n 的大矩形,可以先覆盖 2 * 1 的矩形,再覆盖 2 * (n-1) 的矩形;或者先覆盖 2 * 2 的矩形,再覆盖 2 * (n-2)的矩形。而覆盖 2 * (n-1) 和 2 * (n-2) 的矩形可以看成子问题。该问题的递推公式如下:
int RectCover(int n)
{
if(n<=2)
return n;
int a=1,b=2;
int res;
for(int i=3;i<=n;i++)
{
res=a+b;
a=b;
b=res;
]
return res;
}
最大连续子数组和
HZ偶尔会拿些专业问题来忽悠那些非计算机专业的同学。今天测试组开完会后,他又发话了:
在古老的一维模式识别中,常常需要计算连续子向量的最大和,当向量全为正数的时候,问题很好解决。但是,如果向量中包含负数,是否应该包含某个负数,并期望旁边的正数会弥补它呢?
例如:{6,-3,-2,7,-15,1,2,2},连续子向量的最大和为8(从第0个开始,到第3个为止)。给一个数组,返回它的最大连续子序列的和,你会不会被他忽悠住?(子向量的长度至少是1)
状态:
子状态:长度为1,2,3,…,n的子数组和的最大值
F(i):长度为i的子数组和的最大值,这种定义不能形成递推关系,舍弃
F(i):以array[i]为末尾元素的子数组和的最大值
状态递推:
F(i) = max(F(i-1) + array[i],array[i])
F(i) = (F(i-1) > 0)? F(i-1) + array[i] : array[i]
初始值:
F(0) = array[0]
返回结果:
maxsum:所有F(i)中的最大值
int FindGreatestSumOfSubArray(vector<int> array)
{
if (array.empty())
return -1;
// F(i)初始化
int sum = array[0];
// maxsum初始化
int maxsum = array[0];
for (int i = 1; i < array.size(); i++)
{
// F(i) = max(F(i-1) + array[i],array[i])
sum = (sum > 0) ? sum + array[i] : array[i];
// maxsum = max( maxsum,F(i))
maxsum = (sum < maxsum) ? maxsum : sum;
}
return maxsum;
}
字符串分割(Word Break)
给定一个字符串和一个词典dict,确定s是否可以根据词典中的词分成一个或多个单词
比如,给定 s = “leetcode” dict = [“leet”, “code”]
返回true,因为"leetcode"可以被分成"leet code"
状态:
- 子状态:前1,2,3,…,n个字符能否根据词典中的词被成功分词
- F(i): 前i个字符能否根据词典中的词被成功分词
状态递推:
F(i): true{j <i && F(j) && substr[j+1,i]能在词典中找到} OR false
在j小于i中,只要能找到一个F(j)为true,并且从j+1到i之间的字符能在词典中找到,则F(i)为true
初始值:
对于初始值无法确定的,可以引入一个不代表实际意义的空状态,作为状态的起始
空状态的值需要保证状态递推可以正确且顺利的进行,到底取什么值可以通过简单的例子进行验证
F(0) = true
返回结果:F(n)
bool wordBreak(string s, unordered_set<string> &dict)
{
if(s.empty())
return false;
if (dict.empty())
return false;
//获取词典中的单词的最大长度
int max_length = 0;
unordered_set<string>::iterator dict_iter= dict.begin();
for(; dict_iter != dict.end(); dict_iter++)
{
if((*dict_iter).size() > max_length)
{
max_length = (*dict_iter).size();
}
}
vector<bool> can_break(s.size() + 1, false);
//初始化F(0) = true
can_break[0] = true;
for(int i = 1; i <= s.size(); i++)
{
for(int j = i - 1; j >= 0; j--)
{
// 如果最小子串长度大于max_length,跳过
if((i-j) > max_length)
{
break;
}
// F(i): true{j <i && F(j) && substr[j+1,i]能在词典中找到} OR false
// 第j+1个字符的索引为j
if(can_break[j] && dict.find(s.substr(j, i - j)) != dict.end())
{
can_break[i] = true;
break;
}
}
}
return can_break[s.size()];
}
三角矩阵(Triangle)
给定一个三角矩阵,找出自顶向下的最短路径和,每一步可以移动到下一行的相邻数字。
比如给定下面一个三角矩阵,自顶向下的最短路径和为11。
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
易错点:只保留每一步的最小值,忽略其他路径,造成最终结果错误【局部最小不等于全局最小】
遇到关于矩阵,网格,字符串间的比较,匹配的问题,单序列(一维)动规解决不了的情况下,考虑双序列(二维)动规
状态:
- 子状态:从(0,0)到(1,0),(1,1),(2,0),…(n,n)的最短路径和
- F(i,j): 从(0,0)到(i,j)的最短路径和
状态递推:
F(i,j) = min( F(i-1, j-1), F(i-1, j)) + triangle[i][j]
初始值:
F(0,0) = triangle[0][0]
返回结果:
min(F(n-1, i))
int minimumTotal(vector<vector<int>> &triangle)
{
if (triangle.empty())
return 0;
// F[i][j], F[0][0]初始化
vector<vector<int>> min_sum(triangle);
int line = triangle.size();
for (int i = 1; i < line; i++)
{
for (int j = 0; j <= i; j++)
{
// 处理左边界和右边界
if (j == 0)
{
min_sum[i][j] = min_sum[i - 1][j];
}
else if (j == i)
{
min_sum[i][j] = min_sum[i - 1][j - 1];
}
else
{
min_sum[i][j] = min(min_sum[i-1][j], min_sum[i-1][j-1]);
}
//F(i,j) = min( F(i-1, j-1), F(i-1, j)) + triangle[i][j]
min_sum[i][j] = min_sum[i][j] + triangle[i][j];
}
}
int result = min_sum[line - 1][0];
//min(F(n-1, i))
for(int i = 1; i < line; i++)
{
result = min(min_sum[line - 1][i], result);
}
return result;
}
动态规划(反向思维)
状态:
- 子状态:从(n,n),(n,n-1),…(1,0),(1,1),(0,0)到最后一行的最短路径和
- F(i,j): 从(i,j)到最后一行的最短路径和
状态递推:
F(i,j) = min( F(i+1, j), F(i+1, j+1)) + triangle[i][j]
初始值:
F(n-1,0) = triangle[n-1][0], F(n-1,1) = triangle[n-1][1],…, F(n-1,n-1) = triangle[n-1][n-1]
返回结果:
F(0, 0)
这种逆向思维不需要考虑边界,也不需要最后寻找最小值,直接返回F(0,0)即可
int minimumTotal(vector<vector<int>> &triangle)
{
if (triangle.empty())
return 0;
// F[n-1][n-1],...F[n-1][0]初始化
vector<vector<int>> min_sum(triangle);
int line = triangle.size();
// 从倒数第二行开始
for (int i = line - 2; i >= 0; i--)
{
for (int j = 0; j <= i; j++)
{
// F(i,j) = min( F(i+1, j), F(i+1, j+1)) + triangle[i][j]
min_sum[i][j] = min(min_sum[i + 1][j], min_sum[i + 1][j + 1]) +
triangle[i][j];
}
}
return min_sum[0][0];
}
路径总数
在一个m*n的网格的左上角有一个机器人,机器人在任何时候只能向下或者向右移动,机器人试图到达网格的右下角,有多少可能的路径
状态:
- 子状态:从(0,0)到达(1,0),(1,1),(2,1),…(m-1,n-1)的路径数
- F(i,j): 从(0,0)到达F(i,j)的路径数
状态递推:
F(i,j) = F(i-1,j) + F(i,j-1)
初始化:
特殊情况:第0行和第0列
F(0,i) = 1
F(i,0) = 1
返回结果:
F(m-1,n-1)
int uniquePaths(int m, int n)
{
if (m < 1 || n < 1)
return 0;
// 申请F(i,j)空间,初始化
vector<vector<int> > ret(m, vector<int>(n, 1));
for (int i = 1; i < m; ++i)
{
for (int j = 1; j < n; ++j)
{
// F(i,j) = F(i-1,j) + F(i,j-1)
ret[i][j] = ret[i - 1][j] + ret[i][j - 1];
}
}
return ret[m-1][n-1];
}
路径总数(Unique Paths II)
和上题框架相同,机器人还是要从网格左上角到达右下角,但是网格中添加了障碍物,障碍物用1表示
状态:
- 子状态:从(0,0)到达(1,0),(1,1),(2,1),…(m-1,n-1)的路径数
- F(i,j): 从(0,0)到达F(i,j)的路径数
状态递推:
F(i,j) = {F(i-1,j) + F(i,j-1)} OR {0, if obstacleGrid(i,j) = 1}
初始化:
特殊情况:第0行和第0列
F(0,i) = {1} OR {0, if obstacleGrid(0,j) = 1, j <= i}
F(i,0) = {1} OR {0, if obstacleGrid(j,0) = 1, j <= i}
返回结果:
F(m-1,n-1)
int uniquePathsWithObstacles(vector<vector<int> > &obstacleGrid)
{
if (obstacleGrid.empty() || obstacleGrid[0].empty())
return 0;
const int M = obstacleGrid.size();
const int N = obstacleGrid[0].size();
// 申请F(i,j)空间,初始值为0
vector<vector<int> > ret(M, vector<int>(N, 0));
for (int i = 0; i < M; ++i)
{
// 第0列中只要前面有障碍,后面都无法到达
if(obstacleGrid[i][0])
break;
else
ret[i][0] = 1;
}
for (int i = 1; i < M; ++i)
{
for (int j = 1; j < N; ++j)
{
//obstacleGrid[i][j] = 1 时,F(i,j)无法到达
if (obstacleGrid[i][j])
ret[i][j] = 0;
else
// F(i,j) = F(i-1,j) + F(i,j-1)
ret[i][j] = ret[i - 1][j] + ret[i][j - 1];
}
}
return ret[M - 1][N - 1];
}
最小路径和(Minimum Path Sum)
给定一个m*n的网格,网格用非负数填充,找到一条从左上角到右下角的最短路径
注:每次只能向下或者向右移动
状态:
- 子状态:从(0,0)到达(1,0),(1,1),(2,1),…(m-1,n-1)的最短路径
- F(i,j): 从(0,0)到达F(i,j)的最短路径
状态递推:
F(i,j) = min{F(i-1,j) , F(i,j-1)} + (i,j)
初始化:
F(0,0) = (0,0)
特殊情况:第0行和第0列
F(0,i) = F(0,i-1) + (0,i)
F(i,0) = F(i-1,0) + (i,0)
返回结果:
F(m-1,n-1)
int minPathSum(vector<vector<int> > &grid)
{
// 如果为空或者只有一行,返回0
if(grid.empty() || grid[0].empty())
return 0;
// 获取行和列大小
const int M = grid.size();
const int N = grid[0].size();
// F(i,j)
vector<vector<int> > ret(M, vector<int>(N, 0));
// F(0,0), F(0,i), F(i,0)初始化
ret[0][0] = grid[0][0];
for (int i = 1; i != M; ++i)
{
ret[i][0] = grid[i][0] + ret[i - 1][0];
}
for (int i = 1; i != N; ++i)
{
ret[0][i] = grid[0][i] + ret[0][i - 1];
}
// F(i,j) = min{F(i-1,j) , F(i,j-1)} + (i,j)
for (int i = 1; i < M; ++i)
{
for (int j = 1; j < N; ++j)
{
ret[i][j] = grid[i][j] + min(ret[i - 1][j], ret[i][j - 1]);
}
}
return ret[M - 1][N - 1];
}
背包问题
有 n 个物品和一个大小为 m 的背包. 给定数组 A 表示每个物品的大小和数组 V 表示每个物品的价值.问最多能装入背包的总价值是多大?
状态:
F(i, j): 前i个物品放入大小为j的背包中所获得的最大价值
状态递推:
对于第i个商品,有一种例外,装不下,两种选择,放或者不放
如果装不下:此时的价值与前i-1个的价值是一样的
F(i,j) = F(i-1,j)
如果可以装入:需要在两种选择中找最大的
F(i, j) = max{F(i-1,j), F(i-1, j - A[i]) + V[i]}
F(i-1,j): 表示不把第i个物品放入背包中, 所以它的价值就是前i-1个物品放入大小为j的背包的最大价值
F(i-1, j - A[i]) + V[i]:表示把第i个物品放入背包中,价值增加V[i],但是需要腾出j - A[i]的大小放第i个商品
初始化:第0行和第0列都为0,表示没有装物品时的价值都为0
F(0,j) = F(i,0) = 0
返回值:F(n,m)
int backPackII(int m, vector<int> A, vector<int> V)
{
if (A.empty() || V.empty() || m < 1)
return 0;
//多加一行一列,用于设置初始条件
const int N = A.size() + 1;
const int M = m + 1;
vector<vector<int> > result;
result.resize(N);
//初始化所有位置为0,第一行和第一列都为0,初始条件
for(int i = 0; i != N; ++i)
{
result[i].resize(M, 0);
}
for (int i = 1; i < N; ++i)
{
for (int j = 1; j != M; ++j)
{
//第i个商品在A中对应的索引为i-1: i从1开始
//如果第i个商品大于j,说明放不下, 所以(i,j)的最大价值和(i-1,j)相同
if (A[i - 1] > j)
{
result[i][j] = result[i - 1][j];
}
//如果可以装下,分两种情况,装或者不装
//如果不装,则即为(i-1, j)
//如果装,需要腾出放第i个物品大小的空间: j - A[i-1],装入之后的最大价值即为(i - 1, j - A[i-1]) + 第i个商品的价值V[i - 1]
//最后在装与不装中选出最大的价值
else
{
int newValue = result[i - 1][j - A[i - 1]] + V[i - 1];
result[i][j] = max(newValue, result[i - 1][j]);
}
}
}
//返回装入前N个商品,物品大小为m的最大价值
return result[N - 1][m];
}
/*
优化算法:
上面的算法在计算第i行元素时,只用到第i-1行的元素,所以二维的空间可以优化为一维空间
但是如果是一维向量,需要从后向前计算,因为后面的元素更新需要依靠前面的元素未更新(模拟二维矩阵的上一行的值)的值
*/
int backPackII(int m, vector<int> A, vector<int> V)
{
if (A.empty() || V.empty() || m < 1)
return 0;
const int N = A.size();
//多加一列,用于设置初始条件,因为第一件商品要用到前面的初始值
const int M = m + 1;
vector<int> result;
//初始化所有位置为0,第一行都为0,初始条件
result.resize(M, 0);
//这里商品的索引位置不需要偏移,要和未优化的方法区分开
//这里的i-1理解为上一行,或者未更新的一维数组值
for (int i = 0; i != N; ++i)
{
for (int j = M - 1; j > 0; --j)
{//如果第i个商品大于j,说明放不下, 所以(i,j)的最大价值和(i-1,j)相同
if (A[i] > j)
{
result[j] = result[j];
}
//如果可以装下,分两种情况,装或者不装
//如果不装,则即为(i-1, j)
//如果装,需要腾出放第i个物品大小的空间: j - A[i],装入之后的最大价值即为(i - 1, j - A[i-1]) + 第i个商品的价值V[i]
//最后在装与不装中选出最大的价值
else
{
int newValue = result[j - A[i]] + V[i];
result[j] = max(newValue, result[j]);
}
}
}
//返回装入前N个商品,物品大小为m的最大价值
return result[m];
}
回文串分割(Palindrome Partitioning)
给定一个字符串 s,把 s 分割成一系列的子串,分割的每一个子串都为回文串。返回最小的分割次数
比如,给定 s = “aab”,
返回1,因为一次cut就可以产生回文分割[“aa”,“b”]
状态:
- 子状态:到第1,2,3,…,n个字符需要的最小分割数
- F(i): 到第i个字符需要的最小分割数
状态递推:
F(i) = min{F(i), 1 + F(j)}, where j<i && j+1到i是回文串
上式表示如果从j+1到i判断为回文字符串,且已经知道从第1个字符
到第j个字符的最小切割数,那么只需要再切一次,就可以保证
1–>j, j+1–>i都为回文串。
初始化:
F(i) = i - 1
上式表示到第i个字符需要的最大分割数
比如单个字符只需要切0次,因为单子符都为回文串
2个字符最大需要1次,3个2次…
返回结果:
F(n)
如何判断一段字符串为回文串:
循环判断首尾元素是否相同,如果全部相同,则是回文串
class Solution {
public:
int minCut(string s)
{
if (s.empty()) .
return 0;
int len = s.size();
vector<int> cut;
// F(i)初始化
// F(0)= -1,必要项,如果没有这一项,对于重叠字符串“aaaaa”会产生错误的结果
for (int i = 0; i < 1 + len; ++i)
{
cut.push_back(i - 1);
}
for (int i = 1; i < 1 + len; ++i)
{
for (int j = 0; j < i; ++j)
{
// F(i) = min{F(i), 1 + F(j)}, where j<i && j+1到i是回文串
// 从最长串判断,如果从第j+1到i为回文字符串
// 则再加一次分割,从1到j,j+1到i的字符就全部分成了回文字符串
if (isPalindrome(s, j, i - 1))
{
cut[i] = min(cut[i], 1 + cut[j]);
}
}
}
return cut[len];
}
//判断是否回文串
bool isPalindrome(string s, int i, int j)
{
while (i<j)
{
if (s[i] != s[j])
return false;
i++;
j--;
}
return true;
}
};
上述方法两次循环时间复杂度是O(n^2),
判断回文串时间复杂度是O(n),
所以总时间复杂度为O(n^3)
判断回文串的方法可以继续优化,使总体时间复杂度将为O(n^2)
判断回文串,这是一个“是不是”的问题,所以也可以用动态规划来实现
判断回文串:动态规划
状态:
- 子状态:从第一个字符到第二个字符是不是回文串,第1-3,第2-5,…
- F(i,j): 字符区间 [i,j] 是否为回文串
状态递推:
F(i,j): true->{s[i]==s[j] && F(i+1,j-1)} OR false
上式表示如果字符区间首尾字符相同且在去掉区间首尾字符后字符区间仍为回文串,
则原字符区间为回文串
从递推公式中可以看到第i处需要用到第i+1处的信息,所以i应该从字符串末尾遍历
初始化:
F(i,j) = false
返回结果:
矩阵F(n,n), 只更新一半值(i <= j),n^2 / 2
*/
class Solution2 {
public:
int minCut(string s)
{
if(s.empty())
return 0;
int len = s.size();
vector<int> cut;
// F(i)初始化
// F(0)= -1,必要项,如果没有这一项,对于重叠字符串“aaaaa”会产生错误的结果
for (int i = 0; i < 1 + len; ++i)
{
cut.push_back(i - 1);
}
vector<vector<bool> > mat = getMat(s);
for(int i = 1; i < 1 + len; ++i)
{
for(int j = 0; j < i; ++j)
{
//F(i) = min{F(i), 1 + F(j)}, where j<i && j+1到i是回文串
//从最长串判断,如果从第j+1到i为回文字符串
//则再加一次分割,从1到j,j+1到i的字符就全部分成了回文字符串
if (mat[j][i - 1])
{
cut[i] = min(cut[i], 1 + cut[j]);
}
}
}
return cut[len];
}
vector<vector<bool> > getMat(string s)
{
int len = s.size();
vector<vector<bool> > mat = vector<vector<bool> >(len, vector<bool>(len, false));
for(int i = len - 1; i >= 0; --i)
{
for (int j = i; j < len; ++j)
{
if (j == i)
{
// 单字符为回文字符串
mat[i][j] = true;
}
else if (j == i + 1)
{
// 相邻字符如果相同,则为回文字符串
mat[i][j] = (s[i] == s[j]);
}
else
{
// F(i,j) = {s[i]==s[j] && F(i+1,j-1)
// j > i+1
mat[i][j] = ((s[i] == s[j]) && mat[i + 1][j - 1]);
}
}
}
return mat;
}
};
上述方法判断回文串时间复杂度O(n^2)
主方法两次循环时间复杂度O(n^2)
总体时间复杂度O(n^ 2) ~ O(2 * n^ 2) = O(n ^ 2) + O(n^2)
总结:
简单的动态规划问题,状态,状态递推和状态初始化都比较直观
对于复杂的动态规划问题,状态,状态递推和状态初始化都比较隐含,需要仔细推断,尤其是状态递推可能需要额外的辅助判断条件才能达成
编辑距离(Edit Distance)
字符串类的动态规划,可引入空串进行初始化
给定两个单词word1和word2,找到最小的修改步数,把word1转换成word2
每一个操作记为一步
允许在一个word上进行如下3种操作:
a) 插入一个字符
b) 删除一个字符
c) 替换一个字符
编辑距离(Edit Distance):
是指两个字串之间,由一个转成另一个所需的最少编辑操作次数
状态:
- 子状态:word1的前1,2,3,…m个字符转换成word2的前1,2,3,…n个字符需要的编辑距离
- F(i,j):word1的前i个字符于word2的前j个字符的编辑距离
状态递推:
F(i,j) = min { F(i-1,j)+1, F(i,j-1) +1, F(i-1,j-1) +(w1[i]==w2[j]?0:1) }
上式表示从删除,增加和替换操作中选择一个最小操作数
F(i-1,j): w1[1,…,i-1]于w2[1,…,j]的编辑距离,删除w1[i]的字符—>F(i,j)
F(i,j-1): w1[1,…,i]于w2[1,…,j-1]的编辑距离,增加一个字符—>F(i,j)
F(i-1,j-1): w1[1,…,i-1]于w2[1,…,j-1]的编辑距离,如果w1[i]与w2[j]相同,
不做任何操作,编辑距离不变,如果w1[i]与w2[j]不同,替换w1[i]的字符为w2[j]—>F(i,j)
初始化:
初始化一定要是确定的值,如果这里不加入空串,初始值无法确定
F(i,0) = i :word与空串的编辑距离,删除操作
F(0,i) = i :空串与word的编辑距离,增加操作
返回结果:F(m,n)
int minDistance(string word1, string word2)
{
// word与空串之间的编辑距离为word的长度
if (word1.empty() || word2.empty())
return max(word1.size(), word2.size());
int len1 = word1.size();
int len2 = word2.size();
// F(i,j)初始化
vector<vector<int> > f(1 + len1, vector<int>(1 + len2, 0));
for (int i = 0; i <= len1; ++i)
{
f[i][0] = i;
}
for (int i = 0; i <= len2; ++i)
{
f[0][i] = i;
}
for (int i = 1; i <= len1; ++i)
{
for (int j = 1; j <= len2; ++j)
{
// F(i,j) = min { F(i-1,j)+1, F(i,j-1) +1, F(i-1,j-1) +(w1[i]==w2[j]?0:1) }
// 判断word1的第i个字符是否与word2的第j个字符相等
if (word1[i - 1] == word2[j - 1])
{
f[i][j] = 1 + min(f[i][j - 1], f[i - 1][j]);
// 字符相等,F(i-1,j-1)编辑距离不变
f[i][j] = min(f[i][j], f[i - 1][j - 1]);
}
else
{
f[i][j] = 1 + min(f[i][j - 1], f[i - 1][j]);
// 字符不相等,F(i-1,j-1)编辑距离 + 1
f[i][j] = min(f[i][j], 1 + f[i - 1][j - 1]);
}
}
}
return f[len1][len2];
}
不同子序列(Distinct Subsequences)
给定两个字符串S和T,求S有多少个不同的子串与T相同。
S的子串定义为在S中任意去掉0个或者多个字符形成的串
子串可以不连续,但是相对位置不能变。
比如“ACE”是“ABCDE”的子串,但是“AEC”不是。
理解:S有多少个不同的子串与T相同
S[1:m]中的子串与T[1:n]相同的个数
由S的前m个字符组成的子串与T的前n个字符相同的个数
状态:
- 子状态:由S的前1,2,…,m个字符组成的子串与T的前1,2,…,n个字符相同的个
- F(i,j): S[1:i]中的子串与T[1:j]相同的个数
状态递推:
在F(i,j)处需要考虑S[i] = T[j] 和 S[i] != T[j]两种情况
当S[i] = T[j]:
1. 让S[i]匹配T[j],则
F(i,j) = F(i-1,j-1)
2. 让S[i]不匹配T[j],则问题就变为S[1:i-1]中的子串与T[1:j]相同的个数,则
F(i,j) = F(i-1,j)故,S[i] = T[j]时,F(i,j) = F(i-1,j-1) + F(i-1,j)
当S[i] != T[j]:
问题退化为S[1:i-1]中的子串与T[1:j]相同的个数
故,S[i] != T[j]时,F(i,j) = F(i-1,j)
初始化:引入空串进行初始化
F(i,0) = 1 —> S的子串与空串相同的个数,只有空串与空串相同
返回结果:
F(m,n)
class Solution {
public:
int numDistinct(string S, string T)
{
int s_size = S.size();
int t_size = T.size();
// S的长度小于T长度,不可能含有与T相同的子串
if (S.size() < T.size())
return 0;
//T为空串,只有空串与空串相同,S至少有一个子串,它为空串
if (T.empty())
return 1;
// F(i,j),初始化所有的值为0
vector<vector<int> > f(s_size + 1, vector<int>(t_size + 1, 0));
// 空串与空串相同的个数为1
f[0][0] = 1;
for(int i = 1; i <= s_size; ++i)
{
// F(i,0)初始化
f[i][0] = 1;
for(int j = 1; j <= t_size; ++j)
{
//S的第i个字符与T的第j个字符相同
if (S[i-1] == T[j-1])
{
f[i][j] = f[i-1][j] + f[i-1][j-1];
}
else
{
//S的第i个字符与T的第j个字符不相同
//从S的前i-1个字符中找子串,使子串与T的前j个字符相同
f[i][j] = f[i-1][j];
}
}
}
return f[s_size][t_size];
}
};