递归算法就是通过解决同一问题的一个或多个更小的实例来最终解决一个大问题的算法。为了在C语言中实现递归算法,常常使用递归函数,也就是说能调用自身的函数。递归程序的基本特征:它调用自身(参数的值更小),具有终止条件,可以直接计算其结果。
在使用递归程序时,我们需要考虑编程环境必须能够保持一个其大小与递归深度成正比例的下推栈。对于大型问题,这个栈需要的空间可能妨碍我们使用递归的方法。
一个递归模型为分治法,最本质的特征就是:把一个问题分解成独立的子问题。如果子问题并不独立,问题就会复杂的多,主要原因是即使是这种最简单算法的直接递归实现,也可能需要难以想象的时间,使用动态规划技术就可以避免这个缺陷。
例如,斐波那契数列的递归实现如下:
int F(int i) { if(i < 1) return 0; if(i == 1) return 1; return F(i-1) + F(i - 2); }
千万不要使用这样的程序,因为它的效率极低,需要指数级时间。相比之下,如果首先计算前N个斐波那契数,并把它们存储在一个数组中,就可以使用线性时间(与N成正比)计算F。
F[0] = 0;F[1] = 1; for(i = 2; i <= N; i++) F[i] = F[i-1] + F[i-2];
这个技术(dp)给了我们一个获取任何递归关系数值解的快速方法。在斐波那契数的例子中,我们甚至可以舍弃数组,只需要保存前两个值,这种思想有时能大幅代码,例如HDU-1003的第二种代码。
由上面的讨论我们可以得出这样的结论:我们可以按照从最小开始的顺序计算所有函数值来求任何类似函数的值,在每一步使用先前已经计算出的值来计算当前值,我们称这项技术为自底向上的动态规划。只要有存储已经计算出的值的空间,就能把这项技术应用到任何递归计算中,就能把算法从指数级运行时间向线性运行时间改进。
自顶向下的动态规划甚至是一个更简单的技术,这项技术允许我们执行函数的代价与自底向上的动态规划一样(或更小),但是它的计算是自动的。我们实现递归程序来存储它所计算的每一个值(正如它最末的步骤),并通过检查所存储的值,来避免重新计算它们的任何项(正如它最初的步骤)。这种方法有时也称作为备忘录法(记忆化搜索)。
斐波那契数--dp+记忆化搜索
通过把所计算的值存储在递归过程的外部数组中,明确地避免重复计算。这一程序计算的时间与N成正比。
int F(int i) { if(knownF[i] != unknown) return knownF[i]; if(i == 0) t = 0; if(i == 1) t = 1; if(i > 1) t = F(i - 1) + F(i - 2); return knownF[i] = t; }
性质:动态规划降低了递归函数的运行时间,也就是减少了计算所有小于或等于给定参数的递归调用所要求的时间,其中处理一次递归调用的时间为常量。
我们不需要把递归参数限制到单整形参数的情况。当有一个带有多个整形参数的函数时,可以把较小子问题的解存储在多维数组中,一个参数对应数组的一维。其他那些完全不涉及整形参数的情形,就使用抽象的离散问题公式,它能让我们把问题分解为一个个的小问题。
在自顶向下的动态规划中,我们存储已知的值;在自底向上的动态规划中,我们预先计算这些值。
我们常常选择自顶向下的动态规划而不选自底向上动态规划,其原因如下:
1 自顶向下的动态规划是一个自然的求解问题的机械转化。
2 计算子问题的顺序能自己处理。
3 我们可能不需要计算所有子问题的解。
我们不能忽视至关重要的一点是,当我们需要的可能的函数值的数目太大以至于不能存储(自顶向下)或预先计算(自底向上)所有值时,动态规划就会变得低效。自顶向下动态规划确实是开发高效的递归算法实现的基本技术,这类算法应纳入任何从事算法设计与实现所需的工具箱。