递归详解.md 16.3 KB
Newer Older
L
labuladong 已提交
1 2
# 递归详解

3 4 5 6 7 8 9 10 11 12

<p align='center'>
<a href="https://github.com/labuladong/fucking-algorithm" target="view_window"><img alt="GitHub" src="https://img.shields.io/github/stars/labuladong/fucking-algorithm?label=Stars&style=flat-square&logo=GitHub"></a>
<a href="https://www.zhihu.com/people/labuladong"><img src="https://img.shields.io/badge/%E7%9F%A5%E4%B9%8E-@labuladong-000000.svg?style=flat-square&logo=Zhihu"></a>
<a href="https://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站-@labuladong-000000.svg?style=flat-square&logo=Bilibili"></a>
</p>

![](../pictures/souyisou.png)

L
labuladong 已提交
13
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~
14 15 16

**-----------**

L
labuladong 已提交
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
首先说明一个问题,简单阐述一下递归,分治算法,动态规划,贪心算法这几个东西的区别和联系,心里有个印象就好。

递归是一种编程技巧,一种解决问题的思维方式;分治算法和动态规划很大程度上是递归思想基础上的(虽然动态规划的最终版本大都不是递归了,但解题思想还是离不开递归),解决更具体问题的两类算法思想;贪心算法是动态规划算法的一个子集,可以更高效解决一部分更特殊的问题。

分治算法将在这节讲解,以最经典的归并排序为例,它把待排序数组不断二分为规模更小的子问题处理,这就是 “分而治之” 这个词的由来。显然,排序问题分解出的子问题是不重复的,如果有的问题分解后的子问题有重复的(重叠子问题性质),那么就交给动态规划算法去解决!

## 递归详解

介绍分治之前,首先要弄清楚递归这个概念。

递归的基本思想是某个函数直接或者间接地调用自身,这样就把原问题的求解转换为许多性质相同但是规模更小的子问题。我们只需要关注如何把原问题划分成符合条件的子问题,而不需要去研究这个子问题是如何被解决的。递归和枚举的区别在于:枚举是横向地把问题划分,然后依次求解子问题,而递归是把问题逐级分解,是纵向的拆分。

以下会举例说明我对递归的一点理解,**如果你不想看下去了,请记住这几个问题怎么回答:**

1. 如何给一堆数字排序? 答:分成两半,先排左半边再排右半边,最后合并就行了,至于怎么排左边和右边,请重新阅读这句话。
2. 孙悟空身上有多少根毛? 答:一根毛加剩下的毛。
3. 你今年几岁? 答:去年的岁数加一岁,1999 年我出生。

递归代码最重要的两个特征:结束条件和自我调用。自我调用是在解决子问题,而结束条件定义了最简子问题的答案。

```c++
int func(你今年几岁) {
    // 最简子问题,结束条件
    if (1999年几岁) return 0;
    // 自我调用,缩小规模
    return func(你去年几岁) + 1;   
}
```

其实仔细想想,**递归运用最成功的是什么?我认为是数学归纳法。**我们高中都学过数学归纳法,使用场景大概是:我们推不出来某个求和公式,但是我们试了几个比较小的数,似乎发现了一点规律,然后编了一个公式,看起来应该是正确答案。但是数学是很严谨的,你哪怕穷举了一万个数都是正确的,但是第一万零一个数正确吗?这就要数学归纳法发挥神威了,可以假设我们编的这个公式在第 k 个数时成立,如果证明在第 k + 1 时也成立,那么我们编的这个公式就是正确的。

那么数学归纳法和递归有什么联系?我们刚才说了,递归代码必须要有结束条件,如果没有的话就会进入无穷无尽的自我调用,直到内存耗尽。而数学证明的难度在于,你可以尝试有穷种情况,但是难以将你的结论延伸到无穷大。这里就可以看出联系了 —— 无穷。

递归代码的精髓在于调用自己去解决规模更小的子问题,直到到达结束条件;而数学归纳法之所以有用,就在于不断把我们的猜测向上加一,扩大结论的规模,没有结束条件,从而把结论延伸到无穷无尽,也就完成了猜测正确性的证明。

