动态规划常用于最优化问题。动态规划方法将问题划分为规模较小的子问题,递归求解子问题,并合并起来。如果一个问题的最优解,可以利用其子问题的最优解来解决,那么该问题具有最优子结构,有时可以通过分治法或动态规划求解。
在分治法中,每个子问题只要求解一次。换言之,假定问题可以分为子问题A和B,A又可以分为子问题C和D,那么C和D绝不可能用来参与解决问题B。例如分治法最典型的应用二分查找中,对数组后半部分的查找问题(B)绝不可能分解出这样的子问题来:对数组的1/4~1/2部分进行查找(D)。
动态规划问题与之不同,子问题可能需要求解多次。假设某个问题可以分为子问题A和B,而A又可以分为子问题C和D,那么问题B很有可能分解出子问题D和E来。此时问题D就需要多次求解了。因为动态规划问题具有这样的性质,所以如果每次都对类似于D这样的子问题进行求解,最后消耗的时间往往是指数级的。动态规划算法一般这样做:子问题首次求解完成后,将其存储到一张表中,以后每次需要使用到该问题的结果时,直接从表中查询,而不是再次求解。
装配线调度
制造汽车需要经过装配线的每一个装配站,某公司有两条装配线,各有6个装配站,同一条装配线上的各个装配站,不同装配线上同等位置的装配站,所耗用的时间都各不一样。将未完成的车辆从一条装配线挪到另一条装配线上需要耗费时间也不一样,依赖于其离开的装配站。求解如何在最快的时间内装配出一条车辆。
思路:最快路线的子问题是:经过 $S_{1,6}$ 和 $S_{2,6}$ 的最快路径,而经过 $S_{1,6}$ 的最快路径又可以通过经过 $S_{1,5}$ 和 $S_{2,5}$ 的最快路径来获得;而通过 $S_{2,6}$ 的装配路径也用到了这两个子问题。
为了函数参数尽可能少,我的实现中没有考虑进入和离开装配线所耗用的时间(所以结果也与书中的不一样)。
void fastest_way(int* a1, int* a2, int* t1, int* t2, int len, // 输出 int* f1, int* f2, int* l1, int* l2){ f1[0] = a1[0]; f2[0] = a2[0]; l1[0] = l2[0] = 0; for (int i=1; i<len; i++) { if (f1[i-1]<f2[i-1]+t2[i-1]){ f1[i] = f1[i-1]+a1[i]; l1[i] = 1; } else{ f1[i] = f2[i-1]+t2[i-1]+a1[i]; l1[i] = 2; } if (f2[i-1]<f1[i-1]+t1[i-1]){ f2[i] = f2[i-1]+a2[i]; l2[i] = 2; } else{ f2[i] = f1[i-1]+t1[i-1]+a2[i]; l2[i] = 1; } } }
输出结果:
FastestWayCost:32 Line1,Station6 Line2,Station5 Line2,Station4 Line1,Station3 Line2,Station2 Line2,Station1
练习15.1-5 Canty教授猜测存在某些 $e_{i}$,$a_{i,j}$,$t_{i,j}$ 的值,使得程序FASTEST_WAY在某个装配站 $j$ 上,产生满足 $l_{1}[j]=2$ 且 $l_{2}[j]=1$ 的 $l_{i}[j]$ 值。假设所有移动的代价 $t_{i,j}$ 都是正值。说明Canty教授的猜测是不正确的。
$l_{1}[j]=2$,即到达 $S_{1,j}$ 装配站的最短路径是从 $S_{2,j-1}$ 装配站来的,经过了更换装配线的过程,即:
$$f_{2}[j-1]+t_{2,j-1}<f_{1}[j-1]$$
同理,$l_{2}[j]=1$ 可以推出:
$$f_{2}[j-1]>f_{1}[j-1]+t_{1,j-1}$$
两者矛盾,所以猜测是不正确的。
矩阵链乘法
对给定 $n$ 个需要相乘的矩阵,计算乘积 $A_{1}A_{2}...A_{n}$ 。选择合适的加括号的方式,使需要进行乘法运算的次数最少。两个矩阵$M_{p,q}$和$N_{q,r}$相乘,需要进行的乘法运算次数为 $p\cdot q\cdot r$。
思路:矩阵链相乘 $A_{1}\cdot A_{2}...A_{n}$ 的最小代价,可以被认为是以下这些子问题的最小值:$A_{1}\cdot A_{2...n}$ 的代价加上 $A_{2...n}=A_{2}\cdot A_{3}...A_{n}$ 的代价;$A_{1...2}\cdot A_{3...n}$ 的代价 加上 $A_{1...2}$ 的代价再加上 $A_{3...n}$ 的代价;等等类推。对某个 $p$ 和 $q$,$A_{p...q}$ 的代价可能要反复使用多次,如 $A_{p...q+1}$ 和 $A_{p-1...q+2}$ 都会用到,因此将其存储在一张二维表中,以$p$ 和 $q$ 作为索引值。最终 $A_{1...n}$ 的代价就是所要的结果:最小的运算次数。
我的实现如下,与书中不同的是,下标都是0开始的。
void matrix_chain_order(int* p, int len, // 输出 int(*m)[6], int(*s)[6]){ for (int i=0; i<len; i++){ m[i][i] = 0; } for (int l=2; l<=len; l++){ for (int i=0; i<=len-l; i++){ int mincost = INT_MAX; int minpos = -1; for (int j=i; j<=i+l-2; j++){ int cost = p[i]*p[j+1]*p[i+l]+m[i][j]+m[j+1][i+l-1]; if (cost<mincost){ mincost = cost; minpos = j; } } m[i][i+l-1] = mincost; s[i][i+1-1] = minpos; } } }
输出结果,如下。
0 15750 7875 9375 11875 15125 0 2625 4375 7125 10500 0 750 2500 5375 0 1000 3500 0 5000 0
练习15.2-3 使用替换法证明:
$$P(n)=\left\{\begin{matrix}1...............................n=1\\ \sum_{k=1}^{n-1}P(k)P(n-k)...n\geq 2\end{matrix}\right.$$
的解为 $\Omega(2^{n})$
思路:代换法,假设对于 $i<n$ 满足 $P(i)=\Omega(2^{i})$,代入:
$$P(n)=\sum_{k=0}^{n} \Omega(2^k)\cdot \Omega(2^{n-k})=\sum_{k=0}^{n} \Omega(2^{n})=\Omega(2^{n})$$
练习15.2-4 设 $R(i,j)$ 为matrix_chain_order中计算其他表项时,$m_{i,j}$ 被引用的次数,证明:
$$\sum_{i=1}^{n} \sum_{j=1}^{n} R(i,j)=(n^3-n)/3$$
思路:以考虑长度 $l$ 的变化,则所有表项被引用次数的和为:
$$\sum_{i=1}^{n} \sum_{j=1}^{n} R(i,j)=\sum_{l=2}^{n}(n-l)\cdot l\cdot 2=(n^3-n)/3$$
$n-l$ 是所有长度为l的矩阵链的个数(注意,矩阵的个数为 $n-1$),$l$ 是矩阵链中需要考虑的子问题的个数,$2$ 表示每个子问题需要引用两次其他表项。
练习15.3-3 考虑矩阵链乘法的一个变形,目标是加完全部括号后,标量乘法次数最大,该问题是否具有最优子结构?具有。
备忘录
备忘录是动态规划的一种变形。它仅仅(在第一次遇到的时候)解决哪些用得着的子问题并记录为表项,而不是按照特定顺序解决所有子问题。备忘录是真正递归的方法,逻辑比较清晰。
最长公共子序列
最长公共子序列(LCS)问题。对给定序列 $X={x_{1},x_{2}...x_{m}}$,有另一个序列 $Z={z_{1},z_{2}...z_{k}}$,如果存在存在X的严格递增下标序列 ${i_{1},i_{2}...i_{k}}$ 使得所有 $j=1,2...k$ 都有 $z_{i_{j}}=x_{i_{j}}$,那么 $Z$ 可以称为 $X$ 的子序列。形象地说,序列中去掉零个或多个元素,得到的新序列就称为原序列的子序列。最长公共子序列,顾名思义,就是找到最长的,又同时属于两个序列的子序列。
序列 $X_{1...m}$ 和序列 $Y_{1...n}$ 的LCS,就是:
- 如果$x_{m}=y_{n}$,则为 $X_{1...m-1}$ 和 $Y_{1...n-1}$ 的LCS,加上 $x_{m}$ 得到的序列;
- 如果 $x_{m}\neq y_{n}$,则为 ($X_{1...m-1}$ 和 $Y_{1...n}$ 的LCS) 和 ( $X_{1...m}$ 和 $Y_{1...n-1}$ 的LCS) 之中的较长者。
子问题:$X_{1...i}$ 和序列 $Y_{1...j}$的LCS只有两个维度 $i$ 和 $j$,因此我们将其存储在一张二维表中:
我的实现如下:
void lcs_length(char* x, char* y, int m, int n){ using std::vector; vector<vector<int>> b; vector<vector<int>> c; for (int i=0; i<=m; i++){ vector<int> _b(n+1, -1); vector<int> _c(n+1, -1); b.push_back(_b); c.push_back(_c); } for (int i=0; i<=m; i++){ c[i][0]=0; } for (int j=0; j<=n; j++){ c[0][j]=0; } for (int i=1; i<=m; i++){ for (int j=1; j<=n; j++){ if (x[i-1]==y[j-1]){ c[i][j]=c[i-1][j-1]+1; b[i][j]=2; } else if (c[i-1][j]>=c[i][j-1]){ c[i][j]=c[i-1][j]; b[i][j]=3; } else{ c[i][j]=c[i][j-1]; b[i][j]=1; } } } print_lcs(b, x, m, n); }
结果比较简单,因此没有打印到文件中,将 abcbdab 和 bdcaba 作为参数传入,结果为 bcba。
void print_lcs(std::vector<std::vector<int>> b, char* x, int m, int n){ if (m==0 || n==0){ return; } else{ if (b[m][n]==2){ print_lcs(b, x, m-1, n-1); std::cout<<x[m-1]; } else if (b[m][n]==3){ print_lcs(b, x, m-1, n); } else{ print_lcs(b, x, m, n-1); } } }
练习15.4-3 给出一个运行时间为 $O(mn)$ 的LCS_LENGTH算法的备忘录版本。
思路:构建一个二维数组 $memo_{i,j}$,存储序列 $X_{1...i}$ 和 序列 $Y_{1...j}$ 的LCS。实现如下,输出为 bcba 。
std::string lcs_lenth_memo(std::string x, std::string y, int m, int n, std::vector<std::vector<std::string>> memo) { using namespace std; if (m==-1 || n==-1){ return ""; } else if (memo[m][n] != ""){ return memo[m][n]; } else{ if (x[m]==y[n]){ string tp = lcs_lenth_memo(x,y,m-1,n-1,memo).append(1, x[m]); memo[m][n] = tp; return tp; } else{ string _x = lcs_lenth_memo(x,y,m-1,n,memo); string _y = lcs_lenth_memo(x,y,m,n-1,memo); if (_x.length() >= _y.length()){ memo[m][n] = _x; return _x; } else{ memo[m][n] = _y; return _y; } } } } void lcs_lenth_memo_test() { using namespace std; string x = "abcbdab"; string y = "bdcaba"; vector<vector<string>> memo; for (int i=0; i<x.length(); i++){ vector<string> tp(y.length(), ""); memo.push_back(tp); } cout<<lcs_lenth_memo(x, y, x.length()-1, y.length()-1, memo); getchar(); }
练习15.4-5 给出一个 $O(n^{2})$ 时间的算法,使之找出一个n个数序列中最长的单调递增子序列。
思路:
- 序列 $X_{1...m}$ 的最长递增子序列,是分别以 $x_{1}...x_{m}$ 结尾的 $m$ 个递增子序列中最长的一个。
- 以 $x_{i}$ 结尾的递增子序列,是以下这些序列中最长的一个:
- 只有 $x_{i}$ 的序列,长度为1。
- 以 (序列 $X_{1...i-1}$ 中所有比 $x_{i}$ 小的元素) 结尾 的递增子序列 加上 $x_{i}$ 构成的 $i-1$ 个新序列。
将以 $x_{i}$ 结尾的递增子序列存储在以 $i$ 为索引的一维数组中,便于多次查找。
另一个取巧的思路是:对序列排序,并用排序后的序列与原序列求最长公共子序列。