从概念和原理出发去学习某个知识点,往往有种晦涩、无从下手的感觉,本文列举两个示例,从实际应用的角度来理解“动态规划”思想。
“数字三角形”是动态规划的经典入门问题:
问题描述:
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
给定一个数字三角形(例如上图),寻找一条从顶部到底边的路径,使得路径上所经过的数字之和最大。路径上的每一步都只能往左下或右下走。只需要求出这个最大和即可,不必给出具体路径。三角形的行数大于1且小于等于100,数字范围为0-99。
输入描述:第一行输入三角形的行数,接下来输入数字三角形
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
输出描述:输出路径的最大和
30
分析:我们用一个二维数组来存储数字三角形,其中D[i][j]表示第i行第j个数字(i, j从1开始算)。用MaxSum(i, j)表示从D[i][j]到底边的各条路径中最佳路径的数字之和,所以本题即为求MaxSum(1, 1),是一个典型的递归问题。
1.递归
由题意,当从D[i][j]出发时,下一步的选择只能是D[i + 1][j]或D[i + 1][j + 1]。所以,对于n行的三角形,可得到以下递归式:
// 如果D[i][j]就在底边,则其MaxSum的值即为它本身
if(i == n)
MaxSum(i, j) = D[i][j]
// 如果D[i][j]不在底边,则其MaxSum的值为它左下和右下元素的MaxSum值的较大者加上它本身
else
MaxSum(i, j) = Max{ MaxSum(i + 1, j), MaxSum(i + 1, j + 1) } + D[i][j]
由此可写出“递归”思路的代码,如下:
#include
#include
using namespace std;
#define MAX 101
int D[MAX][MAX];
int n;
int MaxSum(int i, int j)
{
if(i == n)
return D[i][j];
int x = MaxSum(i + 1, j);
int y = MaxSum(i + 1, j + 1);
return max(x, y) + D[i][j];
}
int main()
{
int i, j;
cin >> n;
for(i = 1; i <= n; i++)
for(j = 1; j <= i; j++)
cin >> D[i][j];
cout << MaxSum(1,1) << endl;
return 0;
}
递归的思路简单,但由于存在大量重复计算,时间复杂度很高(O(2^n))。例如下图,我们从第二行的数字3和8出发,求其到底边的最佳路径之和时,均需要计算一次第三行数字1到底边的最佳路径之和,重复计算浪费了时间。
2.记忆递归型的动态规划:
怎么降低上述方案的时间复杂度呢,我们可以想到每计算出一个MaxSum(i, j)就保存起来,下次用到其值的时候直接取用,则可免去重复计算。这种做法的时间复杂度下降到了O(n^2)。代码如下:
#include
#include
using namespace std;
#define MAX 101
int D[MAX][MAX];
int n;
int maxSum[MAX][MAX];
int MaxSum(int i, int j)
{
// 如果该maxSum值存在则直接返回,不再进行重复计算
if( maxSum[i][j] != -1 )
return maxSum[i][j];
if(i==n)
maxSum[i][j] = D[i][j];
else{
int x = MaxSum(i+1, j);
int y = MaxSum(i+1, j+1);
maxSum[i][j] = max(x, y)+ D[i][j];
}
return maxSum[i][j];
}
int main()
{
int i,j;
cin >> n;
for(i = 1; i <= n; i++)
for(j = 1; j <= i; j++) {
cin >> D[i][j];
// 还未计算maxSum值时,初始化为-1
maxSum[i][j] = -1;
}
cout << MaxSum(1,1) << endl;
return 0;
}
3.递归转为递推
如果不采用递归的方法,怎么解决这个问题呢?此时我们需要将递归转为递推——由已知推出未知:从最底层开始,一层层依次向上递推求每个数字的MaxSum值,过程如下图:
这种方法称为“人人为我”型递推动态规划(时间复杂度为O(n^2)),代码如下:
#include
#include
using namespace std;
#define MAX 101
int D[MAX][MAX];
int n;
int maxSum[MAX][MAX];
int main()
{
int i, j;
cin >> n;
for(i = 1; i <= n; i++)
for(j = 1; j <= i; j++)
cin >> D[i][j];
// 为第n行的maxSum值赋值
for(int i = 1; i <= n; ++i)
maxSum[n][i] = D[n][i];
// 依次向上类推出每个数字的maxSum值
for(int i = n - 1; i >= 1; --i)
for(int j = 1; j <= i; ++j)
maxSum[i][j] = max(maxSum[i + 1][j], maxSum[i + 1][j + 1]) + D[i][j];
cout << maxSum[1][1] << endl;
return 0;
}
另外,在此基础上,我们还可以从空间复杂度上去优化代码(时间复杂度不变):我们没必要用二维数组来存储每一个maxSum值,只用一维数组maxSum[100]存储一行MaxSum值就行了,如下图:
这样,我们所求的最佳路径数字之和即为maxSum[1]。
进一步考虑,连一维maxSum数组都可以不要,直接用D的第n行替代maxSum即可,代码如下:
#include
#include
using namespace std;
#define MAX 101
int D[MAX][MAX];
int n;
int *maxSum;
int main()
{
int i,j;
cin >> n;
for(i = 1; i <= n; i++)
for(j = 1; j <= i; j++)
cin >> D[i][j];
maxSum = D[n]; //maxSum指向第n行
for(int i = n - 1; i >= 1; --i)
for(int j = 1; j <= i; ++j)
maxSum[j] = max(maxSum[j], maxSum[j+1]) + D[i][j];
cout << maxSum[1] << endl;
return 0;
}
以上就是关于“数字三角形”问题的讨论,从中我们可以总结出动态规划解题的一般思路:
(1)将原问题分解为子问题
子问题与原问题形式相似,只不过规模变小了,子问题都解决了,原问题就解决了。“数字三角形”问题中的原问题即为求顶端到底边的最大路径和,它的子问题则为求顶端的左下和右下元素到底边路径的较大者。
(2)确定状态
“状态”是指和子问题相关的各个变量的一组取值,例如“数字三角形”中位置(i, j)对应的数字到底边的最大路径和就为一个状态。
(3)确定一些初始状态的值
以“数字三角形”为例,初始状态就是底边数字,值就是底边数字值。
(4)确定状态转移方程
定义出什么是“状态”,以及在该“状态”下的“值”后,就要找出不同的状态之间如何迁移,即如何从一个或多个“值”已知的 “状态”,求出另一个“状态”的“值”(递推型动态规划)。
下面我们尝试运用这种思路来解决“构造回文问题”。
(腾讯2017暑期实习生编程题)
问题描述:
给定一个字符串s,你可以从中删除一些字符,使得剩下的串是一个回文串。如何删除才能使得回文串最长呢?输出需要删除的字符个数。
输入描述:输入数据有多组,每组包含一个字符串s,且保证字符串的长度大于等于1且小于等于100。
输出描述:对于每组数据,输出一个整数,代表最少需要删除的字符个数。
输入例子:
abcda
google
输出例子:
2
2
分析
(1)我们需要利用回文串的特点:源字符串逆转后,回文串(不一定连续)相当于顺序没变;
(2)利用动态规划的思想求源字符串和逆转字符串的最大公共子序列(非子串)的长度;
(3)字符串的总长度减去最大公共子序列的长度即为最少需要删除的字符个数。
解题思路
运用动态规划的一般思路分析如何求解字符串的最大公共子序列(LCS)
(1)将原问题分解为子问题
设字符串s1=
原问题:求字符串s1、s2的最大公共子序列长度。
子问题:由最大公共子序列的性质,若xm == yn,则需进一步解决子问题s1’=
(2)确定状态
用一个二维数组MaxLen保存两个字符串的最大公共子序列长度,“状态”MaxLen[i][j](i,j均从1开始)表示s1左边i个字符与s2左边j个字符的最大公共子序列长度。
(3)确定一些初始状态的值
MaxLen[i][j] = 0 (当序列s1的长度为0或s2的长度为0)
(4)确定状态转移方程
MaxLen[i][j]的状态转移关系如下:
// 若s1第i个字符与s2第j个字符相匹配
if(s1[i - 1] == s2[j - 1])
MaxLen[i][j] = MaxLen[i - 1][j - 1] + 1
// 若s1第i个字符与s2第j个字符不匹配
else
MaxLen[i][j] = max(MaxLen[i - 1][j], MaxLen[i][j - 1])
代码实现如下:
#include
#include
#include
using namespace std;
const int MAX = 101;
int MaxLen[MAX][MAX];
int maxLen(string s1, string s2)
{
int length1 = s1.size();
int length2 = s2.size();
for (int i = 0; i < length1; ++i)
MaxLen[i][0] = 0;
for (int i = 0; i < length2; ++i)
MaxLen[0][i] = 0;
for (int i = 1; i <= length1; ++i)
{
for (int j = 1; j <= length2; ++j)
{
if (s1[i-1] == s2[j-1])
{
MaxLen[i][j] = MaxLen[i-1][j - 1] + 1;
}
else
{
MaxLen[i][j] = max(MaxLen[i - 1][j], MaxLen[i][j - 1]);
}
}
}
return MaxLen[length1][length2];
}
int main()
{
string s;
while (cin >> s)
{
int length = s.size();
if (length == 1)
{
cout << 1 << endl;
continue;
}
// 利用回文串的特点
string s2 = s;
reverse(s2.begin(), s2.end());
int max_length = maxLen(s, s2);
cout << length - max_length << endl;
}
return 0;
}
我们在讨论“数字三角形”问题时,用到了递归和递推的思路,但是,我们需要知道:
动态规划的本质不在于是递推或是递归,也不需要纠结是不是内存换时间。动态规划的本质,是对问题状态的定义和状态转移方程的定义,是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
它反映了可用动态规划解决的问题的特点:一个阶段的最优可以由前一个阶段的最优得到。具体来说:
(1)问题具有最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质。
(2)无后效性。当前的若干个状态值一旦确定,则此后过程的演变就只和这若干个状态的值有关,和之前是采取哪种手段或经过哪条路径演变到当前的这若干个状态,没有关系。
参考资料:
教你彻底学会动态规划——入门篇. http://blog.csdn.net/baidu_28312631/article/details/47418773
视频讲解 - http://v.youku.com/v_show/id_XODkxMDg0OTUy.html
动态规划解最长公共子序列问题. http://blog.csdn.net/yysdsyl/article/details/4226630/
https://www.zhihu.com/question/23995189/answer/35429905