动态规划是运筹学的一个分支,是一种求解多阶段决策过程最优化问题的数学方法。要搞清楚它,首先需要搞清楚几个基本概念。
简单起见,下面用大家非常熟悉的求斐波拉契数列的过程来说明这几个概念。斐波拉契数列的公式为:
f ( 1 ) = f ( 2 ) = 1 f ( n ) = f ( n − 1 ) + f ( n − 2 ) ( n ≥ 3 ) f(1) = f(2) = 1\\ f(n) = f(n-1) + f(n-2) \quad (n≥3) f(1)=f(2)=1f(n)=f(n−1)+f(n−2)(n≥3)
假设我现在想计算第10个非波那契数,那么计算每一个斐波拉契数的过程就是一个阶段,每一个斐波拉契数就是这个问题的一个状态。按照公式计算就是决策。每一步都按公式算就是策略。状态转移方程为 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n−1)+f(n−2)。求一个新数字只需要之前的两个状态,与怎么得到这个状态值无关,所以具有无后效性。每个阶段的状态即斐波拉契数可以由之前的两个状态得到,所以具有最优子结构。根据公式,求 f ( n ) f(n) f(n) 时需要求 f ( n − 2 ) f(n-2) f(n−2),求 f ( n − 1 ) f(n-1) f(n−1) 时也需要求 f ( n − 2 ) f(n-2) f(n−2),所以有重叠⼦问题。
动态规划就是利用最优子结构,把多阶段决策过程的最优化问题转化为一系列单阶段最优化问题,然后逐个求解子问题。在每一个子问题的求解中,均可以利用了它前面的子问题的最优化结果,依次进行,直到得到最后一个子问题的最优解,也就是整个问题的最优解。
动态规划的流程一般可以分为以下几步:
下面就用一个例子来感受一下动态规划的魅力。
这是前几年很常见的一个问题,网上也有很多人给了解法。典型的利用动态规划来求解。问题描述如下:
一幢100层的大楼,给你2个鸡蛋。假设,若在第n层扔下鸡蛋,鸡蛋不碎,那么在第1层到第(n-1)层扔鸡蛋,鸡蛋都不会碎。两个鸡蛋一模一样,不碎的话可以扔无数次。目标是利用这两个鸡蛋找出临界楼层t,使得鸡蛋在t层扔下不会碎,但在(t+1)层扔下就会碎。现要求回答,最少需要多少次尝试,才能保证在最坏的情况下,找到楼层t。
首先我们分析一下这个问题,很容易得出几个结论:
然后我们按流程用动态规划来解这道题。
划分阶段。
这个很明显,扔一次就是一个阶段。
对各阶段确定状态变量。
很容易想到的是这次扔之前剩的鸡蛋数(设为e)和剩下的还要测试的楼层数(设为n)。
确定决策的开销以及最终的目标函数。
决策就是选择去哪层楼扔鸡蛋。开销就是鸡蛋的减少和楼层数的减少。目标函数就是在现有鸡蛋数e和楼层数n的情况下,最少需要尝试的次数,用st(e,n)来表示。
建立各阶段状态变量的转移过程,确定状态转移方程。
在第i层去扔一次鸡蛋,如果鸡蛋碎了,那么鸡蛋的个数e要减⼀,需要测试的楼层区间从[1, n]变为[1, i-1],共(i-1)层楼,子问题为st(e-1,i-1);如果鸡蛋没碎,那么鸡蛋的个数e保持不变,需要测试的楼层区间从[1, n]变为[i+1, n]共(n-i)层楼,子问题为st(e, n-i)。取这两个子问题中的最大值再加一(因为扔了一次鸡蛋)即为在第i层扔一次鸡蛋后的状态;再对i取所有的情况,即每一层都试一下,取其最小值,即为最少尝试次数st(e,n)。由此得到状态转移方程为:
s t ( e , n ) = min 1 ≤ i ≤ n ( max ( s t ( e − 1 , i − 1 ) , s t ( e , n − i ) ) + 1 ) st(e,n) = \min_{1 \le i \le n}(\max(st(e-1,i-1), \quad st(e, n-i)) + 1) st(e,n)=1≤i≤nmin(max(st(e−1,i−1),st(e,n−i))+1)
根据状态转移方程用代码来实现。
代码如下,用递归实现,在只剩一个蛋或者没有楼层的时候退出,状态转移按照状态转移方程实现,结果res初始化为楼层数加一,因为扔鸡蛋的次数不可能超过楼层数。
int st(int e, int n)
{
int up, down, max, res;
int i;
/* 如果只剩一个蛋了,只能一层一层试 */
if (e == 1)
return n;
/* 如果没有楼层了,不需再扔 */
if (n == 0)
return 0;
/* 状态转移 */
res = n + 1; //初始化为楼层数加一
for (i=1; i<=n; i++) {
up = st(e, n-i);
down = st(e-1, i-1);
max = up > down ? up : down;
if (res > max + 1) {
res = max + 1;
}
}
return res;
}
然后加上main函数如下来打印最终结果。
void main(void)
{
int e = 2;
int n = 100;
printf("%d eggs, %d floors, %d times.\n", e, n, st(e,n));
}
跑这个代码的时候就会发现,很长时间都出不来结果,这是因为递归太多,需要耗费大量的时间和空间。简单的测试发现,在我的这个很弱的电脑上,跑st(2,30),即2个蛋30层楼,就已经需要10秒才能出结果了。
这就是我们之前说到的重叠子问题太多,所以需要记录结果。修改代码如下。代码中给st函数增加了两个参数ptable和N,用来记录所有需要计算的子问题的结果,其中N为总的楼层数,即ptable的列数。封装了一层函数egg_drop,在其中申请ptable所需的内存,这样对主函数的接口可以保持不变。
/*
ptable记录所有需要计算的子问题的结果
N为总的楼层数,即ptable的列数
*/
int st(int e, int n, int *ptable, int N)
{
int up, down, max, res;
int i;
/* 如果只剩一个蛋了,只能一层一层试 */
if (e == 1)
return n;
/* 如果没有楼层了,不需再扔 */
if (n == 0)
return 0;
/* 如果已经计算过,直接返回结果 */
if ( *(ptable+(e-1)*N+(n-1)) != 0)
return *(ptable+(e-1)*N+(n-1));
/* 状态转移 */
res = n + 1; //初始化为楼层数加一
for (i=1; i<=n; i++) {
up = st(e, n-i, ptable, N);
down = st(e-1, i-1, ptable, N);
max = up > down ? up : down;
if (res > max + 1) {
res = max + 1;
}
}
/* 记录子问题的结果 */
*(ptable+(e-1)*N+(n-1)) = res;
return res;
}
int egg_drop(int e, int n)
{
int res;
int *ptable = (int*)malloc(e*n*sizeof(int));
if (ptable == NULL) return;
memset(ptable, 0, e*n*sizeof(int));
res = st(e, n, ptable, n);
free(ptable);
return res;
}
void main(void)
{
int e = 2;
int n = 100;
printf("%d eggs, %d floors, %d times.\n", e, n, egg_drop(e,n));
}
运行这个代码可以瞬间得到结果,对于100层楼2个蛋来说,结果为14。
还是用动态规划,不过这次我们选择不同的状态变量。
划分阶段。
这个很明显,扔一次就是一个阶段。
对各阶段确定状态变量。
我们选择这次扔之前剩的鸡蛋数(设为e)和现存的测试次数(设为m)。
确定决策的开销以及最终的目标函数。
决策就是选择去哪层楼扔鸡蛋。开销就是鸡蛋的减少和测试次数的减少。目标函数就是在现有鸡蛋数e和现有的测试次数m的情况下,最多可以测多少层楼,用st(e,m)来表示。
建立各阶段状态变量的转移过程,确定状态转移方程。
在第i层去扔一次鸡蛋,如果鸡蛋碎了,那么鸡蛋的个数e要减⼀,测试次数m要减一,需要测试i层的楼下,子问题为st(e-1,m-1);如果鸡蛋没碎,那么鸡蛋的个数e保持不变,测试次数m要减一,需要测试i层的楼上,子问题为st(e,m-1)。i层的楼上和楼下再加上自己这一层,就是在这次决策之前的状态。所以得到状态转移方程为:
s t ( e , m ) = s t ( e − 1 , m − 1 ) + s t ( e , m − 1 ) + 1 st(e,m) = st(e-1,m-1) + st(e,m-1) + 1 st(e,m)=st(e−1,m−1)+st(e,m−1)+1
根据状态转移方程用代码来实现。
代码如下,用递归实现,在只剩一个蛋或者没有测试次数的时候退出,状态转移按照状态转移方程实现,子问题的结果保存在ptable中。egg_drop中用一个循环找出刚刚能测出n层的测试次数m,即为需要求解的值。
/*
ptable记录所有需要计算的子问题的结果
N为总的楼层数,即ptable的列数
*/
int st(int e, int m, int *ptable, int N)
{
int res;
/* 如果只剩一个蛋了,只能一层一层试 */
if (e == 1)
return m;
/* 如果没有测试次数了,不能再测试 */
if (m == 0)
return 0;
/* 如果已经计算过,直接返回结果 */
if ( *(ptable+(e-1)*N+m) != 0)
return *(ptable+(e-1)*N+m);
/* 状态转移 */
res = st(e, m-1, ptable, N) + st(e-1, m-1, ptable, N) + 1;
/* 记录子问题的结果 */
*(ptable+(e-1)*N+m) = res;
return res;
}
int egg_drop(int e, int n)
{
int m;
/* m最大不会超过n,申请e*(n+1)空间足够了 */
int *ptable = (int*)malloc(e*(n+1)*sizeof(int));
if (ptable == NULL) return;
memset(ptable, 0, e*(n+1)*sizeof(int));
/* 找出刚刚能测出n层的测试次数m */
m = 0;
while ( st(e, m, ptable, n+1) < n) {
m++;
}
free(ptable);
return m;
}
比较两种方法,都是用动态规划,但选择的状态变量不一样,状态转移方程也就不一样。后一种方式代码实现更简单一些,但是不容易想到。后一种方式可以把递归改写为迭代来实现,代码如下,更加简洁。
int egg_drop(int e, int n)
{
int m;
int i;
/* m最大不会超过n,申请e*(n+1)空间足够了 */
int *ptable = (int*)malloc(e*(n+1)*sizeof(int));
if (ptable == NULL) return;
memset(ptable, 0, e*(n+1)*sizeof(int));
/* 如果只剩一个蛋了,只能一层一层试 */
for (i=0; i<n+1; i++)
*(ptable+i) = i;
/* 找出刚刚能测出n层的测试次数m */
m = 0;
while ( *(ptable+(e-1)*(n+1)+m) < n) {
m++;
for (i=2; i<=e; i++)
*(ptable+(i-1)*(n+1)+m) = *(ptable+(i-1)*(n+1)+(m-1)) + *(ptable+(i-2)*(n+1)+(m-1)) + 1;
}
free(ptable);
return m;
}
动态规划的本质利用最优子结构,把多阶段决策过程转化为一系列单阶段最优化问题,同时利用重叠子问题提高效率。其难点在于确定状态变量和找出状态转移方程。
贪心策略也是把多阶段决策过程转化为一系列单阶段最优化问题,它与动态规划的区别是:贪心策略的每个阶段的最优状态都是由上一个阶段的最优状态得到的,即每一步都选择眼前最优,但到最后不一定是总体最优;而动态规划是所有选择全都要,最终得到总体最优,哪怕眼前这一步不是最优的,结果也要记录着,也许加上下一步就最优了。
无后效性是动态规划所必须的。如果一个问题有后效性,即为了做出下一步决策,我们不仅要知道当前的状态,还要知道当前状态是怎么来的,也就是说历史信息会影响我们以后的决策,那么我们只能用搜索的方法。比如迷宫问题,寻找从迷宫起点到终点的最短路径,每一步决策都要考虑之前所有的历史,就没法用动态规划的方法来解。