【恋上数据结构与算法二】(四)递归(Recursion)

递归(Recursion)

◼ 递归:函数(方法)直接或间接调用自身。是一种常用的编程技巧

int sum(int n) {
    if (n <= 1) return n;
    return n + sum(n-1);
}
void a(int v) {
    if (v < 0) return;
    b(--v);
}

void b(int v) {
    a(--v);
}

递归现象

从前有座山,山里有座庙,庙里有个老和尚,正在给小和 尚讲故事呢!故事是什么呢?【从前有座山,山里有座庙, 庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢? 『从前有座山,山里有座庙,庙里有个老和尚,正在给小 和尚讲故事呢!故事是什么呢?......』】

GNU 是 GNU is Not Unix 的缩写
GNU → GNU is Not Unix → GNU is Not Unix is Not Unix → GNU is Not Unix is Not Unix is Not Unix

假设A在一个电影院,想知道自己坐在哪一排,但是前面人很多,
A 懒得数,于是问前一排的人 B【你坐在哪一排?】,只要把 B 的答案加一,就是 A 的排数。
B 懒得数,于是问前一排的人 C【你坐在哪一排?】,只要把 C 的答案加一,就是 B 的排数。
C 懒得数,于是问前一排的人 D【你坐在哪一排?】,只要把 D 的答案加一,就是 C 的排数。
......
直到问到最前面的一排,最后大家都知道自己在哪一排了

函数的调用过程

public static void main(String[] args) {
    test1(10);
    test2(20);
}

public static void test1(int v) {}

public static void test2(int v) {
    test3(30);
}

public static void test3(int v) {}

函数的递归调用过程

public static void main(String[] args) {
    sum(4);
}

public static void sum(int n) {
    if (n <= 1) return n;
    return n + sum(n - 1);
}

◼ 如果递归调用没有终止,将会一直消耗栈空间
最终导致栈内存溢出(Stack Overflow)

◼ 所以必需要有一个明确的结束递归的条件
也叫作边界条件、递归基

实例分析

◼求 1+2+3+...+(n-1)+n 的和(n>0)

int sum(int n) {
    if (n <= 1) return n;
    return n + sum(n - 1);
}

◼总消耗时间T(n)=T(n−1) +O(1),因此
时间复杂度:O(n)
空间复杂度:O(n)

int sum(int n) {
    int result = 0;
    for (int i = 1; i < n; i++) {
        result += i;
    }
    return result;
}

◼ 时间复杂度:O(n),空间复杂度:O(1)

int sum(int n) {
    if (n <= 1) return n;
    return (1 + n) * n >>1;
}

◼ 时间复杂度:O(1),空间复杂度:O(1)

◼ 注意:使用递归不是为了求得最优解,是为了简化解决问题的思路,代码会更加简洁
◼ 递归求出来的很有可能不是最优解,也有可能是最优解

递归的基本思想

◼ 拆解问题
把规模大的问题变成规模较小的同类型问题
规模较小的问题又不断变成规模更小的问题
规模小到一定程度可以直接得出它的解

◼求解
由最小规模问题的解得出较大规模问题的解
由较大规模问题的解不断得出规模更大问题的解
最后得出原来问题的解

◼ 凡是可以利用上述思想解决问题的,都可以尝试使用递归
很多链表、二叉树相关的问题都可以使用递归来解决
✓ 因为链表、二叉树本身就是递归的结构(链表中包含链表,二叉树中包含二叉树)

递归的使用套路

1 明确函数的功能
先不要去思考里面代码怎么写,首先搞清楚这个函数的干嘛用的,能完成什么功能?

2 明确原问题与子问题的关系
寻找 f(n) 与 f(n – 1) 的关系

3 明确递归基(边界条件)
递归的过程中,子问题的规模在不断减小,当小到一定程度时可以直接得出它的解
寻找递归基,相当于是思考:问题规模小到什么程度可以直接得出解?

练习1 – 斐波那契数列

◼ 斐波那契数列:1、1、2、3、5、8、13、21、34、......
F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n≥3)
◼编写一个函数求第 n 项斐波那契数

int fib0(int n) {
    if (n <= 2) return 1;
    return fib0(n - 1) + fib0(n - 2);
}

◼根据递推式T(n) =T(n−1) +T(n−2)+O(1),可得知时间复杂度:O(2n)
◼ 空间复杂度:O(n)
递归深度 = O(n)
每次调用所需的辅助空间 = 1
递归调用的空间复杂度 = 递归深度 * 每次调用所需的辅助空间 = O(n)

fib函数的调用过程

