从一道题目出发:求两个字符串的最大公共子串长度,比如字符串str1:BDCABA和字符串str2:ABCBDAB的最大公共子串即为红色部分BCBA,长度为4
-
常规动态规划
如何从动态规划的思想出发解题呢,动态规划的特点是当前最优解是从之前的子最优解和当前状态分析得到,是一种递推的过程,比如上述最大公共子串,对于长度为n的字符串1和长度为m的字符串2的最大公共子串长度记为f(n,m),这个是动态规划的核心,只有正确表示出了题目要求的公式才能进行下一步分析,因此,对于n-1的字符串1和m-1的字符串2的最大公共子串长度为f(n-1,m-1)。当求长度为n的字符串1和长度为m的字符串2的最大公共子串长度时,只需比较str1[n]与str2[m],显然如果str1[n] == str2[m],f(n,m)=f(n-1,m-1)+1,即已解出子最优解加1;当str1[n] != str2[m]时,由于现在要求求f(n,m),所以f(n-1,m)和f(n,m-1)已经求过了,既然str1[n]和str2[m]不相等,则必是f(n-1,m)和f(n,m-1)二者最大的那个。
通过以上分析,便可得到如下公式:
代码如下
#include <vector>
using namespace std;
int max_public_str1(const string& str1, const string& str2)
{
vector<vector<int>> p(str1.length()+1, vector<int>(str2.length()+1, 0));
for (int i = 1; i <= str1.length(); ++i)
{
for (int j = 1; j <= str2.length(); ++j)
{
if (str1[i-1] == str2[j-1])
{
p[i][j] = p[i - 1][j - 1] + 1;
}
else
{
p[i][j] = p[i - 1][j] > p[i][j - 1] ? p[i - 1][j] : p[i][j - 1];
}
}
}
return p[str1.length()][str2.length()];
}
其中,f(n,m)用一个n*m的二维数组表示,记录每个位置str1与str2的最大公共子串长度,初始值是0,其实,严格意义上,只有f(0,1...m)和f(1...n,0)为0,表示字符串1长度为0,无论字符串2长度是几,公共子串长度都是0,同理字符串2长度为0的情况,但是从代码的角度,定义的二维数组变量p[n][m]如果只初始化p[0][0...m]和p[0...n][0],即只初始化第0行和第0列的数据,不符合代码规范,因为有未定义的变量值,另外即使数组都初始化为0,也是合理的,因为此时还没有开始计算。
-
滚动数组
对于使用二维数组记录子最优解结果的方式,显然其空间复杂度为O(n*m),那么能否减少空间复杂度呢,答案是肯定的。通过上述f(n,m)的计算公式,可以看出,无论str1[n]与str2[m]是否相等,f(n,m)的公式中只出现了f(n-1,m-1),f(n,m-1),f(n-1,m)这三个已经求出的值,因此,可以通过一个2*m的二维数组p记录1到m长度时,与1到n的长度最大公共子串长度,这里二维数组p是相当于两个长度为m的一维数组,一个表示前一行一个表示当前行,并非第一行就是前一行,第二行就是当前行,而是随着对str1字符串的遍历交替更新的,这就是滚动数组意思。代码如下:
#include<vector>
using namespace std;
int max_public_str2(const string& str1, const string& str2)
{
vector<vector<int>> p(2,vector<int>(str2.length()+1, 0));
for (int i = 1; i <= str1.length(); ++i)
{
for (int j = 1; j <= str2.length(); j++)
{
if (str1[i-1] == str2[j-1])
{
p[i%2][j] = p[(i-1)%2][j - 1] + 1;
}
else
{
p[i%2][j] = p[(i-1)%2][j] > p[i%2][j - 1] ? p[(i-1)%2][j] : p[i%2][j - 1];
}
}
}
return p[str1.length()%2][str2.length()];
}
通过上述代码可以看出原来n*m的数组p改为了2*m的数组,一维坐标通过i%2在0和1之间进行更替。通过f(n,m)的公式知道,对于n*m的数组p记录结果时,在计算p[i][j]时只用到了p[i-1][j-1],p[i-1][j],p[i][j-1],显然只用到了第i-1行和第i行,而对于0到i-2行的数据已经 不需要了。因此,就可以只用两个长度为m的一维数组或是2*m的二维数组记录这两行的结果。比如计算i=1时p[(1-1)%2=0][...](第一行)是上一次的数据,p[1%2=1][...](第二行)是要计算的数据,当i=2时,p[1%2=1][...]就是上一次的数据,原本应该将本次结果记录在p[2][...](第三行)中,但是只用两行数据空间,而p[2%2=0][...](第一行)的数据已经没有用了,所以可以直接将本次计算的数据保存在p[2%2=0][...]中,当i=3时,p[(3-1)%2=0][...]是上一次的数据,p[3%2=1][...]中数据已经没有用了,所以直接将计算结果保存在p[3%2=1][...]中,依次类推,当计算到i时,p[(i-1)%2][...]是上一次数据,p[i%2][...]是上上一次数据,本次计算用不到了,所以直接在p[i%2][...]记录本次结果。
小结:显然直接根据题意建立对应数组记录结果比较容易理解,比如上述题目用二维数组直接表达公式很容易理解,但是使用滚动数组在理解上会有些困难。其实,滚动数组就是直接使用数组中的值,因此只要确保用使用的已有数据在使用前没有改变。
-
适用于四边形不等式型动态规划
还有一类动态规划其记录结果的数组不适于更改为滚动数组。
有题目出发,比如有如下题目:
有N堆石子排成一排,每堆石子有一定的数量。现要将N堆石子并成为一堆。合并的过程只能每次将相邻的两堆石子堆成一堆,每次合并花费的代价为这两堆石子的和,经过N-1次合并后成为一堆。求出总的代价最小值(获最大代价)。
假设为i到j堆石子合并的总代价,将i到j堆石子分为两堆,划分位置为k,则i到j堆石子合并的总代价为
,其中
为i到j堆石子总数。而对于总代i到j堆石子合并总的代价最小值,就要在i到j之间找到使
值最小的k。
通常其公式如下(其中min如果是max,就是求最大值)
通过上述公式可以看出不仅i,j在遍历,当i,j固定时k还要在i到j之间遍历一趟,寻找最优的k值得到最优策略,显然
,一共需要三次遍历即三次循环,因此时间复杂度为O(n^3)(不妨设
)。
能否优化该算法呢,这里就用到了四边形不等式。
四边形不等式的定义:
对于函数,如果对任意区间
,使得
均成立,则函数
满足四边形不等式。
而对于上述公式,可以证明其满足四边形不等式。这个证明较为复杂,这里不做详述,感兴趣可以看看这个四边形不等式证明。因此,如果用
记录
求得最优策略时
的值,即
时,上述公式可以修改为如下所示
的取值范围由原来的
修改为
,其时间复杂度由原来的O(n^3)优化为O(n^2)
总结:
通过上述一道题的讲解,可以看出动态规划方法解题需要分为阶段划分、确定状态、状态转移、边界条件。阶段划分是将问题划分若干阶段,比如上述题目,划分为f(n-1,m-1)和f(n,m)阶段;确定状态,分析各种可能的状态,比如str1[n]与str2[m]相等与否,对于此题只有两种状态,对于一些题可能有多种状态,要对每一种可能出现的状态进行分析;状态转移,经过分析每一种状态之后,要确定每一种出现的状态情况是如何进行状态更新的,比如上述题目,针对str1[n]与str2[m]相等与否两种状态,f(n,m)计算公式给出了不同的方案;边界条件,就是状态转移的终止条件。