从斐波那契数列讲解算法设计的思路

从斐波那契到递归

  很多人在开始学计算机程序设计类的课程时,都听过一个再经典不过的例子,那就是斐波那契数列,也称兔子数列。为什么叫兔子数列呢,我们知道算法的研究当然是为了解决问题,这个问题越实际,这个算法的意义也就越大,而斐波那契数列的求解算法也应该有自己实际所需要解决的问题,这个问题最早开始就是兔子生长数目问题。
  问题说的是,有一对刚出生的兔子,两个月后每对刚出生的兔子又能诞生下一对兔子,假设兔子永远不会死去,并且只要出生的兔子过了两个月就可以一直生小兔子,现在问过了 n n 个月一共有多少对兔子。
  通过对这个问题的分析我们就可以得出一个条件,第 n n 个月的兔子有两个来源,第一个来源是 n 1 n-1 月就有的兔子,第二个来源就是这个月刚出生的兔子,如果把兔子的总数量当成关于 n n 的函数,第n个月兔子的数量记成 f ( n ) f(n) 。我们惊奇的发现,第 n 1 n-1 月出生的兔子到第 n n个 月还不能生兔子,所以出生的兔子都是之前出生的兔子生的,那就是前 n 2 n-2 个月兔子生的,每对兔子可诞生一对,那就意味着第 n n 个月出生的兔子数量就等于 f ( n 2 ) f(n-2) ,两部分我们都表示出来了,我们就得到了大名鼎鼎的斐波那契数列的递归表达式, f ( n ) = f ( n 1 ) + f ( n 2 ) f(n)=f(n-1)+f(n-2) ,光有这个表达式还足够我们解决问题,我们应该注意到第一个月只有一对兔子,第二个月这对兔子还不生产,所以第二个月也只有一对兔子,即 f ( 1 ) = 1 , f ( 2 ) = 1 f(1)=1,f(2)=1
  通过上面这个例子,我们就可以看出递归是一种非常适合人类思维的算法策略,第 n n 个月有多少对兔子这种大问题我们解决不了,我们可以解决小问题,如果n比较小我们可以解决,那么我们就去找寻如何把问题从大化小的办法。递归就深刻的体现了把大问题化成小问题,然后去解决小问题的思想。但是要注意,我们总是很不希望把问题化小的同时引来了新的难以处理的问题,最好这个问题我们能解决,那如果问题化小之后还是这个问题,这个样子就再好不过了。这也是递归需要的,那就是大问题在化解为小问题的时候,是重复的子问题。问题可以化解了,我们化解到什么程度呢,比如本例中的第1个月和第2个月,我们就觉得这样的问题可以解决,所以递归还需要有初始值,代表子问题化小的终点
  我们还知道,在计算机编程语言里,函数就是为了完成一种任务,我们去解决大问题,可以先调用小问题的解决函数,既然递归的大问题和小问题是同一种问题,这就相当于函数调用的小问题函数是自己,也就是出现了函数调用自身的情况,再使用初始值作为函数的结束条件,这就是程序中的递归。而栈的结构应用于这种递归思想再合适不过了,所以递归是一种既符合人类思维、又符合程序语言设计、还符合计算机底层设计的一种思路。
  递归的好处算是显而易见了,因为符合程序设计语言和人类思维,所以递归的代码写出来都是非常简洁的,无非就是设计好终止条件,然后根据不同的条件做递归就行了。在很多情况下都是非常好用的,比如在操作数据结构中的树,比如需要使用栈的一些特性中。但是递归也有自己的缺点,首先就是开销问题,因为使用递归要多次的调用函数,这是有开销的,当问题的规模非常大的时候,过于多次的调用会使得程序的栈溢出,这也是务必需要规避的。此外,递归还有一个缺点就是有可能带来重复计算的问题。在上述斐波那契数列的递归计算路径如下图。
递归计算斐波那契f(6)路径
  如图所示,我们可以看到计算 f ( 6 ) f(6) 的时候,越底层的项重复计算的就越多。可以用数学证明,这个计算量是指数增加的,而其中又都是重复计算。

带备忘录的递归

  有没有一种方法可以减少递归算法的重复计算,可以想到在递归过程中引入一个备忘录,专门用来存储已经计算过的值,通常采用的数据结构是数组,因为数组的随机访问可以实现真正意义上的 O ( 1 ) O(1) 查找。以上图为例,第一次递归调用计算 f ( 3 ) f(3) 的时候,我们就可以开辟一块数组,把计算结果记录下来,下次计算就去查找就行了。总的来说就时要计算一个值,我们直接去查找,如果找到就返回,找不到则计算。
  这种算法思路本质上还是递归,只是通过空间换时间,来减少了重复计算。有的人把这种算法称为动态规划,我不习惯这样,因为这种算法不具备动态规划自底向上的特征。动态规划后面会讲解到。
  尽管这种备忘录的方式减少了递归的重复计算,降低了算法的时间复杂度到 O ( n ) O(n) 级别,但是还是有可能出现递归的其他问题。同时空间也不是最优的。