◼ 出现了特别多的重复计算
◼ 这是一种“自顶向下”的调用过程

fib优化1 – 记忆化

◼ 用数组存放计算过的结果,避免重复计算

int fib1(int n) {
    if (n <= 2) return 1;
    int[] array = new int[n + 1];
    array[1] = array[2] = 1;
    return fib1(n, array);
}

int fib1(int n, int[] array) {
    if (array[n] == 0) {
        array[n] = fib1(n - 1, array) + fib1(n - 2, array);
    }
    return array[n];
}

◼ 时间复杂度:O(n),空间复杂度:O(n)

fib优化2

◼ 去除递归调用

int fib2(int n) {
    if (n <= 2) return 1;
    int[] array = new int[n + 1];
    array[1] = array[2] = 1;
    for (int i = 3; i <= n; i++) {
        array[i] = array[i - 1] + array[i - 2];
    }
    return array[n];
}

◼ 时间复杂度:O(n),空间复杂度:O(n)
◼ 这是一种“自底向上”的计算过程

fib优化3

◼由于每次运算只需要用到数组中的 2 个元素,所以可以使用滚动数组来优化

int fib3(int n) {
    if (n <= 2) return 1;
    int[] array = new int[2];
    array[0] = array[1] = 1;
    for (int i = 3; i <= n; i++) {
        array[i % 2] = array[(i - 1) % 2] + array[(i - 2) % 2];
    }
    return array[n % 2];
}

◼ 时间复杂度:O(n),空间复杂度:O(1)

fib优化4 – 位运算取代模运算

◼ 乘、除、模运算效率较低,建议用其他方式取代

int fib4(int n) {
    if (n <= 2) return 1;
    int[] array = new int[2];
    array[0] = array[1] = 1;
    for (int i = 3; i <= n; i++) {
        array[i & 1] = array[(i - 1) & 1] + array[(i - 2) & 1];
    }
    return array[n & 1];
}

直接用两个变量代替数组

int fib5(int n) {
    if (n <= 2) return 1;
    int first = 1;
    int second = 1;
    for (int i = 3; i <= n; i++) {
        second = first + second;
        first = second - first;
    }
    return second;
}

◼ 时间复杂度:O(n),空间复杂度:O(1)

fib优化6

◼ 斐波那契数列有个线性代数解法:特征方程


int fib6(int n) {
    double c = Math.sqrt(5);
    return (int)((Math.pow((1+c)/2, n) - Math.pow((1-c)/2, n))/c);
}

◼ 时间复杂度、空间复杂度取决于 pow 函数(至少可以低至O(logn))

练习2 – 上楼梯(跳台阶)

◼楼梯有 n 阶台阶,上楼可以一步上 1 阶,也可以一步上 2 阶,走完 n 阶台阶共有多少种不同的走法?
假设 n 阶台阶有 f(n) 种走法,第 1 步有 2 种走法
✓如果上 1 阶,那就还剩 n – 1 阶,共 f(n – 1) 种走法
✓如果上 2 阶,那就还剩 n – 2 阶,共 f(n – 2) 种走法
所以 f(n) = f(n – 1) + f(n – 2)

int climbStairs(int n) {
    if (n <= 2) return n;
    return climbStairs(n - 1) + climbStairs(n - 2);
}

◼ 跟斐波那契数列几乎一样,因此优化思路也是一致的

int climbStairs(int n) {
    if (n <= 2) return 1;
    int first = 1;
    int second = 1;
    for (int i = 3; i <= n; i++) {
        second = first + second;
        first = second - first;
    }
    return second;
}

练习3 – 汉诺塔(Hanoi)

◼编程实现把 A 的 n 个盘子移动到 C(盘子编号是 [1, n] )
每次只能移动1个盘子
大盘子只能放在小盘子下面

1个盘子

2个盘子

3个盘子

汉诺塔 – 思路

◼其实分 2 种情况讨论即可
当 n == 1时,直接将盘子从 A 移动到 C
当 n > 1时,可以拆分成3大步骤
1 将n–1个盘子从A移动到B
2 将编号为n的盘子从A移动到C
3 将n–1个盘子从B移动到C
✓步骤 1 3 明显是个递归调用

汉诺塔 – 实现

/**
 * 将 no 号盘子从 from 移动到 to
 */
void move(int no, String from, String to) {
    count++;
    System.out.println("第 " + count + " 步:" + "将" + no + "号盘子从" + from + "移动到" + to);
}
/**
 * 将 n 个碟子从 p1 挪动到 p3
 * @param p2 中间的柱子
 */
