动态规划
动态规划(dynamic programming)与分治法类似,都是通过组合子问题的解来求解原问题。但是不同的是:
分治法将问题划分为互不相交的子问题,递归地求解子问题,然后将子问题的解进行组合进而求出原问题的解。
动态规划中,允许子问题之间存在交集,如果使用分治法,则对于交集的求解可能会执行多次,造成重复计算,而动态规划却将交集的解保存到一个表格中,避免了重复计算的开销(自底向上的递归实现)。
动态规划常用来求解最优化问题,目的是寻找一个最优解(可能存在多个最优解),一个动态规划算法通常包含如下3个步骤:
- 确定一个最优解的结构
- 递归地定义最优解的值
- 采用自底向上的方式计算最优解的值(可以避免重复计算的开销)
实例分析:钢条切割
问题描述:将n个单位长度的钢条进行切割,在切割长度必须为整数的情况下,一共有2n-1种切割方案(由于n个单位中间可以有n-1个切割点,在每个点中都有2种选择:切或不切。因此,一共有2n-1种)。假设长度为i的钢条可以获得收益为pi,则如何切割才能使得总收益r最大?
长度i |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
收益pi |
1 |
5 |
8 |
9 |
10 |
17 |
17 |
20 |
24 |
30 |
分析:
假设将钢条切割为k段可以获得最大收益,则有:
n = n1 + n2 + ... + nk
r = p1 + p2 + ... + pk
一般地,总收益r可以如下表示:
r = max(pn, r1 + rn-1, r2 + rn-2, ..., rn-1 + r1),即,钢条可以不切割,或者先将钢条切割为两端:长度为i和n – i,接着递归地求解ri和rn-i。当然我们也可以对长度为i的钢条不再进行第二次切割,那么r的表达式变为如下:
r = max(pn, p1 + rn-1, p2 + rn-2, ..., pn-1 + r1)
接着采用自底向上实现递归求解(关于自顶向下和自底向上两种递归实现的区别见如下递归的两种不同实现方式部分):
动态规划中对于递归的求解采用自底向上求解顺序,对每个子问题只求解一次,并将结果保存下来,而不必重新计算,这是典型的以空间换时间的实例。
1 int CutSteel(int* p_proceeds, const int length) 2 { 3 int revenue[length + 1]; 4 revenue[0] = 0; 5 6 for (int n = 1; n <= length; ++n) 7 { 8 int temp = INT_MIN; 9 for (int i = 1; i <= n; ++i) 10 temp = std::max(temp, p_proceeds[i] + revenue[n - i]); 11 revenue[n] = temp; 12 } 13 14 return revenue[length]; 15 } 16 17 void test_dynamic() 18 { 19 int length = 5; 20 int proceeds[11] = // 不同长度的收益表 21 { 22 0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30 23 }; 24 25 cout << CutSteel(proceeds, length) << endl; // 13 26 }
动态规划的适用性
具备两个特征的问题适合采用动态规划解决:最优子结构、子问题重叠。
- 最优子结构
如果一个问题的最优解包含其子问题的最优解,那么我们就称此问题具备最优子结构的性质。我们可以使用子问题的最优解来构造原问题的最优解。如前所述,长度为n的钢条的最优解可以由切割后的长度为i的钢条和长度为n-i的钢条的最优解求得。
对于不同的问题,最优子结构的不同体现在两个方面:
1. 原问题的最优解涉及几个子问题
2. 在确定最优解使用哪种子问题时,我们需要考虑多少种选择
如上钢条切割问题中,仅仅使用了一个子问题,即长度为n-i的钢条的最优切割。但是我们必须考察i的n个不同的取值中,哪一个会产生最优解。
我们可以使用子问题的总数和每个子问题需要考虑的选择数这两个因子的乘积来大致分析动态规划的时间复杂度,如上钢条切割问题中,共有Θ(n)个子问题,每个子问题最多需要有n种不同的选择,因此时间复杂度为Ο(n2)。
- 重叠子问题
动态规划中对于重叠子问题的求解是一次性的,不需要重复计算。其求解过程主要是利用自底向上的递归解法。
总结
动态规划的步骤可以归纳为两步:
- 将原问题分解为子问题
- 采用自底向上的递归求解子问题(此时一定要注意子问题可能有多个不同的选择)
递归的两种不同实现方式
以斐波那契数列为例:
/*
求斐波那契数列的第n项
思路1:自顶向下(通常我们使用的方式)
*/
double Fibonacci(unsigned int n);
/*
求斐波那契数列的第n项
思路1不足:当n很小时可以,但是当n逐渐增大时,其递归调用深度呈指数增加,
比如n = 40时需要5秒,而n = 50时需要665秒...
这主要是由于递归中的重复计算引起的(递归的两大弊端:1 重复计算 2 递归调用栈溢出):
比如我们想计算fibonacci(5),则需要计算fibonacci(4)和fibonacci(3),而计算fibonacci(4)
时需要计算fibonacci(3)和fibonacci(2),如此fibonacci(3)便被重复计算了一次...。当n增大时,同一项可能被重复计算好多次,造成时间的浪费
思路2:自底向上
为了避免重复计算,我们可以从下向上进行:由fibonacci(1)和fibonacci(0)计算得到
fibonacci(2),由fibonacci(2)和fibonacci(1)计算得到fibonacci(3),由fibonacci(3)和
fibonacci(2)计算得到fibonacci(4),由fibonacci(4)和fibonacci(3)计算得到fibonacci(5)
如此,没有了重复的计算,时间复杂度降为了O(n)
*/
double Fibonacci(unsigned int n, bool flag); // 当flag为true时,使用自底向上计算
1 double Fibonacci(unsigned int n) 2 { 3 if (n == 0) 4 return 0; 5 6 if (n == 1) 7 return 1; 8 9 return Fibonacci(n - 1) + Fibonacci(n - 2); 10 } 11 12 double Fibonacci(unsigned int n, bool flag) 13 { 14 double result; 15 16 if (!flag) 17 result = Fibonacci(n); // 如果flag = false,则调用自顶向下递归解法 18 else 19 { 20 if (n == 0) 21 return 0; 22 23 if (n == 1) 24 return 1; 25 26 double fib_n_minus_1 = 1; // f(n - 1) 27 double fib_n_minus_2 = 0; // f(n - 2) 28 double fib_n = 0; // f(n) 29 30 for (unsigned int i = 2; i <= n; ++i) 31 { 32 fib_n = fib_n_minus_1 + fib_n_minus_2; // f(n) = f(n - 1) + f(n - 2) 33 34 fib_n_minus_2 = fib_n_minus_1; 35 fib_n_minus_1 = fib_n; 36 } 37 38 result = fib_n; 39 } 40 41 return result; 42 }