动态规划算法基本概念
动态规划算法的2个基本要素:最优子结构和子问题重叠。
最优子结构
应用动态规划算法第一步:刻画最优解的结构。当问题的最优解包含其子问题的最优解时,称该问题具有最优子结构性质。反过来说,可以利用子问题的最优解推导出问题的最优解。
通常,利用子问题的最优子结构性质,以自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解。
子问题重叠
子问题重叠性质:递归算法自顶向下求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算过多次。
动态规划算法对每个子问题只解一次,并将解存放到一个表格中(可能是一个值,也可能是一个一维数组等)。当后续需要解此子问题时,直接用常数时间查看一下结果。
最长公共子序列
什么是子序列?
对于一个给定的序列,删去若干元素后得到的序列叫子序列。
子序列的元素下标顺序是与原来一致的,也就是说子序列虽然删去了元素,但不会改变以前的排列顺序。注意与连续子序列区分开,子序列不一定是连续的。 如X={1,3,5,7,9},其中一个子序列是Z={3,9}。而序列{9,3}不是X子序列。
公共子序列:对于序列X和Y,当序列Z既是X子序列,也是Y子序列时,称Z是X和Y的公共子序列。
最长公共子序列(Longest Common Sequence,简称LCS):在X和Y的公共子序列中,元素个数最多的序列,称为最长公共子序列。
最长公共子序列问题,就是求解序列X、Y的最长公共子序列。
如X = {3,2,1,5,7,9,5,4}, Y = {6,3,1,4,7,4,2}
此时,序列X和Y最长公共子序列:{3,1,7,4}
最长公共子序列的最优子结构
如何求解序列X、Y的最长公共子序列?
如果用穷举法,先穷举X所有子序列,然后再逐个判断是否为Y的子序列,这样需要指数级时间。因为X如果有m个元素,那么X就有2^m个不同子序列。
有没有更好的办法?
尝试用动态规划来构造最优解。
最长公共子序列问题的最优子结构
设序列X={x1,x2,...,xm},Y={y1,y2,...,yn},最长公共子序列Z={z1,z2,...,zk},则有
1. xm = yn => zk = xm = yn,且Z(k-1)是X(m-1)和Y(m-1)的最长公共子序列;
-- xm, yn, zk分别是X, Y, Z最后一项,如果xm = yn,最长公共子序列Z必然包含xm(yn)。由于子序列不会改变元素顺序关系,必然有zk = xm = yn
2. xm ≠ yn且zk ≠ xm => Z是X(m-1)和Y的最长公共子序列;
-- zk ≠ xm意味着xm(X最后一个元素)不是Z的最后一个元素,也就是说Z不包含xm,因为如果Z包含xm,必有xm是Z最后一个元素(根据子序列不会改变元素顺序特点)。这也就是说,Z也是X(m-1)和Y的最长子序列。
而xm ≠ yn是zk ≠ xm的必要条件,因为根据1,如果xm = yn,必有zk = xm。
3. xm ≠ yn且zk ≠ yn => Z是X和Y(n-1)的最长公共子序列;
-- 道理同2
递归结构
LCS问题的最优子结构,找的是问题规模为n时与子问题规模为n-1时的关系,根据n-1子问题最优解推导出n问题最优解。注意最优子结构问题规模不一定总是n-1,有可能是n-2,n/2,或者别的数,总之是要比母问题规模n小。
用c[i][j]表示序列Xi和Yj的LCS长度,其中Xi = {x1,x2,...,xi},Yj = {y1,y2,...,yj}。
当i=0或j=0时,Z=∅是Xi、Yi的LCS => c[i][j] = 0。其他情况,递归式:
计算最优值
这里最优值指的是c[i][j]最大值,即LCS的最大值,而非最优解(LCS本身),后面利用最优值要构造最优解。
时间复杂度O(mn)
针对LCS问题的类Lcs示例代码,需要注意的是例子中只用到x[1..m], y[1..n]用于存储数据,而x[0], y[0]用做占位(示例放了空格字符,其值无实际含义),表示X、Y子序列为空的情况。
变量解释:
x[i] 表示序列X的第i个元素(i=1..m),而i=0表示空;
y[j] 表示序列Y的第j个元素(j=1..n),而j=0表示空;
c[i][j] 表示序列x[1..i]和序列y[1..j]的Lcs长度。c[0][0] = 0,其实也没有意义;
b[i][j] 表示得到c[i][j]的过程,值为1时,表示x[i] == y[j];值为2时,表示x[i] ≠ y[j]且c[i-1][j] >= c[i][j-1],这样c[i][j] = c[i-1][j];值为3时,表示x[i] ≠ y[j]且c[i-1][j] < c[i][j-1],这样c[i][j] = c[i][j-1];
注释箭头符号说明:
↖ 对应b[i][j] = 1,意味着c[i][j]值源于c[i-1][j-1];
↑ 对应b[i][j] = 2,意味着c[i][j]值源于c[i-1][j];
← 对应b[i][j] = 3,意味着c[i][j]值源于c[i][j-1];
/**
* 动态规划算法求最长公共子序列(LCS)最大值
* @param x 存放序列X, x[1..m]存放了序列X的有效数据, x[0]数据无实际含义, 占位代表序列为空的情况
* @param y 存放序列Y, y[1..n]存放了序列Y的有效数据, y[0]数据无实际含义, 占位代表序列为空的情况
* @param b [out] b[i][j]记录c[i][j]由哪一个子问题的解得到, 在构造最优解(最长公共子序列 LCS)时, 会用到
* @return LCS长度
* @note 时间复杂度O(mn)
*/
int Lcs::lcsLength(string &x, string &y, int **b) {
if (!b || !b[0]) {
cout << "input memory is a null pointer" << endl;
return 0;
}
int m = x.size() - 1;
int n = y.size() - 1;
int res = 0;
// 申请二维数组c[m + 1][n + 1]
auto c = new int*[m + 1];
for (int i = 0; i < m + 1; ++i) {
c[i] = new int[n + 1];
}
// 设置c[i][j]初值
c[0][0] = 0;
for (int i = 1; i <= m; ++i) c[i][0] = 0; // j = 0
for (int j = 1; j <= n; ++j) c[0][j] = 0; // i = 0
// 利用递推式求解c[i][j]
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (x[i] == y[j]) {
c[i][j] = c[i - 1][j - 1] + 1;
b[i][j] = 1;
}
else if (c[i - 1][j] >= c[i][j - 1]) {
c[i][j] = c[i - 1][j];
b[i][j] = 2;
}
else {
c[i][j] = c[i][j - 1];
b[i][j] = 3;
}
}
}
res = c[m][n];
cout << "c[][]: " << endl;
printArray(c, m + 1, n + 1);
cout << "construct c[][] log: " << endl;
printLog(c, b, m + 1, n + 1);
cout << "
b[][]: " << endl;
printArray(b, m + 1, n + 1);
// 释放数组c[][]资源
for (int i = 0; i < m + 1; ++i) {
delete c[i];
}
delete c;
return res;
}
构造最优解LCS
lcsLength只是求解出了最优值LCS长度,但并没有求出最大公共子序列。要求最大公共子序列,可以通过b[m+1][n+1]记录的由c[1][1]推导出c[m][n]的过程,打印出LCS。
/**
* 打印LCS
* @param i c[i][j]所在行位置, c[i][j]代表Xi和Yj的LCS长度
* @param j c[i][j]所在列位置, c[i][j]代表Xi和Yj的LCS长度
* @param x 存放序列X
* @param b[out] b[i][j]记录c[i][j]由哪一个子问题的解得到
* @note 必须在用lcsLength得到b[][]之后调用, 根据b[i][j]记录如何得到c[m][n](LCS长度)的, 反向搜寻路径, 打印LCS
* 时间复杂度O(m+n), 因为每次调用是让i-1或j-1
*/
void Lcs::lcs(int i, int j, string &x, int **b) {
if (i == 0 || j == 0) return ;
if (b[i][j] == 1) {
lcs(i - 1, j - 1, x, b); // ↖
cout << x[i];
}
else if (b[i][j] == 2) {
lcs(i - 1, j, x, b); // ↑
}
else lcs(i, j - 1, x, b); // ←
}
运行结果:
c[][]:
0 0 0 0 0 0 0 0
0 0 1 1 1 1 1 1
0 0 1 1 1 1 1 2
0 0 1 2 2 2 2 2
0 0 1 2 2 2 2 2
0 0 1 2 2 3 3 3
0 0 1 2 2 3 3 3
0 0 1 2 2 3 3 3
0 0 1 2 3 3 4 4
construct c[][] log:
0 0 0 0 0 0 0 0
0 0↑ 1↖ 1← 1← 1← 1← 1←
0 0↑ 1↑ 1↑ 1↑ 1↑ 1↑ 2↖
0 0↑ 1↑ 2↖ 2← 2← 2← 2↑
0 0↑ 1↑ 2↑ 2↑ 2↑ 2↑ 2↑
0 0↑ 1↑ 2↑ 2↑ 3↖ 3← 3←
0 0↑ 1↑ 2↑ 2↑ 3↑ 3↑ 3↑
0 0↑ 1↑ 2↑ 2↑ 3↑ 3↑ 3↑
0 0↑ 1↑ 2↑ 3↖ 3↑ 4↖ 4←
b[][]:
0 0 0 0 0 0 0 0
0 2 1 3 3 3 3 3
0 2 2 2 2 2 2 1
0 2 2 1 3 3 3 2
0 2 2 2 2 2 2 2
0 2 2 2 2 1 3 3
0 2 2 2 2 2 2 2
0 2 2 2 2 2 2 2
0 2 2 2 1 2 1 3
lcs length = 4
3174
如何理解运行结果,特别是c[][]的构建?
下面用表格方式,解释c[][]的构建
序列X和Y,其中x[0], y[0]表示∅
构建c[8+1][7+1],初值c[][0] = 0, c[0][i] = 0
当i=1, j=1,X子序列x[1..i]={3}, y[1..i] = {6} => x[i] ≠ y[j] ,加上c[i-1][j]=0 >= c[i][j-1]=0 => c[i][j] = c[i-1][j]=0;
当i=1, j=2, X子序列x[1..i]={3}, y[1..i] = {6,3} => x[i] = y[j] => c[i][j] = c[i-1][j-1] + 1 = 1;
当i=1, j=3, X子序列x[1..i]={3}, y[1..i] = {6,3,1} => x[i] ≠ y[j],加上c[i-1][j]=0 < c[i][j-1]=1 => c[i][j] = c[i][j-1] = 1;
...
核心步骤是先比较子序列最后一个元素xi和yj,如果相等,则c[i][j] = c[i-1][j-1] + 1 = 1(为上一行斜对角元素+1);如果不等,取前一行和前一列元素值较大者。
而打印LCS的过程,是构建c[][]的相反过程,抓住了LCS元素一定来源于xi == yj的情况。利用b[][]从m, n 位置进行回溯到0, 0位置,中间碰到xi==yj,返回时打印;否则,回到上一行或者前一列(具体取决于b[i][j]值)。
附:完整源代码
// Lcs.h
class Lcs {
public:
static int lcsLength(std::string &x, std::string &y, int **b); // 求序列X,Y的LCS长度
static void lcs(int i, int j, std::string &x, int **b); // 求X, Y的LCS
};
// Lcs.cpp
#include "Lcs.h"
#include <iostream>
using namespace std;
void printArray(int **arr, int m, int n) {
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
cout << arr[i][j];
if (j < n - 1) cout << " ";
else cout << endl;
}
}
}
void printLog(int **arr, int **b, int m, int n, bool printArrow = true) {
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
cout << arr[i][j];
if (printArrow) {
if (b[i][j] == 1) cout << "↖";
else if (b[i][j] == 2) cout << "↑";
else if (b[i][j] == 3) cout << "←";
else cout << " ";
}
if (j < n - 1) cout << " ";
else cout << endl;
}
}
}
/**
* 动态规划算法求最长公共子序列(LCS)最大值
* @param x 存放序列X, x[1..m]存放了序列X的有效数据, x[0]数据无实际含义, 占位代表序列为空的情况
* @param y 存放序列Y, y[1..n]存放了序列Y的有效数据, y[0]数据无实际含义, 占位代表序列为空的情况
* @param b [out] b[i][j]记录c[i][j]由哪一个子问题的解得到, 在构造最优解(最长公共子序列 LCS)时, 会用到
* @return LCS长度
* @note 时间复杂度O(mn)
*/
int Lcs::lcsLength(string &x, string &y, int **b) {
if (!b || !b[0]) {
cout << "input memory is a null pointer" << endl;
return 0;
}
int m = x.size() - 1;
int n = y.size() - 1;
int res = 0;
// 申请二维数组c[m + 1][n + 1]
auto c = new int*[m + 1];
for (int i = 0; i < m + 1; ++i) {
c[i] = new int[n + 1];
}
// 设置c[i][j]初值
c[0][0] = 0;
for (int i = 1; i <= m; ++i) c[i][0] = 0; // j = 0
for (int j = 1; j <= n; ++j) c[0][j] = 0; // i = 0
// 利用递推式求解c[i][j]
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (x[i] == y[j]) {
c[i][j] = c[i - 1][j - 1] + 1;
b[i][j] = 1;
}
else if (c[i - 1][j] >= c[i][j - 1]) {
c[i][j] = c[i - 1][j];
b[i][j] = 2;
}
else {
c[i][j] = c[i][j - 1];
b[i][j] = 3;
}
}
}
res = c[m][n];
cout << "c[][]: " << endl;
printArray(c, m + 1, n + 1);
cout << "construct c[][] log: " << endl;
printLog(c, b, m + 1, n + 1);
cout << "
b[][]: " << endl;
printArray(b, m + 1, n + 1);
// 释放数组c[][]资源
for (int i = 0; i < m + 1; ++i) {
delete c[i];
}
delete c;
return res;
}
/**
* 打印LCS
* @param i c[i][j]所在行位置, c[i][j]代表Xi和Yj的LCS长度
* @param j c[i][j]所在列位置, c[i][j]代表Xi和Yj的LCS长度
* @param x 存放序列X
* @param b[out] b[i][j]记录c[i][j]由哪一个子问题的解得到
* @note 必须在用lcsLength得到b[][]之后调用, 根据b[i][j]记录如何得到c[m][n](LCS长度)的, 反向搜寻路径, 打印LCS
* 时间复杂度O(m+n), 因为每次调用是让i-1或j-1
*/
void Lcs::lcs(int i, int j, string &x, int **b) {
if (i == 0 || j == 0) return ;
if (b[i][j] == 1) {
lcs(i - 1, j - 1, x, b);
cout << x[i];
}
else if (b[i][j] == 2) {
lcs(i - 1, j, x, b);
}
else lcs(i, j - 1, x, b);
}
客户端测试Lcs功能
// TestLcs.cpp
#include "Lcs.h"
#include <iostream>
using namespace std;
int main() {
// 注意程序只使用了x[1..m], y[1..n], 对于x[0]和y[0]只用于占位, 表示序列X和Y为空的情况, 其具体值无实际意义, 这里是设为' '(空格字符)
string x = " 32157954";
string y = " 6314742";
int m = x.size() - 1;
int n = y.size() - 1;
// 申请b[][]资源并初始化为0
int **b = new int *[m + 1];
for (int i = 0; i <= m; ++i) {
b[i] = new int[n + 1];
}
for (int i = 0; i < m + 1; ++i) {
for (int j = 0; j < n + 1; ++j) {
b[i][j] = 0;
}
}
int clen = Lcs::lcsLength(x, y, b);
cout << "lcs length = " << clen << endl;
Lcs::lcs(m, n, x, b);
// 释放b资源
if (b) {
for (int i = 0; i < x.size(); ++i) {
delete b[i];
}
}
return 0;
}
参考
王晓东著《算法设计与分析》
动态规划 最长公共子序列 | CSDN
【动态规划理论】:一篇文章带你彻底搞懂最优子结构、无后效性和重复子问题 | CSDN