void hanoi(int n, String p1, String p2, String p3) {
    if (n == 1) {
        move(n, p1, p3);
        return;
    }
    hanoi(n - 1, p1, p3, p2);
    move(n, p1, p3);
    hanoi(n - 1, p2, p1, p3);
} 
public static void main(String[] args) {
    new Hanoi().hanoi(3, "A", "B", "C");
}
第 1 步:将1号盘子从A移动到C
第 2 步:将2号盘子从A移动到B
第 3 步:将1号盘子从C移动到B
第 4 步:将3号盘子从A移动到C
第 5 步:将1号盘子从B移动到A
第 6 步:将2号盘子从B移动到C
第 7 步:将1号盘子从A移动到C

◼T(n) =2∗(Tn−1) +O(1)
因此时间复杂度是:O(2n)
◼空间复杂度:O(n)

递归转非递归

◼递归调用的过程中,会将每一次调用的参数、局部变量都保存在了对应的栈帧(Stack Frame)中

public static void main(String[] args) {
    log(4);
}
static void log(int n) {
    if (n < 1) return;
    log(n - 1);
    int v = n + 10;
    System.out.println(v);
}

◼ 若递归调用深度较大,会占用比较多的栈空间,甚至会导致栈溢出
◼ 在有些时候,递归会存在大量的重复计算,性能非常差
这时可以考虑将递归转为非递归(递归100%可以转换成非递归)

◼ 递归转非递归的万能方法
自己维护一个栈,来保存参数、局部变量
但是空间复杂度依然没有得到优化

static class Frame {
    int n;
    int v;
    Frame(int n, int v) {
        this.n = n;
        this.v = v;
    }
}
static void log2(int n) {
    Stack<Frame> frames = new Stack<>();
    while (n > 0) {
        frames.push(new Frame(n, n + 10));
        n--;
    }
    while (!frames.isEmpty()) {
        Frame frame = frames.pop();
        System.out.println(frame.v);
    }
}

◼ 在某些时候,也可以重复使用一组相同的变量来保存每个栈帧的内容

static void log(int n) {
    for (int i = 1; i <= n; i++) {
        System.out.println(i + 10);
    }
}

◼这里重复使用变量 i 保存原来栈帧中的参数
空间复杂度从O(n) 降到了O(1)

尾调用(Tail Call)

◼ 尾调用:一个函数的最后一个动作是调用函数
如果最后一个动作是调用自身,称为尾递归(Tail Recursion),是尾调用的特殊情况

void test1() {
    int a = 10;
    int b = a + 20;
    test2(b);
}
void test2(int n) {
    if (n < 0) return;
    test2(n - 1);
}

◼ 一些编译器能对尾调用进行优化,以达到节省栈空间的目的

下面代码不是尾调用

static int facttorial(int n) {
    if (n <= 1) return n;
    return n * facttorial(n - 1);
}

◼ 因为它最后1个动作是乘法

尾调用优化(Tail Call Optimization)

◼尾调用优化也叫做尾调用消除(Tail Call Elimination)
如果当前栈帧上的局部变量等内容都不需要用了,当前栈帧经过适当的改变后可以直接当作被尾调用的函数的栈帧使用,然后程序可以 jump 到被尾调用的函数代码。
生成栈帧改变代码与 jump 的过程称作尾调用消除或尾调用优化
尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高

◼ 消除尾递归里的尾调用比消除一般的尾调用容易很多
比如Java虚拟机(JVM)会消除尾递归里的尾调用,但不会消除一般的尾调用(因为改变不了栈帧)
因此尾递归优化相对比较普遍,平时的递归代码可以考虑尽量使用尾递归的形式

尾调用优化前的汇编代码(C++)

尾调用优化后的汇编代码(C++)

◼ 汇编教程
https://ke.qq.com/course/348781
https://ke.qq.com/course/336509

尾递归示例1 – 阶乘

◼求 n 的阶乘 123...(n-1)*n (n>0)

static int facttorial(int n) {
    if (n < 1) return;
    return n * facttorial(n - 1);
}
static int facttorial(int n) {
    return facttorial(n, 1);
}

/**
* result:从小到达累乘的结果
*/
static int facttorial(int n, int result) {
    if (n <= 1) return result;
    return facttorial(n - 1, result * n);
}
public static void main(String[] args) {
    System.out.println(facttorial(4));
}

尾递归示例2 – 斐波那契数列

int fib(int n) {
    if (n <= 2) return 1;
    return fib(n-1) + fib(n-2);
}
int fib(int n) {
    return fib(n, 1, 1);
}

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

推荐阅读更多精彩内容