### 为什么要写递归

首先为了训练逆向思考的能力。递推的思维是正常人的思维,总是看着眼前的问题思考对策,解决问题是将来时;递归的思维,逼迫我们倒着思考,看到问题的尽头,把解决问题的过程看做过去时。

第二,练习分析问题的结构,当问题可以被分解成相同结构的小问题时,你能敏锐发现这个特点,进而高效解决问题。

第三,跳出细节,从整体上看问题。再说说归并排序,其实可以不用递归来划分左右区域的,但是代价就是代码极其难以理解,大概看一下代码(归并排序在后面讲,这里大致看懂意思就行,体会递归的妙处):

```java
void sort(Comparable[] a){    
    int N = a.length;
    // 这么复杂,是对排序的不尊重。我拒绝研究这样的代码。
    for (int sz = 1; sz < N; sz = sz + sz)
        for (int lo = 0; lo < N - sz; lo += sz + sz)
            merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1));
}

/* 我还是选择递归,简单,漂亮 */
void sort(Comparable[] a, int lo, int hi) {
    if (lo >= hi) return;
    int mid = lo + (hi - lo) / 2;
    sort(a, lo, mid); // 排序左半边
    sort(a, mid + 1, hi); // 排序右半边
    merge(a, lo, mid, hi); // 合并两边
}

```

看起来简洁漂亮是一方面,关键是**可解释性很强**:把左半边排序,把右半边排序,最后合并两边。而非递归版本看起来不知所云,充斥着各种难以理解的边界计算细节,特别容易出 bug 且难以调试,人生苦短,我更倾向于递归版本。

显然有时候递归处理是高效的,比如归并排序,**有时候是低效的**,比如数孙悟空身上的毛,因为堆栈会消耗额外空间,而简单的递推不会消耗空间。比如这个例子,给一个链表头,计算它的长度:

```java
/* 典型的递推遍历框架,需要额外空间 O(1) */
public int size(Node head) {
    int size = 0;
    for (Node p = head; p != null; p = p.next) size++;
    return size;
}
/* 我偏要递归,万物皆递归,需要额外空间 O(N) */
public int size(Node head) {
    if (head == null) return 0;
    return size(head.next) + 1;
}
```

### 写递归的技巧

我的一点心得是:**明白一个函数的作用并相信它能完成这个任务,千万不要试图跳进细节。**千万不要跳进这个函数里面企图探究更多细节,否则就会陷入无穷的细节无法自拔,人脑能压几个栈啊。

先举个最简单的例子:遍历二叉树。

```cpp
void traverse(TreeNode* root) {
    if (root == nullptr) return;
    traverse(root->left);
    traverse(root->right);
}
```

这几行代码就足以扫荡任何一棵二叉树了。我想说的是,对于递归函数`traverse(root)`,我们只要相信:给它一个根节点`root`,它就能遍历这棵树,因为写这个函数不就是为了这个目的吗?所以我们只需要把这个节点的左右节点再甩给这个函数就行了,因为我相信它能完成任务的。那么遍历一棵N叉数呢?太简单了好吧,和二叉树一模一样啊。

```cpp
void traverse(TreeNode* root) {
    if (root == nullptr) return;
    for (child : root->children)
        traverse(child);
}
```

至于遍历的什么前、中、后序,那都是显而易见的,对于N叉树,显然没有中序遍历。



W
wzy 已提交
126
以下**详解 LeetCode 的一道题来说明**:给一颗二叉树,和一个目标值,节点上的值有正有负,返回树中和等于目标值的路径条数,让你编写 pathSum 函数:
L
labuladong 已提交
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263

```
/* 来源于 LeetCode PathSum III: https://leetcode.com/problems/path-sum-iii/ */
root = [10,5,-3,3,2,null,11,3,-2,null,1],
sum = 8

      10
     /  \
    5   -3
   / \    \
  3   2   11
 / \   \
3  -2   1

Return 3. The paths that sum to 8 are:

1.  5 -> 3
2.  5 -> 2 -> 1
3. -3 -> 11
```

