动态规划

动态规划

动态规划是运筹学的一个分支,是一种求解多阶段决策过程最优化问题的数学方法。要搞清楚它,首先需要搞清楚几个基本概念。

  • 阶段:整个决策过程可以按某个(空间、时间或其它)标准分为多个步骤,每一步称为一个阶段。比如下棋,走一步就可以认为是一个阶段。
  • 状态:状态表示在每个阶段我们关注的决策相关的影响因素的情况。比如下棋到某一步时,此刻棋盘上所有棋子的位置就是此阶段的状态。状态通常可以用一个或多个变量来描述。
  • 决策:一个阶段的状态给定以后,从该状态演变到下一阶段某个状态的一种选择行动方法称为决策。比如下棋到某一步时,决定下一步怎么走,就是一步决策,也叫单阶段决策。决策通常也可以用变量来描述。
  • 策略:由每个阶段的决策组成的序列称为策略,也就是多阶段决策。比如下棋,从开始下到结束的每一步走法构成了一个策略。策略可能有很多种,其中达到最优效果的策略称为最优策略
  • 状态转移方程:从一个阶段到下一个阶段的状态变化,称为状态转移,如果这个变化可以用方程来描述,则称之为状态转移方程。
  • 无后效性:说的是一旦某个阶段的状态确定,则此后过程的演变不再受此前各种状态及决策的影响。也就是说历史信息不会影响我们以后的决策。比如下棋,现在已经有一个棋面,有可能是随机摆成的,也可能是认真下成这样的,但是不管怎样这不影响我们去决定下一步应该怎么下。当前的棋面是唯一影响未来的东西。
  • 最优⼦结构:如果每个阶段的最优状态可以从之前某个或某些阶段的某个或某些状态直接得到,也就是说一个问题的最优解可以由其子问题的最优解来得到,我们称其具有最优子结构。
  • 重叠⼦问题:在求解一个问题时需要先求解其子问题,子问题又有子问题,若在求解过程中需要重复求解某些子问题,这些子问题称为重叠子问题。对于重叠子问题不需要重复求解,只需要求解一次,然后记录下来,以后每次查询就可以了,可以大大降低运行时间,即用空间换时间。

简单起见,下面用大家非常熟悉的求斐波拉契数列的过程来说明这几个概念。斐波拉契数列的公式为:
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(n1)+f(n2)(n3)

假设我现在想计算第10个非波那契数,那么计算每一个斐波拉契数的过程就是一个阶段,每一个斐波拉契数就是这个问题的一个状态。按照公式计算就是决策。每一步都按公式算就是策略状态转移方程 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n1)+f(n2)。求一个新数字只需要之前的两个状态,与怎么得到这个状态值无关,所以具有无后效性。每个阶段的状态即斐波拉契数可以由之前的两个状态得到,所以具有最优子结构。根据公式,求 f ( n ) f(n) f(n) 时需要求 f ( n − 2 ) f(n-2) f(n2),求 f ( n − 1 ) f(n-1) f(n1) 时也需要求 f ( n − 2 ) f(n-2) f(n2),所以有重叠⼦问题

动态规划就是利用最优子结构,把多阶段决策过程的最优化问题转化为一系列单阶段最优化问题,然后逐个求解子问题。在每一个子问题的求解中,均可以利用了它前面的子问题的最优化结果,依次进行,直到得到最后一个子问题的最优解,也就是整个问题的最优解。

动态规划的流程一般可以分为以下几步:

  1. 对决策过程划分阶段。
  2. 对各阶段确定状态变量。
  3. 根据状态变量确定每个决策的开销以及最终的目标函数。
  4. 建立各阶段状态变量的转移过程,确定状态转移方程。
  5. 根据状态转移方程用代码来实现,一般可以用递归,注意对重叠子问题要记录其解。

下面就用一个例子来感受一下动态规划的魅力。

高楼扔鸡蛋问题

这是前几年很常见的一个问题,网上也有很多人给了解法。典型的利用动态规划来求解。问题描述如下:

