一、动态规划的基本思想
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。
将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。为了达到此目的,我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。
二、动态规划的基本要素(特征)
1、最优子结构:
当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。
2、重叠子问题:
在用递归算法自顶向下解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,在以后尽可能多地利用这些子问题的解。
三、用动态规划求解问题的主要步骤
1、找出最优解的性质,并刻画其结构特征;
2、递归地定义最优值(写出动态规划方程);
3、以自底向上的方式计算出最优值;
4、根据计算最优值时得到的信息,构造一个最优解。
说明:(1)步骤 1~3 是动态规划算法的基本步骤;
(2)在只需要求出最优值的情形,步骤 4 可以省略;
(3)若需要求出问题的一个最优解,则必须执行步骤 4。
四、动态规划实例
1、矩阵连乘问题
m × n 矩阵 A 与 n × p 矩阵 B 相乘需耗费的时间。我们把 m x n x p 作为两个矩阵相乘所需时间的测量值。
现在假定要计算三个矩阵 A、B 和 C 的乘积,有两种方式计算此乘积。
(1)先用 A 乘以 B 得到矩阵 D,然后 D 乘以 C 得到最终结果,这种乘法的顺序为(AB)C;
(2)先用 B 乘以 C 得到矩阵 E,然后 E 乘以 A 得到最终结果,这种乘法的顺序为 A(BC)。
尽管这两种不同的计算顺序所得的结果相同,但时间消耗会有很大的差距。
实例:
图1.1 A、B 和 C 矩阵
矩阵乘法符合结合律,所以在计算 ABC 矩阵连乘时,有两种方案,即 (AB)C 和 A(BC)。
对于第一方案(AB)C 和,计算:
图1.2 AB 矩阵相乘
其乘法运算次数为:2 × 3 × 2 = 12
图1.3 (AB)C 矩阵连乘
其乘法运算次数为:2 × 2 × 4 = 16
总计算量为:12 + 16 = 28
对第二方案 A(BC),计算:
图1.4 BC 矩阵相乘
其乘法运算次数为:3 × 2 × 4 = 24
图1.5 A、B 和 C 矩阵连乘
其乘法运算次数为:2 × 3 × 4 = 24
总计算量为:24 + 24 = 48
可见,不同方案的乘法运算量可能相差很悬殊。
问题定义:
给定 n 个矩阵 {A1, A2, …, An},其中 Ai 与 Ai+1 是可乘的,i = 1,2,…,n-1。考察这 n 个矩阵的连乘积 A1A2…An。 由于矩阵乘法满足结合律,所以计算矩阵的连乘可以有许多不同的计算次序。
这种计算次序可以用加括号的方式来确定。完全加括号的矩阵连乘积可递归地定义为:
(1)单个矩阵是完全加括号的;
(2)矩阵连乘积 A 是完全加括号的,则 A 可表示为 2 个完全加括号的矩阵连乘积 B 和 C 的乘积并加括号,即 A = (BC)。设有四个矩阵 A, B, C, D,总共有五种完全加括号的方式: (A((BC)D)) , (A(B(CD))) , ((AB)(CD)) , (((AB)C)D) , ((A(BC)D))。
a. 找出最优解的性质,并刻画其结构特征;
将矩阵连乘积 AiAi+1…Aj ,简记为 A[i : j], 这里 i≤j;考察计算 A[1:n] 的最优计算次序。
设这个计算次序在矩阵 Ak 和 Ak+1 之间将矩阵链断开,1 ≤ k < n,则其相应完全加括号方式为 (A1A2…Ak)(Ak+1Ak+2…An)。
总计算量 = A[1:k] 的计算量 + A[k+1:n] 的计算量 + A[1:k] 和 A[k+1:n]相乘的计算量
特征:计算 A[1:n] 的最优次序所包含的计算矩阵子链 A[1:k] 和 A[k+1:n] 的次序也是最优的。
b. 递归地定义最优值(写出动态规划方程);
图1.6 建立递归关系
c. 以自底向上的方式计算出最优值。
1 #include2 using namespace std; 3 4 #define NUM 51 5 int p[NUM]; //矩阵维数 P0 x P1,P1 x P2,P2 x P3,...,P5 x P6 6 int m[NUM][NUM]; //最少乘次数 / 最优值数组 7 int s[NUM][NUM]; //最优断开位置 8 9 void MatrixChain(int n) 10 { 11 for (int i = 1; i <= n; i++) m[i][i] = 0; 12 13 for (int r = 2; r <= n; r++) //矩阵个数 14 for (int i = 1; i <= n - r+1; i++) //起始 15 { 16 int j=i+r-1; //结尾 17 m[i][j] = m[i+1][j] + p[i-1]*p[i]*p[j]; //计算初值,从i处断开,计算最优断开位置 18 s[i][j] = i; 19 for (int k = i+1; k < j; k++) 20 { 21 int t = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j]; 22 if (t < m[i][j]) { m[i][j] = t; s[i][j] = k;} 23 } 24 } 25 } 26 27 void TraceBack(int i, int j) 28 { 29 if(i==j) 30 cout<< "A" << i; 31 else 32 { 33 cout << "("; 34 TraceBack(i, s[i][j]); 35 TraceBack(s[i][j]+1, j); 36 cout << ")"; 37 } 38 } 39 40 int main() 41 { 42 int n; 43 cin >> n; //矩阵的个数 44 int temp; 45 for(int i = 0; i < n; i++) 46 cin >> p[i] >> temp; //矩阵的维数 47 p[n] = temp; 48 MatrixChain(n); 49 cout << m[1][n] << endl; //最少乘次数 50 TraceBack(1, n); //按照最优断开位置列出乘法顺序 51 return 0; 52 }
2、矩阵连乘之备忘录方法
备忘录方法是动态规划算法的变形。与动态规划算法一样,备忘录方法用一个表格保存已解决的子问题的答案,再碰到该子问题时,只要简单地查看该子问题的解答,而不必重新求解。备忘录方法的控制结构与直接递归方法的控制结构相同,区别仅在于备忘录方法为每个解过的子问题建立了备忘录以备需要时查看,避免了相同子问题的重复求解。
1 #include2 using namespace std; 3 4 #define NUM 51 5 int p[NUM]; //矩阵维数 P0 x P1,P1 x P2,P2 x P3,...,P5 x P6 6 int m[NUM][NUM]; //最少乘次数 / 最优值数组 7 int s[NUM][NUM]; //最优断开位置 8 9 int LookupChain(int i, int j) 10 { 11 if(m[i][j] > 0) return m[i][j]; 12 if(i == j) return 0; 13 int u = LookupChain(i, i) + LookupChain(i+1, j) + p[i-1]*p[i]*p[j]; 14 s[i][j] = i; 15 for(int k = i+1; k < j; k++) 16 { 17 int t = LookupChain(i, k) + LookupChain(k+1, j) + p[i-1]*p[k]*p[j]; 18 if(t < u) {u = t; s[i][j] = k;} 19 } 20 m[i][j] = u; 21 return u; 22 } 23 24 int MemoizedMatrixChain(int n) 25 { 26 for(int i = 1; i <= n; i++) 27 for(int j = i; j <= n; j++) m[i][j] = 0; 28 return LookupChain(1, n); 29 30 } 31 32 void TraceBack(int i, int j) 33 { 34 if(i==j) 35 cout<< "A" << i; 36 else 37 { 38 cout << "("; 39 TraceBack(i, s[i][j]); 40 TraceBack(s[i][j]+1, j); 41 cout << ")"; 42 } 43 } 44 45 int main() 46 { 47 int n; 48 cin >> n; //矩阵的个数 49 int temp; 50 for(int i = 0; i < n; i++) 51 cin >> p[i] >> temp; //矩阵的维数 52 p[n] = temp; 53 MemoizedMatrixChain(n); 54 cout << m[1][n] << endl; //最少乘次数 55 TraceBack(1, n); //按照最优断开位置列出乘法顺序 56 return 0; 57 }
动态规划与备忘录方法比较:
(1)相同点
这两种算法都利用了子问题重叠性质。对每个子问题,两种方法都只解一次,并记录答案。再次遇到该子问题时,不重新求解而简单地取用已得到的答案,节省了计算量,提高了算法的效率。
(2)不同点
动态规划是自底向上的方式计算;备忘录是自顶向下的方式计算。
当一个问题的所有子问题都至少要解一次时,用动态规划算法比用备忘录方法好;当子问题中的部分子问题可不必求解时,用备忘录方法则较有利,因为从其控制结构可以看出,该方法只解那些需要求解的子问题。
3、最长公共子序列
最长公共子序列的结构:
设序列 X = {x1,x2,…,xm} 和 Y = {y1,y2,…,yn} 的最长公共子序列为 Z = {z1,z2,…,zk},则
a. 若 xm = yn,则 zk = xm = yn,且 zk-1 是 xm-1 和 yn-1 的最长公共子序列;
b. 若 xm ≠ yn 且 zk ≠ xm,则 Z 是 xm-1 和 Y 的最长公共子序列;
c. 若 xm ≠ yn 且 zk ≠ yn,则 Z 是 X 和 yn-1 的最长公共子序列。
总结:(1)两个序列的最长公共子序列包含了这两个序列的前缀的最长公共子序列;
(2)最长公共子序列问题具有最优子结构性质。
子问题的递归结构:
由最长公共子序列问题的最优子结构性质可知,要找出 X 和 Y 的最长公共子序列,可按以下方式递归地进行:
a. 当 xm = yn 时,找出 Xm-1 和 Yn-1 的最长公共子序列,然后在其尾部加上 xm(=yn)即可得 X 和 Y 的一个最长公共子序列;
b. 当 xm ≠ yn 时,必须解两个子问题,即找出 Xm-1 和 Y 的一个最长公共子序列及 X 和 Yn-1 的一个最长公共子序列。
这两个公共子序列中较长者为 X 和 Y 的一个最长公共子序列。
用 c[i][j] 记录序列 Xi 和 Yj 的最长公共子序列的长度。Xi = {x1,x2,…,xi},Yj = {y1,y2,…,yj}。
当 i = 0 或 j = 0 时,空序列是 Xi 和 Yj 的最长公共子序列,故此时 C[i][j] = 0。其它情况下,由最优子结构性质可建立递归关系如下:
图3.1 由最优子结构性质建立递归关系
例如:X = {A,B,C,B,D,A,B},Y = {B,D,C,A,B,A}。
最长公共子序列为 4,即{B,C,B,A}。
1 #include2 using namespace std; 3 4 #define NUM 100 5 int c[NUM][NUM]; //最长公共子序列中的字母个数 6 int b[NUM][NUM]; //存放方向编号 7 8 void LCSLength(int m, int n, char x[],char y[]) 9 { //数组c的第0行、第0列置0 10 for (int i = 1; i <= m; i++) c[i][0] = 0; 11 for (int i = 1; i <= n; i++) c[0][i] = 0; 12 13 //根据递推公式构造数组c 14 for(int i = 1; i <= m; i++) 15 for(int j = 1; j <= n; j++) 16 { 17 if(x[i] == y[j]) 18 {c[i][j] = c[i-1][j-1] + 1; b[i][j] = 1;} // ↖ 19 else if(c[i-1][j] >= c[i][j-1]) 20 {c[i][j] = c[i-1][j]; b[i][j] = 2;} // ↑ 21 else 22 {c[i][j] = c[i][j-1]; b[i][j] = 3;} // ← 23 } 24 } 25 26 void LCS(int i, int j, char x[]) 27 { 28 if(i == 0 || j == 0) return; 29 if(b[i][j] == 1) {LCS(i-1, j-1, x); cout << x[i];} 30 else if(b[i][j] == 2) LCS(i-1, j, x); 31 else LCS(i, j-1, x); 32 } 33 34 int main() 35 { 36 char x[NUM]; 37 char y[NUM]; 38 int m, n; 39 cin >> m; 40 for(int i = 1; i <= m; i++) 41 cin >> x[i]; 42 cin >> n; 43 for(int i = 1; i <= n; i++) 44 cin >> y[i]; 45 LCSLength(m, n, x, y); 46 cout << c[m][n] << endl; 47 LCS(m, n, x); 48 return 0; 49 }
4、最大子段和
给定由 n 个整数(包含负整数)组成的序列 a1,a2,...,an,求该序列子段和的最大值。
当所有整数均为负值时定义其最大子段和为 0。所求的最优值为:
图4.1 最大子段和公式
例如:
当(a1,a2, ……a7,a8)= (1,-3,7,8,-4,12, -10,6)时,最大子段和为:
图4.2 最大子段和实例
(1)最大子段和分治算法
所给的序列 a[1:n] 分为长度相等的两段 a[1:n/2] 和 a[n/2+1:n] ,分别求出这两段的最大子段和,则 a[1:n] 的最大子段和有三种情形:
Ⅰ. a[1:n] 的最大子段和与 a[1:n/2] 的最大子段和相同;
Ⅱ. a[1:n] 的最大子段和与 a[n/2+1:n] 的最大子段和相同;
Ⅲ. a[1:n] 的最大子段和为 ai+…+aj,且 1 ≤ i ≤ n/2,n/2+1 ≤ j ≤ n。
1 #include2 using namespace std; 3 4 #define NUM 50 5 6 int MaxSubSum(int *a, int left, int right) 7 { 8 int sum = 0; 9 if(left == right) sum = a[left] > 0 ? a[left] : 0; 10 else 11 { 12 int center = (left + right) / 2; 13 int leftsum = MaxSubSum(a, left, center); 14 int rightsum = MaxSubSum(a, center+1, right); 15 16 int s1 = 0; 17 int lefts = 0; 18 for(int i = center; i >= left; i--) 19 { 20 lefts += a[i]; 21 if(lefts > s1) 22 s1 = lefts; 23 } 24 25 int s2 = 0; 26 int rights = 0; 27 for(int i = center+1; i <= right; i++) 28 { 29 rights += a[i]; 30 if(rights > s2) 31 s2 = rights; 32 } 33 34 sum = s1 + s2; 35 if(sum < leftsum) sum = leftsum; 36 if(sum < rightsum) sum = rightsum; 37 } 38 return sum; 39 } 40 41 int MaxSum(int n, int *a) 42 { 43 return MaxSubSum(a, 1, n); 44 } 45 46 int main() 47 { 48 int a[NUM] = {0}; 49 int n; 50 cin >> n; 51 for(int i = 1; i <= n; i++) 52 cin >> a[i]; 53 cout<< MaxSum(n, a) <
(2)最大子段和动态规划算法
由bj的定义易知,当 bj-1 > 0 时 bj = bj-1 + aj,否则 bj = aj。则计算 bj 的动态规划递归式:bj = max{bj-1+aj, aj},1 ≤ j ≤ n。
1 #include2 using namespace std; 3 4 #define NUM 50 5 6 int MaxSum(int n, int *a) 7 { 8 int sum = 0, b = 0; 9 for(int i = 1; i <= n; i++) 10 { 11 if(b > 0) b += a[i]; 12 else b = a[i]; 13 if(b > sum) sum = b; 14 } 15 return sum; 16 } 17 18 int main() 19 { 20 int a[NUM] = {0}; 21 int n; 22 cin >> n; 23 for(int i = 1; i <= n; i++) 24 cin >> a[i]; 25 cout<< MaxSum(n, a) <
5、最长单调递增子序列
例如:
a[] = {1,6,2,4,3}。
则最长单调递增子序列为 3,即 {1,2,3}。
1 #include2 using namespace std; 3 4 #define NUM 50 5 6 int LIS(int n, int *a) 7 { 8 int b[NUM] = {0}; //每一步最长单调递增子序列 9 b[0] = 1; 10 int max = 0; 11 for(int i = 1; i < n; i++) 12 { 13 int k = 0; 14 for(int j = 0; j < i; j++) { 15 if(a[j] <= a[i] && k < b[j]) { 16 k = b[j]; 17 } 18 b[i] = k+1; 19 } 20 21 if(max < b[i]) max = b[i]; 22 } 23 return max; 24 } 25 26 int main() 27 { 28 int a[NUM]; 29 int n; 30 cin >> n; 31 for(int i = 0; i < n; i++) 32 cin >> a[i]; 33 int ans = LIS(n, a); 34 cout << ans <
6、0-1背包
给定一个物品集合 s ={1,2,3,…,n},物品i的重量是 wi,其价值是 vi,背包的容量为 W,即最大载重量不超过 W。在限定的总重量 W 内,我们如何选择物品,才能使得物品的总价值最大。
注意:(1)如果物品不能被分割,即物品i要么整个地选取,要么不选取;
(2)不能将物品 i 装入背包多次,也不能只装入部分物品 i,则该问题称为 0—1 背包问题;
(3)如果物品可以拆分,则问题称为背包问题,适合使用贪心算法。
例如:
1 #include2 using namespace std; 3 4 #define NUM 50 5 6 void Knapsack(int v[], int w[], int c, int n, int m[][10]) 7 { 8 int jMax = min(w[n]-1,c); //背包剩余容量上限,范围[0..w[n]-1] 9 for(int j = 0; j <= jMax; j++) m[n][j] = 0; 10 for(int j = w[n]; j <= c; j++) m[n][j] = v[n]; //限制范围[w[n]~c] 11 12 for(int i = n-1; i > 1; i--) 13 { 14 jMax = min(w[i]-1, c); 15 for(int j = 0; j <= jMax; j++)//背包不同剩余容量 j <= jMax < c 16 m[i][j] = m[i+1][j];//没产生任何效益 17 18 for(int j = w[i]; j <= c; j++) //背包不同剩余容量 j-wi > c 19 m[i][j] = max(m[i+1][j], m[i+1][j-w[i]]+v[i]); //价值增加vi 20 } 21 m[1][c] = m[2][c]; 22 if(c >= w[1]) m[1][c] = max(m[1][c], m[2][c-w[1]]+v[1]); 23 } 24 25 void Traceback(int m[][10], int w[], int c, int n, int x[]) 26 { 27 for(int i=1; i