```cpp
/* 看不懂没关系,底下有更详细的分析版本,这里突出体现递归的简洁优美 */
int pathSum(TreeNode root, int sum) {
    if (root == null) return 0;
    return count(root, sum) + 
        pathSum(root.left, sum) + pathSum(root.right, sum);
}
int count(TreeNode node, int sum) {
    if (node == null) return 0;
    return (node.val == sum) + 
        count(node.left, sum - node.val) + count(node.right, sum - node.val);
}
```

题目看起来很复杂吧,不过代码却极其简洁,这就是递归的魅力。我来简单总结这个问题的**解决过程**

首先明确,递归求解树的问题必然是要遍历整棵树的,所以**二叉树的遍历框架**(分别对左右孩子递归调用函数本身)必然要出现在主函数 pathSum 中。那么对于每个节点,他们应该干什么呢?他们应该看看,自己和脚底下的小弟们包含多少条符合条件的路径。好了,这道题就结束了。

按照前面说的技巧,根据刚才的分析来定义清楚每个递归函数应该做的事:

PathSum 函数:给他一个节点和一个目标值,他返回以这个节点为根的树中,和为目标值的路径总数。

count 函数:给他一个节点和一个目标值,他返回以这个节点为根的树中,能凑出几个以该节点为路径开头,和为目标值的路径总数。

```cpp
/* 有了以上铺垫,详细注释一下代码 */
int pathSum(TreeNode root, int sum) {
    if (root == null) return 0;
    int pathImLeading = count(root, sum); // 自己为开头的路径数
    int leftPathSum = pathSum(root.left, sum); // 左边路径总数(相信他能算出来)
    int rightPathSum = pathSum(root.right, sum); // 右边路径总数(相信他能算出来)
    return leftPathSum + rightPathSum + pathImLeading;
}
int count(TreeNode node, int sum) {
    if (node == null) return 0;
    // 我自己能不能独当一面,作为一条单独的路径呢?
    int isMe = (node.val == sum) ? 1 : 0;
    // 左边的小老弟,你那边能凑几个 sum - node.val 呀?
    int leftBrother = count(node.left, sum - node.val); 
    // 右边的小老弟,你那边能凑几个 sum - node.val 呀?
    int rightBrother = count(node.right, sum - node.val);
    return  isMe + leftBrother + rightBrother; // 我这能凑这么多个
}
```

还是那句话,明白每个函数能做的事,并相信他们能够完成。

总结下,PathSum 函数提供的二叉树遍历框架,在遍历中对每个节点调用 count 函数,看出先序遍历了吗(这道题什么序都是一样的);count 函数也是一个二叉树遍历,用于寻找以该节点开头的目标值路径。好好体会吧!

## 分治算法

**归并排序**,典型的分治算法;分治,典型的递归结构。

分治算法可以分三步走:分解 -> 解决 -> 合并

1. 分解原问题为结构相同的子问题。
2. 分解到某个容易求解的边界之后,进行第归求解。
3. 将子问题的解合并成原问题的解。

归并排序,我们就叫这个函数`merge_sort`吧,按照我们上面说的,要明确该函数的职责,即**对传入的一个数组排序**。OK,那么这个问题能不能分解呢?当然可以!给一个数组排序,不就等于给该数组的两半分别排序,然后合并就完事了。

```cpp
void merge_sort(一个数组) {
    if (可以很容易处理) return;
    merge_sort(左半个数组);
    merge_sort(右半个数组);
    merge(左半个数组, 右半个数组);
}
```

好了,这个算法也就这样了,完全没有任何难度。记住之前说的,相信函数的能力,传给他半个数组,那么这半个数组就已经被排好了。而且你会发现这不就是个二叉树遍历模板吗?为什么是后序遍历?因为我们分治算法的套路是 **分解 -> 解决(触底) -> 合并(回溯)** 啊,先左右分解,再处理合并,回溯就是在退栈,就相当于后序遍历了。至于`merge`函数,参考两个有序链表的合并,简直一模一样,下面直接贴代码吧。

