编程 | 递归入门

本文根据LeetCode上的教程 Introduction to Algorithms - Recursion I 整理而成。目的在于帮助笔者自己更熟悉“递归”这一重要的编程概念,如果能够同时对他人产生帮助,那更好不过了。

本文的结构和LeetCode上的完全相同,分为 简介、递归原则、复现关系、备忘录、复杂度分析、总结 6个部分。

简介

本Card的目的,回答以下问题:

  1. 什么是递归?递归如何工作?
  2. 如何递归地解决一个问题?
  3. 如何分析一个递归算法的时间复杂度和空间复杂度?
  4. 如何以一种更好的方式应用递归?

递归的原理

每次递归函数调用自身,都将给定问题变为子问题。递归过程一直继续指导子问题可以不通过进一步递归就可以直接解决。

递归函数避免无限递归的必要属性:

  1. 递归结束条件(base cases)
  2. 一套规则(recurrence relation),可以将所有其他的cases规约为base cases。

递归函数中可以有多个地方调用本身。

例子 翻转字符串

void printReverse(const char *str) {
    if (!*str) return;  // base case
    printReverse(str + 1);
    putchar(*str);
}

递归函数

如果一个问题存在递归解法,我们就可以遵循下列步骤去实现它。

我们定义该问题为函数F(x)可以实现,其中X是函数的输入,同时表示了问题的范围。

在函数F(X)中,我们实现以下步骤:

  1. 将问题拆分成更小的范围,x_1 \belong X, x_2 \belong X, ..., x_n \belong X.
  2. 递归调用函数F(x_1), F(x_2), ..., F(x_n)以解决X的子问题。
  3. 最后,处理子问题的解,组合成对应X的解。

Recurrence Relation

定义:一个问题的解和其子问题的解之间的关系。

例子:Pascal's Triangle

定义:杨辉三角是一系列数字组成三角形的形状。在杨辉三角中,每行最左和最右的数字永远是1. 对于剩余的数字,每个数字是它前一行正上方2个数字之和。

用数学公式表达出来就是,Recurrence Relation为
f(i, j) = f(i-1, j-1) + f(i-1, j),

base cases为:
f(i, j) = 1, if j = 1 or j = i.

其中,f(i, j)表示第i行第j个数。

Memoization 备忘录

递归过程中重复的计算

递归解法常常是十分符合直觉容易编码的。但大多数时候,在递归过程中,重复计算导致了性能上的损失。

备忘录法(Memoization)
即是一个通用的避免重复计算的技术。
是的,这个词没有拼错,不是Memorization。

定义:为了避免重复计算,我们可以在一个cache中存储中间子问题的结果,以便之后再次使用它们的时候不需要重复计算。

备忘录的实现可以通过hashmap实现。尤其是在OOP中,利用装饰器可以实现通用的Memoization。

例子 斐波那契数

斐波那契数的多种解法,其中有时间复杂度为O(log n)的Binets法公式法,令人印象十分深刻.

Complexity Analysis 复杂度分析

递归算法的复杂度有时候不是显而易见的,要通过一些套路分析。

尾递归是一种特殊的技术,可以消减递归栈的使用,优化空间复杂度,使其和迭代算法相同。

Time Complexity 时间复杂度

递归算法的时间复杂度为:
O(T) = R * O(s),
其中,R为递归调用的数量,O(s)为每次递归调用产生的计算复杂度。
一般来说,R更难算一点,O(s)的计算和非递归算法的时间复杂度分析一样。

借助execution tree的技术,我们可以更好地分析递归调用的数量。
execution tree是展示具体情况下递归调用流的一棵树,每个节点代表一次调用,节点上的值表示调用时的参数。
这棵树的节点数目就是R。

需要特别注意的是,当使用Memoization技术时,execution tree的变化。

Space Complexity 空间复杂度

递归算法的空间使用主要分为2部分:

  1. recursion related
  2. non-recursion related

recursion related

学过编译原理的我们都知道,每次函数调用都要在栈上压入:

  1. 函数的返回地址
  2. 函数参数
  3. 函数的本地变量

递归算法的函数调用栈的深度是从初始case到base case.

non-recursion related

全局变量使用的空间,主要在堆上分配。比如,memoization 要使用的hashmap。

Tail Recursion 尾递归

尾递归是一种递归调用是递归函数的最后指令,而且函数中只有一个递归调用。

尾递归的一个很好的例子。注意,non_tail_recursion在最后的递归调用后还有计算。

public class Main {
    
  private static int helper_non_tail_recursion(int start, int [] ls) {
    if (start >= ls.length) {
      return 0;
    }
    // not a tail recursion because it does some computation after the recursive call returned.
    return ls[start] + helper_non_tail_recursion(start+1, ls);
  }

  public static int sum_non_tail_recursion(int [] ls) {
    if (ls == null || ls.length == 0) {
      return 0;
    }
    return helper_non_tail_recursion(0, ls);
  }

  //---------------------------------------------

  private static int helper_tail_recursion(int start, int [] ls, int acc) {
    if (start >= ls.length) {
      return acc;
    }
    // this is a tail recursion because the final instruction is the recursive call.
    return helper_tail_recursion(start+1, ls, acc+ls[start]);
  }
    
  public static int sum_tail_recursion(int [] ls) {
    if (ls == null || ls.length == 0) {
      return 0;
    }
    return helper_tail_recursion(0, ls, 0);
  }
}

尾递归消除递归栈的原理:
编译器知道在从callee中返回之后,会立刻再次返回,不需要再利用函数调用栈中保存的数据。只需要一个栈帧就可以了,所有层共用一个栈帧,所以返回时可以跳过整个递归栈。

并不是所有语言的编译器都支持尾递归优化的。比如,C, C++支持,而Python, Java不支持。

尾递归通常也不是那么好实现。需要
递归调用只出现在最后一个指令,如果需要调用多个函数,或是对返回值进行计算,就没法尾递归了。

而且细心的同学可以发现尾递归和迭代(loop)的相似之处。事实上,有些函数式编程语言甚至不支持loop,只有递归,完全可以实现迭代。因为我们平时使用loop居多,尾递归很少。如果需要写尾递归时(一般是在一些面试的要求中),可以先写loop版本的代码。然后试着把其中的局部变量更新改成尾递归中的参数,往往就可以写出优雅的(但对于大多数人可读性并不高)的尾递归代码了。

总结

解决递归问题的套路:

  1. 定义递归函数
  2. 写下recurrence relation和base case
  3. 使用Memoization消除重复计算,如果存在的话
  4. 如果可能的话,使用尾递归实现递归算法,以消减空间复杂度
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容