9.1 数字三角形
动态规划是一种用途很广的问题求解方法,它本身并不是一个特定的算法,而是一种思想,一种手段。下面通过一个题目阐述动态规划的基本思路和特点。
9.1.1 问题描述与状态定义
数字三角形问题。有一个由非负整数组成的三角形,第一行只有一个数,除了最下行之外每个数的左下方和右下方各有一个数,如图9-1所示。
| ![]() |
| (a)数字三角形 | (b)格子编号 |
图9-1 数字三角形问题
从第一行的数开始,每次可以往左下或右下走一格,直到走到最下行,把沿途经过的数全部加起来。如何走才能使得这个和尽量大?
【分析】
如果熟悉回溯法,可能会立刻发现这是一个动态的决策问题:每次有两种选择——左下或右下。如果用回溯法求出所有可能的路线,就可以从中选出最优路线。但和往常一样,回溯法的效率太低:一个n层数字三角形的完整路线有2n-1条,当n很大时回溯法的速度将让人无法忍受。
为了得到高效的算法,需要用抽象的方法思考问题:把当前的位置(i, j)看成一个状态(还记得吗?),然后定义状态(i, j)的指标函数d(i, j)为从格子(i, j)出发时能得到的最大和(包括格子(i, j)本身的值)。在这个状态定义下,原问题的解是d(1, 1)。
下面看看不同状态之间是如何转移的。从格子(i, j)出发有两种决策。如果往左走,则走到(i+1, j)后需要求“从(i+1, j)出发后能得到的最大和”这一问题,即d(i+1, j)。类似地,往右走之后需要求解d(i+1, j+1)。由于可以在这两个决策中自由选择,所以应选择d(i+1,j)和d(i+1,j+1)中较大的一个。换句话说,得到了所谓的状态转移方程:
如果往左走,那么最好情况等于(i, j)格子里的值a(i, j)与“从(i+1, j)出发的最大总和”之和,此时需注意这里的“最大”二字。如果连“从(i+1,j)出发走到底部”这部分的和都不是最大的,加上a(i, j)之后肯定也不是最大的。这个性质称为最优子结构(optimal substructure),也可以描述成“全局最优解包含局部最优解”。不管怎样,状态和状态转移方程一起完整地描述了具体的算法。
提示9-1:动态规划的核心是状态和状态转移方程。
9.1.2 记忆化搜索与递推
有了状态转移方程之后,应怎样计算呢?
方法1:递归计算。程序如下(需注意边界处理):
int solve(int i, int j){
return a[i][j] + (i == n ? 0 : max(solve(i+1,j),solve(i+1,j+1)));
}
这样做是正确的,但时间效率太低,其原因在于重复计算。
图9-2 重叠子问题
如图9-2所示为函数solve(1, 1)对应的调用关系树。看到了吗?solve(3, 2)被计算了两次(一次是solve(2, 1)需要的,一次是solve(2, 2)需要的)。也许读者会认为重复算一两个数没有太大影响,但事实是:这样的重复不是单个结点,而是一棵子树。如果原来的三角形有n层,则调用关系树也会有n层,一共有2n-1个结点。
提示9-2:用直接递归的方法计算状态转移方程,效率往往十分低下。其原因是相同的子问题被重复计算了多次。
方法2:递推计算。程序如下(需再次注意边界处理):
int i, j;
for(j = 1; j <= n; j++) d[n][j] = a[n][j];
for(i = n-1; i >= 1; i——)
for(j = 1; j <= i; j++)
d[i][j] = a[i][j] + max(d[i+1][j],d[i+1][j+1]);
程序的时间复杂度显然是O(n2),但为什么可以这样计算呢?原因在于:i是 逆序枚举的,因此在计算d[i][j]前,它所需要的d[i+1][j]和d[i+1][j+1]一定已经计算出来了。
提示9-3:可以用递推法计算状态转移方程。递推的关键是边界和计算顺序。在多数情况下,递推法的时间复杂度是:状态总数×每个状态的决策个数×决策时间。如果不同状态的决策个数不同,需具体问题具体分析。
方法3:记忆化搜索。程序分成两部分。首先用“memset(d,-1,sizeof(d));”把d全部初始化为-1,然后编写递归函数(1):
int solve(int i, int j){
if(d[i][j] >= 0) return d[i][j];
return d[i][j] = a[i][j] + (i == n ? 0 : max(solve(i+1,j),solve(i+1,j+1)));
上述程序依然是递归的,但同时也把计算结果保存在数组d中。题目中说各个数都是非负的,因此如果已经计算过某个d[i][j],则它应是非负的。这样,只需把所有d初始化为-1,即可通过判断是否d[i][j]≥0得知它是否已经被计算过。
最后,千万不要忘记在计算之后把它保存在d[i][j]中。根据C语言“赋值语句本身有返回值”的规定,可以把保存d[i][j]的工作合并到函数的返回语句中。
图9-3 记忆化搜索
上述程序的方法称为记忆化(memoization),它虽然不像递推法那样显式地指明了计算顺序,但仍然可以保证每个结点只访问一次,如图9-3所示。
由于i和j都在1~n之间,所有不相同的结点一共只有O(n2)个。无论以怎样的顺序访问,时间复杂度均为O(n2)。从2n~n2是一个巨大的优化,这正是利用了数字三角形具有大量重叠子问题的特点。
提示9-4:可以用记忆化搜索的方法计算状态转移方程。当采用记忆化搜索时,不必事先确定各状态的计算顺序,但需要记录每个状态“是否已经计算过”。