下面参考《算法4》的 Java 代码,很漂亮。由此可见,不仅算法思想思想重要,编码技巧也是挺重要的吧!多思考,多模仿。

```java
public class Merge {
    // 不要在 merge 函数里构造新数组了,因为 merge 函数会被多次调用,影响性能
    // 直接一次性构造一个足够大的数组,简洁,高效
    private static Comparable[] aux;

     public static void sort(Comparable[] a) {
        aux = new Comparable[a.length];
        sort(a, 0, a.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi) {
        if (lo >= hi) return;
        int mid = lo + (hi - lo) / 2;
        sort(a, lo, mid);
        sort(a, mid + 1, hi);
        merge(a, lo, mid, hi);
    }

    private static void merge(Comparable[] a, int lo, int mid, int hi) {
        int i = lo, j = mid + 1;
        for (int k = lo; k <= hi; k++)
            aux[k] = a[k];
        for (int k = lo; k <= hi; k++) {
            if      (i > mid)              { a[k] = aux[j++]; }
            else if (j > hi)               { a[k] = aux[i++]; }
            else if (less(aux[j], aux[i])) { a[k] = aux[j++]; }
            else                           { a[k] = aux[i++]; }
        }
    }

    private static boolean less(Comparable v, Comparable w) {
        return v.compareTo(w) < 0;
    }
}
```

LeetCode 上有分治算法的专项练习,可复制到浏览器去做题:

https://leetcode.com/tag/divide-and-conquer/


L
labuladong 已提交
264 265


266
**_____________**
L
labuladong 已提交
267

L
labuladong 已提交
268
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章**
L
labuladong 已提交
269

270
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**
L
labuladong 已提交
271

272
<p align='center'>
L
labuladong 已提交
273
<img src="../pictures/qrcode.jpg" width=200 >
274
</p>
L
labuladong 已提交
275

B
brucecat 已提交
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351
======其他语言代码======

### javascript

[437. 路径总和 III](https://leetcode-cn.com/problems/path-sum-iii/)

```js
/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {TreeNode} root
 * @param {number} sum
 * @return {number}
 */
var pathSum = function(root, sum) {
    // 二叉树-题目要求只能从父节点到子节点 所以用先序遍历

    // 路径总数
    let ans = 0

    // 存储前缀和
    let map = new Map()

    // 先序遍历二叉树
    function dfs(presum,node) {
      if(!node)return 0 // 遍历出口

      // 将当前前缀和添加到map
      map.set(presum,(map.get(presum) || 0) +1 )
      // 从根节点到当前节点的值
      let target = presum + node.val

      // target-sum = 需要的前缀和长度
      // 然而前缀和之前我们都存过了 检索map里key为该前缀和的value
      // map的值相当于有多少个节点到当前节点=sum 也就是有几条路径
      ans+=(map.get(target - sum) || 0)

      // 按顺序遍历左右节点
      dfs(target,node.left)
      dfs(target,node.right)

      // 这层遍历完就把该层的前缀和去掉
      map.set(presum,map.get(presum) -1 )
    }
    dfs(0,root)
    return ans
};
```

归并排序

```js
var sort = function (arr) {
    if (arr.length <= 1) return arr;

    let mid = parseInt(arr.length / 2);
    // 递归调用自身,拆分的数组都是排好序的,最后传入merge合并处理
    return merge(sort(arr.slice(0, mid)), sort(arr.slice(mid)));
}
// 将两个排好序的数组合并成一个顺序数组
var merge = function (left, right) {
    let res = [];
    while (left.length > 0 && right.length > 0) {
        // 不断比较left和right数组的第一项,小的取出存入res
        left[0] < right[0] ? res.push(left.shift()) : res.push(right.shift());
    }
    return res.concat(left, right);
}
```