从递归到动态规划

  我么仔细研究递归的路径,我们有 f ( 1 ) f(1) f ( 2 ) f(2) 就可以计算 f ( 3 ) f(3) 了,此时计算出来的 f ( 3 ) f(3) 又可以帮助我们计算 f ( 4 ) f(4) ,然后如此无穷无尽,想计算n为多少,都是可以的了。我们可以放弃原来递归那种自顶向下的算法策略,因为自顶向下容易让我们难以窥测全局。同时,自底向上也没有重复计算的弊端。我们现在可以采取从前到后的循环计算方式,计算后面的值只需要前两个值就可以了,我们也没有必要把计算到的所有值都存储起来,这样就大大的降低了空间复杂度。从原来递归的 O ( n ) O(n) 降到了现在的 O ( 1 ) O(1)
  我们一步步的思考到这个循环算法都是有前提的,首先就是把大问题化小,在化小的过程中,我们发现了这个子问题和原来的问题是同一类问题,这个要素动态规划也会遇到,称之为重叠子问题要素
  到目前为止,我们就回避了递归的所有缺点,效率低和重复计算的问题,我们是通过循环来解决这个问题的,而这种自底向上的循环同时也是动态规划的基础。我们注意到上例中,我们在计算一个值得时候,这个值只和前面的值有关,当这个值一旦确定(状态一旦确定),就和后面的值无关了(状态),通俗来讲,就是某状态以后的过程不会影响以前的状态,这就是动态规划的另一个要素叫无后效性
  但是这个循环求解还不能被称为严格意义上的动态规划,原因在于动态规划还需要满足一个要素,就是最优子结构。这个要素说的就是我们在求解中间过程中我们每一次确定的中间状态(求解出来的值)都是最优的,而且最终的问题是也是寻找最优的,并且局部最优决定全局最优。这个最优的可以是时间最短,开销最小等等条件,总之体现最…就完事了。
  在这里说句题外话,我把兔子数目问题改一改,改成每对兔子过两个月最多可以生一对兔子,最终求n个月之后最多有多少对兔子。这样不就是动态规划了,甚至直接就是贪心了,求解还是一样的,所以最优子结构的要素不必过于拘泥细节。
  动态规划算法的设计的里程碑就是设计出状态转移方程,如在兔子的问题中状态转移方程就是 f ( n ) = f ( n 1 ) + f ( n 2 ) f(n)=f(n-1)+f(n-2) 这个表达式,通俗来说状态转移方程就是后面的状态由前面状态导出的关系式。这是我们编写代码的基础,从状态转移方程我们可以看到循环的过程,就是不断地计算后一个状态;同时也能够从中看到状态之间的联系。
  动态规划的应用有很多,也算是算法设计中的一个难点,但是也只有能做到灵活运用动态规划,掌握动态规划的步骤,设计思路来解决问题这样算是一个合格的算法设计者。下面将整体的梳理一下动态规划的算法设计思路。

动态规划算法设计思路

 1. 分析问题(最优解)的性质,将问题化小,然后刻画子结构的特征
 2. 根据大问题与小问题的联系,分析推出状态转移方程,一般是一个递归的公式
 3. 根据状态转移的顺序,确定初始值,然后以自底向上的方式计算出最优质解
 4. 根据最优值的信息,构造出解。

  值得一提的是最后个过程,一般情况下根据状态转移方程算出来的答案就是最终解,但是很多时候也会存在转换的过程,求出来的值并不是最终的解,而是需要算法构思者自己建立联系,实现转换的过程。以动态规划一个经典的案例来说,最大子串和的最优解法并不是去计算子串的和,而是去计算到某一个数为止最大的的子串和。
  动态规划基于循环实现,动态规划和循环通常都是能够降低递归的开销,在遇到一个算法题的时候,如果我们能够很自然的想到递归解法,往往我们也需要想一想,能够将递归算法改为循环,甚至改成动态规划的自底向上计算,能不能减少开销,这也是一个重要的思路递进规程。

  动态规划的重点内容到此也就结束了,后面有机会我会讲解一些动态规划和贪心算法设计的的思路,也算是在这里给自己挖个坑,后面慢慢填。

分治算法设计

  从递归的基础出发,还有一条重要的算法设计路径,就是分治的思想,分治就是分而治之,基于递归的基础将大问题划分成小问题的然后解决,划分后的小问题之间是独立的,共同决定了大问题的解。举快排的例子,根据pivot把一个数组分为两部分,这就是问题的划分,划分出的子问题就是两部分分别排序,两部分是不相交的,不存在需要排序的公共部分,分别排序后构成了总的排序。所以分治由两个步骤组成,一个步骤就是分,把问题划分成不相交的子问题,一个步骤就是治,解决划分后的问题
  很多分治算法还有一个最终的合并过程,因为划分后的子问题可能并不是直接得到总问题的解。以归并排序为例,划分成两段之后分别排序,但是排序好的两段不能直接作为排序的结果,需要一个两段的合并的过程。
  分治的算法一般不好直接分析算法的复杂度,所以才有了著名的主定理(master theorem),对于分治,算法的划分大概都可以得出一个类似于 T ( n ) = a T ( n b ) + f ( n ) T(n)=aT\left ( \frac{n}{b} \right )+f(n) 的公式,其中 a a 表示划分后需要处理子问题的数量, n n 为总问题的规模, n b \frac{n}{b} 为每个子问题的规模。然后根据 f ( n ) f(n) O ( n log b a ) O\left(n^{\log_ba}\right) 的关系来判断算法复杂度。
  直观上来讲就是划分的问题越少,每个子问题的复杂度越小,总的复杂度越小。主定理的详细内容和推到再次不做讲解。

