动态规划是著名的程序设计思想,也是出了名的难。难点在于思维,而不在于实现,往往代码很简单,但很难想到。要真正掌握动态规划,老老实实刷题吧。(动态规划不是看会的,需要理论加上大量实践)。
介绍
动态规划(dynamic programming),属于运筹学的一个分支,是解决多阶段决策过程最优化的一个方法。该方法是由美国数学家贝尔曼(R.E.Bellman)等人在20世纪50年代初提出。
Bellman 在1957年出版了《Dynamic Programming》一书,是动态规划领域的第一本著作。
动态规划解决问题的特点是:它可以把一个 n 维决策变换为几个一维最优化问题,从而一个一个地去解决。需要指出的是,动态规划不是求解某类问题的一种方法,而是考察问题的一种途径,而不是一种算法。必须对具体问题进行具体分析,运用动态规划的原理和方法,建立相应的模型,然后再用动态规划方法去求解。
这种“分而治之,逐步调整”的方法,在一些比较难以解决的复杂问题中已经显示出优越性。经过半个世纪的发展,动态规划解决问题的方法已经广泛运用于经济、管理、军事、生物、工程等诸多领域,并取得了很好的效果。
多阶段决策问题
所谓的多阶段决策问题是动态决策问题的一种特殊形式;在多阶段决策过程中,系统的动态过程可以按照时间进程分为状态相互联系而又相互区别的各个阶段:每个阶段都要进行决策,目的是使整个过程的决策达到最优效果。
下面列举一些常见的多阶段决策问题:
- 最短路问题
- 资源分配问题
- 生产调度问题
- 设备更新问题
- 库存问题
- 背包问题
例01:第n个斐波那契数列数
斐波那契数列是个很神奇的数列,蕴含着很多大自然的规律,加上本身和黄金分割率有密切关系(看通项公式就知道了),因此也被称为黄金分割数列。它的递推公式是 a n = a n − 1 + a n − 2 , n > 2 a_n=a_{n-1}+a_{n-2},n>2 an=an−1+an−2,n>2,且 a 1 = 1 , a 2 = 1 a_1=1,a_2=1 a1=1,a2=1。进而可以得到 a 3 = a 2 + a 1 = 1 + 1 = 2 , a 4 = a 3 + a 2 = 2 + 1 = 3 a_3=a_2+a_1=1+1=2,a_4=a_3+a_2=2+1=3 a3=a2+a1=1+1=2,a4=a3+a2=2+1=3,再之后是5(3+2)、8(5+3)、13(8+5),依此类推。
第n个斐波那契数列的解法很容易用递归简洁但低效的给出:
int fib(int n) {
return (n < 2) ? 1 : fib(n-1) + fib(n-2);
}
如果不使用三目运算符,用 if
实现同样很简单:
int fib_if(int n) {
if (n < 2) {
return 1;
}
return fib_if(n-1) + fib_if(n-2);
}
可以看到,递归的写法和递推公式非常相似。然而,如果实际运行代码,会发现上面这种写法非常慢,甚至连第50个斐波那契数都要花费特别长的时间。我们把第5个斐波那契的递归调用过程画出来(图1)。
(图1)
大概就会发现为什么递归显得如此低效了。原因无非是重复计算,至少可以看到计算第5个斐波那契数时,第三个斐波那契数求解了2次,第二个斐波那契数求解了3次,第一个斐波那契数求解了2次。加上调用函数的开销,整个算法执行过程非常低效。随着求解的斐波那契数越大,这棵树的节点数列呈现爆炸式增长(指数)。(证明过程略过,非重点)
很自然一个想法就是能不能存起来,如果我建立一个数组,把之前计算得到的斐波那契数存起来,计算过的数直接调出来,不再重复计算。好了,这就是传说中的动态规划了。
const int kMaxN = 100;
typedef long long ll;
ll mem_fib(int n) {
static ll mem_[kMaxN] = {
1, 1};
if (n < 3) {
return mem_[n];
} else {
return (mem_[n] != 0) ? mem_[n] : (mem_[n] = mem_fib(n-1) + mem_fib(n-2));
}
}
因为斐波那契数列呈现指数式增长,int
有点小(long long
其实也无法存下那么大的(比如,第100个斐波那契数已经越界了)的斐波那契数,但这只是简单演示,就不管了)。这个就已经是传说中的“备忘录”了,也就是传说中的动态规划最简单的方式。如果没有计算过该斐波那契数,那就计算,看起来好像也是递归,其实整个过程中,每个斐波那契数最多计算一次。
说到底,我们用了一个经典原则:牺牲空间效率换取了时间效率,成功地把计算复杂度降到了 O ( n ) O(n) O(n)(计算n个斐波那契数的效率,平摊下来,每个是 O ( 1 ) O(1) O(1))。不过,付出了 O ( n ) O(n) O(n) 的空间复杂度。如果你愿意再降低一下时间效率,比如每次计算降到 O ( n 2 ) O(n^2) O(n2)(计算n个斐波那契数的效率,平摊下来,每个是 O ( n ) O(n) O(n)),那么空间效率可以提高到 O ( 1 ) O(1) O(1)。这个过程很多人应该也很熟悉,就是用两个变量,自底而上计算斐波那契数。
int fib_c(int n) {
int f = 1, g = 1;
if (n < 2) {
return 1;
}
for (int i = 1; i < n; i++) {
g = f + g; // a_n
f = g - f; // a_n - a_{n-2} = a_{n-1}
}
return g;
}
*数组从0开开始,这里 f ( n ) f(n) f(n),实际上计算的是 f ( n + 1 ) f(n+1) f(n+1)。
当然,还有把每次计算从 O ( n ) O(n) O(n) 提高到 O ( l o g n