一幢100层的大楼,给你2个鸡蛋。假设,若在第n层扔下鸡蛋,鸡蛋不碎,那么在第1层到第(n-1)层扔鸡蛋,鸡蛋都不会碎。两个鸡蛋一模一样,不碎的话可以扔无数次。目标是利用这两个鸡蛋找出临界楼层t,使得鸡蛋在t层扔下不会碎,但在(t+1)层扔下就会碎。现要求回答,最少需要多少次尝试,才能保证在最坏的情况下,找到楼层t。

首先我们分析一下这个问题,很容易得出几个结论:

  • 无论在哪层楼扔鸡蛋,鸡蛋要么碎要么不碎,碎了的话意味着临界楼层在该层楼下,没碎的话就是在该层楼上。
  • 扔一次鸡蛋之后,因为需要测试的楼层数变少,问题就变成了一个子问题。即该问题具有最优子结构。
  • 如果只剩一个鸡蛋了,为了保证一定能测出来,只能在需要测试的范围内从低到高一层一层试。即最坏情况下,剩多少层,就要扔多少次。

然后我们按流程用动态规划来解这道题。

  1. 划分阶段。
    这个很明显,扔一次就是一个阶段。

  2. 对各阶段确定状态变量。
    很容易想到的是这次扔之前剩的鸡蛋数(设为e)和剩下的还要测试的楼层数(设为n)。

  3. 确定决策的开销以及最终的目标函数。
    决策就是选择去哪层楼扔鸡蛋。开销就是鸡蛋的减少和楼层数的减少。目标函数就是在现有鸡蛋数e和楼层数n的情况下,最少需要尝试的次数,用st(e,n)来表示。

  4. 建立各阶段状态变量的转移过程,确定状态转移方程。
    在第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)=1inmin(max(st(e1,i1),st(e,ni))+1)

  5. 根据状态转移方程用代码来实现。
    代码如下,用递归实现,在只剩一个蛋或者没有楼层的时候退出,状态转移按照状态转移方程实现,结果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。

另一种解法

还是用动态规划,不过这次我们选择不同的状态变量。

  1. 划分阶段。
    这个很明显,扔一次就是一个阶段。

  2. 对各阶段确定状态变量。
    我们选择这次扔之前剩的鸡蛋数(设为e)和现存的测试次数(设为m)。

  3. 确定决策的开销以及最终的目标函数。
    决策就是选择去哪层楼扔鸡蛋。开销就是鸡蛋的减少和测试次数的减少。目标函数就是在现有鸡蛋数e和现有的测试次数m的情况下,最多可以测多少层楼,用st(e,m)来表示。

  4. 建立各阶段状态变量的转移过程,确定状态转移方程。
    在第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(e1,m1)+st(e,m1)+1

  5. 根据状态转移方程用代码来实现。
    代码如下,用递归实现,在只剩一个蛋或者没有测试次数的时候退出,状态转移按照状态转移方程实现,子问题的结果保存在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;
}

写在最后

动态规划的本质利用最优子结构,把多阶段决策过程转化为一系列单阶段最优化问题,同时利用重叠子问题提高效率。其难点在于确定状态变量和找出状态转移方程。

贪心策略也是把多阶段决策过程转化为一系列单阶段最优化问题,它与动态规划的区别是:贪心策略的每个阶段的最优状态都是由上一个阶段的最优状态得到的,即每一步都选择眼前最优,但到最后不一定是总体最优;而动态规划是所有选择全都要,最终得到总体最优,哪怕眼前这一步不是最优的,结果也要记录着,也许加上下一步就最优了。

无后效性是动态规划所必须的。如果一个问题有后效性,即为了做出下一步决策,我们不仅要知道当前的状态,还要知道当前状态是怎么来的,也就是说历史信息会影响我们以后的决策,那么我们只能用搜索的方法。比如迷宫问题,寻找从迷宫起点到终点的最短路径,每一步决策都要考虑之前所有的历史,就没法用动态规划的方法来解。

你可能感兴趣的:(数据结构与算法)