贪心算法设计

  如果我们能够分析到动态规划的要素,我们敏感的嗅觉就能告诉我这是一个动态规划问题,但是知道这是一个动态规划问题和迅速的推导出状态转移方程,并写出代码还有一个巨大的差距,除非已经接触过这种问题。在这个时候,另一种算法设计思路就可以拉出来溜溜了,这就是贪心算法。贪心算法同样需要满足动态规划的最优子结构要素,甚至可以说,贪心算法是一种特殊的动态规划。这种特殊性体现在,贪心的最优子结构状态在选择上具有一定的特殊性质
  在一般的贪心算法中,求解子问题需要对多种情况进行计算,然后对计算值进行评价找出最优的的结构,但是有些结构可能我们能够很容易的判断是最优的结构,例如克鲁斯卡尔算法生成最小生成树,我们要保持加入一个节点的生成树最小,可能有多条路径选择,我们直接选择能加入的最小的边就可以了。这种根据最优性质选择最优状态的算法就是贪心算法。可见贪心算法相对于一般的动态规划算法最大的区别就在于最优结构能否通过某种性质获得。
  所以此时的问题变成了如何寻找最优,有简便方法,就使用,没有的话就只好继续使用一般动态规划思路求解,也就是在子结构的选择中使用暴力方法。而事实恰恰就是大多数问题中都不存在这种特殊的性质,所以动态规划是需要重点掌握的方法。贪心一般对于特殊问题有针对性,注意积累即可

斐波那契数列的用途与特性

斐波那契数列的用途

  斐波那契数列是一种需要使用一种迭代式求解的典型问题,可以给其他问题的求解提供一定的借鉴,甚至有些算法问题直接就是斐波那契数列套上了一层外衣,这样的问题有很多,本文引一两例。
  例1:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法?
  这个问题就是典型的斐波那契数列,一次可以跳一个或两个台阶,那么说明第 n n 个台阶可以是从第 n 1 n-1 n 2 n-2 个台阶上跳上来的,所以就自然的推出了斐波那契的递推式。而且我们在做类似的题目不能过度拘泥于2阶的特性,可以不止前两项之和,也可以是前三项,或者之积等等。

斐波那契数列的特性

  斐波那契和黄金分割的关系式非常密切的,以及特征值等等性质过于数学,感兴趣可以查找相关资料,在此讲解一种比较实用的计算斐波那契数列的方法,是基于线性代数的。

斐波那契数列的矩阵计算
  这个推导应该很容易理解,根据这个矩阵的指数形式来计算 f ( n ) f(n) ,直接乘的话复杂度仍然是 O ( n ) O(n) 的,但是指数的计算有个重要的性质指数相加,等于两个数相乘,使用如下公式可以省去很多计算,比如需要计算 f ( 14 ) f(14) 的时候,可以先依次计算 f ( 3 ) , f ( 6 ) , f ( 7 ) f(3),f(6),f(7) 。这样就可以把时间复杂度降低到 O ( n l o g n ) O(nlogn)
x n = { x n / 2 x n / 2 x x n / 2 x n / 2 x x x^n=\left\{ \begin{aligned} x^{n/2}\cdot x^{n/2}\quad x是偶数 \\ x^{n/2}\cdot x^{n/2}\cdot x \quad x是奇数\\ \end{aligned} \right.

算法设计总结

  总的来说求解问题设计算法,一般都是一个递进式的思路,暴力算法大家可能很轻易就想到了,接下来就是根据问题的性质去做优化了,很可能大家也很自然的针对问题逐渐减小的规模构造出算法的递归求解,但是递归求解通常会带来资源的开销和重复计算的问题,如果这两个问题需要解决,可以考虑循环改写成循环的实现。再者可以考虑能够动过动态规划的方式来解决重复计算,通常动态规划求解的问题都是递归具有指数复杂度的问题。但是动态规划当中还存在着最优子结构选取的暴力求解,能否根据最优子结构的性质把这里优化了,直接根据性质选择到了最优子结构,这就是贪心算法了。也有可能优化算法的时候发现并不存在最优子结构的性质或者局部最优无法决定全局最优,动态规划算法失效。但是问题可以通过相似的划分,然后对划分后的问题逐个击破这就是分治了。   本文着重于算法思想的理解,篇幅有限有很多的东西还未能一次性写到,重点的内容留给以后有机会继续解释,希望此文能够给看到的人一些帮助或者启示,也供阅读者交流之用。