前端仔的“数据结构与算法”之路——递归

204 阅读20分钟

你说说递归是啥?

递归,这个词我想大家都不陌生。经常都能从别人那听到或是文档中看到。甚至前两年的面试中都经常被问到,知道递归不?
想起以前,**我懵懵懂懂的答到,递归就是函数调用函数,自己调用自己。**因为经常看到别人的代码这么写:

function find(x,x){
	...
	...
	return find(x,x)
}

其实,这逻辑还真是最简单的理解,它没有错。只是我们如果理解只停留在这个层面是完全不够的
递归是一种很常见的算法,而且在日常开发中,使用频率也非常高,可以说是一种解决我们某种功能需求的编程技巧。
场景:
复杂点的,购物商城的商品分类功能:
WechatIMG310.png
随意列举了一些,简单理解就是分类标签下还有分类标签。
而且往往这类标签存储在数据库中时是这样的:

idnamechild_id
1食品3,4
2家电5,6
3零食...
4熟食...

一个列表记录了所有的分类标签时,我们怎么将它们关联起来,找到父子关系?就是通过child_id,它指出了该标签的子标签。
假设我们需要展示食品标签下的所有标签,我们怎么做呢?

  • 遍历食品标签的child_id
  • 在这些id对应的数据行中再次遍历child_id
  • 反复如此,知道child_id为空

思路很简单,转化成代码时,我们怎么实现呢?这个标签可能有很多层,一直写循环遍历好像也不好解决。这时用递归的技巧编写代码就很合适。直接看伪代码:

// 假设我们要将属于该标签的所有标签加入此数组展示
const res = []
function find(fId){
  // 加入数组
  res.push(fId)
  for(let i = 0; i < data[fId].child_id.length;i++){
    // 循环遍历child_id,获得的id再次执行find函数
  	find(data[fId].child_id[i])
  }
}

或许有时我们并不熟悉递归,以过往的编程经验也是能写出这样的代码。但是的确这就是递归技巧的基本使用方式。

还有一个简单的场景,也是在极客时间里的一段例子:
你和朋友此时在电影院随意落座了,黑灯瞎火,你不知道自己是第几排。有这么一种方式,你可以向前排的兄弟问问,他是多少,你+1就知道了。然而前排那兄弟也不知道自己第几排,于是他也再向前问,直到第一排的同学看到前面没人了,才开始向后回答我第一排。一个接一个,传回你这,答案就出来了。
有一个先递进后回归的感觉,这也是递归的一种理解。

什么情况我们能使用递归这种技巧呢?

递归的理解,想必大家心中都有一杆秤了。在什么情况下可以用递归这种技巧解决问题呢?根据学习和理解对以下这三点解释解释。

  • 一个大问题,可以拆解成一环又一环的子问题,通过子问题的结果获取最终答案。
    分类标签的例子,找到所有标签,可以拆分为一层一层标签的寻找。找到第一层,再根据第一层的child_id找下一层。
  • 拆解出来的子问题都有同样的解决思路。电影院找排号这个例子中,每一个子问题的解决思路就是“问上一排”。
  • 递归需要有终止条件,这样代码才不会无限循环。
    电影院的终止条件是,找到第一排,前面已经没有了。可以return。
    标签问题的终条件其实是child_id数组还有没有值,没有的话for循环也不会继续执行了。

这三点很关键,需要在代码题中反复的品,才能找到感觉,看你悟性了哈。⚠️⚠️

递归中注意⚠️的问题

1、堆栈溢出

递归操作很容易出现这样的问题,看前面的为代码也可以看出,真的就是函数中调用函数,一直一直调用。我们知道在系统中,函数的调用会使用栈来保存临时变量,每调用一个函数,会将临时变量压入栈再压入内存中,直到函数执行结束,才出栈。一般在系统中的栈内存都不会很大,如果代码递归层次真的很多就会有栈溢出的问题。会引起系统程序的崩溃卡死。
怎么避免这种情况的发生呢?

  • 简单粗暴的做法就是限制递归的深度,假设是电影院问题,我们限制最大查找1000排,之类的就不再向前询问了。
  • 还有就是借助编译器的优化,书写符合优化的尾递归代码。

什么是尾递归呢?

如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。

当[编译器]检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。

举个阶乘的例子:

function fac(n) {
  if (n === 1) return 1;
  return n * fac(n - 1);
}

fac(5) // 120

此时的执行fac的栈情况
fac(5)

(5*fac(4))

(5*(4*fac(3)))

(5*(4*(3*fac(2))))

(5*(4*(3*(2*fac(1)))))

(5*(4*(3*2)))

(5*(4*(6)))

(5*24)

120
// 临时变量n需要与函数的结果相乘,所以函数调用栈不断被压入内存栈中,如果深度太大,容易栈溢出。

// 尾递归
function fac(n,a) {
  if (n === 1) return a;
  return fac(n - 1,a * n);
}

fac(5,1) // 120

fac(5,1)

fac(4,5)

fac(3,20)

fac(2,60)

fac(1,120)

// 经过优化,函数中所有递归形式的调用都出现在函数的末尾,而且没有其他计算,编译器就会将调用栈覆盖之前的
// 而不是继续压栈。减少了空间的浪费。

2、递归代码注意避免重复计算

举个出现频率非常高的例子。爬楼梯问题。我们从这个问题中去观察这种重复计算的情况。

假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?

解决思路

  • 尝试将主问题拆分。假设我们已经走了k级,我们是怎么走到k的呢?从k-1级走1步,从k-2级走2步。所以走到k级台阶的走法就是只有两大种,k-1走过来,k-2走过来。得出结论
    f(k)=f(k-1)+f(k-2)
    f(k)表示走到k级总共有多少种方式。
  • 主问题拆分了,字问题其实和主问题一样。f(k-1)=f(k-1-1)+f(k-1-2)
  • 边界条件是什么呢,一般都是初始情况。这里当k=1时,当然只有一种走法,k=2时,简单想有两种走法。
    f(0)=0,f(1)=1,f(2)=2

我们尝试写出代码

function step (n) {
  if (n <= 2 && n > 0) return n
  return step(n - 1) + step(n - 2)
}

想想,当我们求解到step(6)时,需要求解step(5)和step(4)。我们求解step(5)时,又需要求解step(4)和step(3)。
这里step(4)重复计算了。这种问题在递归种很常见,它也不是错,就是浪费了很多算力。一般这种情况我们可以用一个Map或对象将每个计算过的步骤存储下来,下次再计算时先去Map里看,有没有计算过,有就可以直接取出结果。相当于用空间换取了时间。

3、千万陷入思维误区!!!

有时我们遇到递归问题,总思考这子问题怎么解决,怎么调用子问题。总是想理清楚每一层的关系。这是很不对的,会让我们陷入进去,因为复杂的递归问题用人脑去理解会绕死,很难去理解每一层的关系。并不是你不会,而是这种思维技巧就适合转变成指令交给计算机处理。千万别浪费时间精力试图去理解每一层。我们只需要抽象成递推公式比如f(k)=f(k-1)+f(k-2)。专注解决这层的传递关系就可以了,全局有个大概的把握即可。

好好,概念理论都差不多了。实战来了!!

leetcode实战

剑指 Offer 10- I. 斐波那契数列👈

经典题,就是爬楼梯的专业名词版。

问题:

写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项。斐波那契数列的定义如下:

F(0) = 0,   F(1) = 1 F(N) = F(N - 1) + F(N - 2), 其中 N > 1. 斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。

答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。

示例:

输入:n = 2
输出:1

输入:n = 5
输出:5

思路:

题目中已经把思路表达清晰,帮助我们把问题拆解成子问题了。而且还表明了终止条件。

  • 拆解的子问题:F(N - 1) + F(N - 2)
  • 子问题的处理逻辑都相同,就是继续拆解成两个子问题,然后相加
  • 终止条件也明确:F(0) = 0,   F(1) = 1

先无脑写一波递归。

代码:

var fib = function (n) {
	// 这种写法会有重复计算的问题
    if (n < 2) return n
    return (fib(n - 1) + fib(n - 2))% 1000000007
};

递归的写法能让代码很精简,可读性也不错。但是目前的解法提交时,会超出时间限制。证明重复计算浪费了很多时间。我们先解决计算重复问题再看看。

var fib = function (n) {
    const map = new Map()
    function f(n) {
        if (n < 2) return n
        const f1 = map.has(n - 1) ? map.get(n - 1) : f(n - 1)
        const f2 = map.has(n - 2) ? map.get(n - 2) : f(n - 2)
        const res = (f1 + f2) % 1000000007
        map.set(n, res)
        return res
    }
    return f(n)
};

我们利用map结构保存了计算值,减少了重复计算。顺利的通过了提交。

递归代码可以改成迭代

其实到这,这类递归函数还是可以优化的,递归的优势精简、表达能力强,缺点就是空间复杂度高、还容易栈溢出。对于这种,我们还可以将递归算法改造成迭代算法。迭代不会开辟新的函数栈,不会有溢出的风险,但是同样的可读性比递归低很多。
其实递归这种技巧基本都可以用迭代来表示,只不过理解成本高,而且要把握好迭代的终止条件。根据情况,对于递归问题,我们看着处理。但是这篇的主要目的还是掌握递归为主。
这个问题怎么转化成迭代呢?

我们把方向换一换。我们已知f(0),f(1),f(2)。我们可以从头开始,一步一步求解到f(n)。先求f(3),4,5...然后再到n就好了。反正我们公式已知。

var fib = function (n) {
    if (n < 2) return n
  	// 用前两个值,求第三个值
    let fpre = 0
    let f = 1
    for (let i = 2; i <= n; i++) {
      // 通过循环不断的求解下一个值,并赋值给f、fpre
        [fpre, f] = [f, (f + fpre) % 1000000007]
    }
    return f
};

这个迭代的过程相当于递归中的了。

面试题 08.06. 汉诺塔问题👈

经典汉诺塔问题来了,老生常谈了。

问题:

在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制: (1) 每次只能移动一个盘子; (2) 盘子只能从柱子顶端滑出移到下一根柱子; (3) 盘子只能叠在比它大的盘子上。

请编写程序,用栈将所有盘子从第一根柱子移到最后一根柱子。

你需要原地修改栈。

提示:

  1. A中盘子的数目不大于14个。

示例:

 输入:A = [2, 1, 0], B = [], C = []
 输出:C = [2, 1, 0]

 输入:A = [1, 0], B = [], C = []
 输出:C = [1, 0]

思路:

别问,问就是不会。真的第一次遇见这题目时,直接傻掉。根本不懂怎么做,想都想不出还写成代码??
都是看了各种解释,自己才明白的。

  • 首先,它可以用递归的思想解决问题。我们就想办法找出怎么拆解成子问题,或者递推公式。
  • 其实将A柱子的圆盘按规则移动到C只有3步
  1. 把A柱子想象成只有两个圆盘,把A柱子除最底下的所有盘子移动到B
  2. 把A柱子剩余的最底下的盘子移动到C
  3. 把B柱子的全部盘子移动到C

完事。

3个步骤完全遵守了规则。其中第一步和第三步就是进入递归的入口。

  • 子问题就是前面拆解的3步是一个完整的字问题。
  • 字问题的处理逻辑都相同,就是3步的逻辑代码。
  • 终止条件就是,当只有一个盘子需要移动时,直接移动,不需要继续递归了。

代码:

/**
 * @param {number[]} A
 * @param {number[]} B
 * @param {number[]} C
 * @return {void} Do not return anything, modify C in-place instead.
 */
// 这种写法会栈溢出
var hanota = function (A, B, C) {
    function move(a, b, c) {
        if (a.length === 1) {
          // 终止操作,直接移动
            c.push(a.shift())
        } else if (a.length > 1) {
          // 移动3步曲
            const bottom = a.shift()
            move(a, c, b)
            c.push(bottom)
            move(b, a, c)
        }
    }
    move(A, B, C)
}

我最开始的理解是这么写的,但是当我提交时。栈溢出了,扎心。思路是对的,仔细看了看发现我在每个函数中保存了临时变量bottom来存储A柱子最底下的盘子。看来这种写法不行,得想办法不用临时变量,又能明确出移动盘子时保留最底下的盘子。
于是我又看了看别人的解答,一开始真没想出来怎么改。

/**
 * @param {number[]} A
 * @param {number[]} B
 * @param {number[]} C
 * @return {void} Do not return anything, modify C in-place instead.
 */
var hanota = function (A, B, C) {
    function move(n, a, b, c) {
        if (n === 1) {
            c.push(a.pop())
        } else if (n > 1) {
            move(n - 1, a, c, b)
            c.push(a.pop())
            move(n - 1, b, a, c)
        }
    }
  	// 多传递一个数字,表示需要移动盘子的个数
    move(A.length, A, B, C)
}

698. 划分为k个相等的子集👈

这也是常考题,亲测在大厂面试中出现过。重点⚠️⚠️

问题:

给定一个整数数组  nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。 提示:

  • 1 <= k <= len(nums) <= 16
  • 0 < nums[i] < 10000

示例:

输入: nums = [4, 3, 2, 3, 5, 2, 1], k = 4
输出: True
说明: 有可能将其分成 4 个子集(5),(1,4),(2,3),(2,3)等于总和。

思路:

  • 计算出每个子集的和
  • 尝试将所有元素划分到子集中
  • 重点是子集的和相同

第二第三点就是本题的重点,怎么找到每个子集?
对这类划分子集的题目,如果没什么头绪,比较菜,其实可以先入为主的假设它可以用递归解决,看看符不符合递归的需求。事实这类题型的确可以用递归解。

将问题拆解成子问题?
我们的目的其实是找子集,找k个和找一个其实都是一样。先拆解出第一层,找子集。第二层我们需要找元素,对于元素而言,无非就是大小不同,找到后看看有没有超出子集和。

子问题的解决思路相同吗?
其实找一个子集和k个子集过程都是一样的,都是要判断和有没有超过大小,这个元素有没有被使用过。找元素也是一样,元素被别的子集用了?加入元素后有没有超过和?所以它们解决子问题的思路都是一样的。

终止条件?
对于找元素而言,加入元素后,大小等于和,这个子集收集完毕。
所有元素都找过后,子集没能拼凑完成,答案返回false。

对于子集而言,有k个子集需要收集,收集完毕。达到终止条件,返回true。

经过三点的分析,我们思路已经很清晰了。还有细节问题看代码注释即可。
⚠️注意一下怎么标记元素是否被选取,还有避免重复计算的逻辑。
先看看代码,不理解再看看思路解析。

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {boolean}
 */
var canPartitionKSubsets = function (nums, k) {
    // 判断边界条件,分组大于数组长度************
    if (k > nums.length) return false
    let total = 0
    for (let n of nums) {
        total += n
    }
    // 单组和
    let sum = total / k
    // 单组和,是分数,返回false
    if (sum !== parseInt(sum)) return false
    // 为什么要排序?逻辑上先从大的元素开始查找,这样才不会遗漏
    nums.sort((a, b) => b - a)
    // 数组中最大值大于单组和,返回false
    if (nums[0] > sum) return false
  	// 判断边界条件**************************
    // 记录已经选取的元素
    const visited = new Array(nums.length).fill(0)
    // 递归 拆分任务
    // 寻找全部组合拆分为寻找单个组合,再拆分为寻找单个元素
    // 参数有单组和(taget)、还需要寻找组合的个数(j)、寻找起点位置(start)
    function find(target, j, start) {
        if (j === 0) return true
        for (let i = start; i < nums.length; i++) {
          	// 避免重复计算
            if (visited[i]) continue
          	// 如果之前的元素和当前的相同,且没被选择,证明该元素不适合当前子集
            if (nums[i] === nums[i - 1] && visited[i - 1] === 0) continue
          	// 假定选取该元素,然后进入下方两个判断,是否需要收集
            visited[i] = 1
            if (target - nums[i] > 0 && find(target - nums[i], j, i + 1)) return true
            if (target - nums[i] === 0 && find(sum, j - 1, 0)) return true
          	// 不符合,不收集
            visited[i] = 0
        }
        return false
    }
    return find(sum, k, 0)
};

783. 二叉搜索树节点最小距离👈

二叉树的问题很难离开递归,由于树这种数据结构的特性,它们两个往往会同时出现。

问题:

给定一个二叉搜索树的根节点 root,返回树中任意两节点的差的最小值。
注意: 二叉树的大小范围在 2 到 100。 二叉树总是有效的,每个节点的值都是整数,且不重复。

本题与 530:leetcode-cn.com/problems/mi… 相同

示例:

输入: root = [4,2,6,1,3,null,null]
输出: 1
解释:
注意,root是树节点对象(TreeNode object),而不是数组。

给定的树 [4,2,6,1,3,null,null] 可表示为下图:

          4
        /   \
      2      6
     / \    
    1   3  

最小的差值是 1, 它是节点1和节点2的差值, 也是节点3和节点2的差值。

思路:

注意题目,题目是二叉搜索树中的任意两点的差值。出现了两个关键词。

二叉搜索树的特性,百度百科:

它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。二叉搜索树作为一种经典的数据结构,它既有链表的快速插入与删除操作的特点,又有数组快速查找的优势;所以应用十分广泛,例如在文件系统和数据库系统一般会采用这种数据结构进行高效率的排序与检索操作。

关键就是它左树的所有节点都比根节点小,右树的所有节点都比根节点大。而且左子树和右子树都必须遵守这个规则。
二叉搜索树,其实我们可以联想到它是一个排好序的数据结构。

下面👇来解题
我们的解决思路是只要获得树中所有的节点,并按大小顺序排列好。我们直接遍历比较前后两个元素的差值就好了

  • 访问树,用递归,将树拆解成一个一个的节点。
  • 处理逻辑相同,只是访问,获取节点值。
  • 终止条件是节点为null。
  • 采用树的中序遍历(深度优先遍历的一种),示例中树的中序遍历结果是1,2,3,4,6

代码:

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @return {number}
 */
var minDiffInBST = function (root) {
    let min = Infinity
    let nodes = []
    function tree(node) {
        if (node) {
          	// 中序遍历,左=>根=>右
            tree(node.left)
            nodes.push(node.val)
            tree(node.right)
        }
    }
    tree(root)
  	// 中序遍历的结果是排好序的
    for (let i = 1; i < nodes.length; i++) {
        min = Math.min(min, Math.abs(nodes[i] - nodes[i - 1]))
    }
    return min
};

894. 所有可能的满二叉树👈

在二叉树的使用中,几乎都离不开递归技巧。 最简单的二叉树遍历就是递归。这题就更复杂一些。

问题:

满二叉树是一类二叉树,其中每个结点恰好有 0 或 2 个子结点。

返回包含 N 个结点的所有可能满二叉树的列表。 答案的每个元素都是一个可能树的根结点。

答案中每个树的每个结点都必须有 node.val=0。

你可以按任何顺序返回树的最终列表。

示例:

输入:7
输出:[[0,0,0,null,null,0,0,null,null,0,0],[0,0,0,null,null,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,null,null,null,null,0,0],[0,0,0,0,0,null,null,0,0]]

fivetrees.png

思路:

再次强调一下什么是满二叉树:满二叉树是一类二叉树,其中每个结点恰好有 0 或 2 个子结点。
我们再次理解一下题目,就是给定节点数,返回所有可能组成满二叉树的情况,返回是数组。

对于二叉树,我们怎么拆分问题?
每个二叉树都有一个根节点,我们以根节点的视角去看,二叉树其实可以分为3部分,根节点,左树,右树
我们只需要找出左树的所有情况(l)和右树的所有情况(r),两两组合就是我们需要的答案,(l*k)种树型。
找左树或者右树就是我们拆分的字问题。
通过递归我们可以获取左、右树的所有情况。

子问题的解决思路是否相同?
左树和主问题有什么区别呢?区别就是节点数不同,假设总节点数为7,根节点数占了一个。左树和有树的节点和只能是6,6节点的分配方式有(1,5),(2,4),(3,3),(4,2),(5,1)。其余的过程又相当于一个完整的主问题。

终止条件是什么呢?
当剩余被分配的节点数为1时,只有一种可能,直接返归[root]。还有一种情况,如果节点数是偶数,是不可能组成满二叉树的,直接返回[]

根据思路我们先写一个递归的框架,循序渐进。可能是伪代码

function find (k) {
  // 结果情况数组
  const arr = []
  // 两个终止条件
  if (k === 1) {
    arr.push(new tree(0))
  } else if (k % 2 === 1) {
    for (let i = 1; i < k - 1; i++) {
      // 找左右树
      const left = find(i)
      const right = find(k - 1 - i)
      // 满二叉树 左右都必须有节点
      for (const l of left) {
        for (const r of right) {
          // 两两组合
          const root = new tree(0)
          root.left = l
          root.right = r
          arr.push(root)
        }
      }
    }
  }
  return arr
}

整体框架已经有了,我们注意,递归经常会出现重复计算的情况。依据节点数找树,会有很多重复的情况。我们用map结构来保存一下。
完整代码:

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {number} N
 * @return {TreeNode[]}
 */
var allPossibleFBT = function (N) {
    const map = new Map()
    function tree(k) {
        if (!map.has(k)) {
            map.set(k, [])
            if (k === 1) {
                // 只有一个节点的情况
                map.set(k, [new TreeNode(0)])
            } else if (k % 2 == 1) {
                const arr = map.get(k)
                // 有k个节点,根节点占据一个,剩余的按序分配给左右节点
                for (let i = 1; i < k; i++) {
                    const left = tree(i)
                    const right = tree(k - 1 - i)
                    // 新建根节点,左右节点都存在,或者左右节点都不存在
                    for (const l of left) {
                        for (const r of right) {
                            const node = new TreeNode(0)
                            node.left = l
                            node.right = r
                            arr.push(node)
                        }
                    }
                }
                map.set(k, arr)
            }
        }
        return map.get(k)
    }
    return tree(N)
};

总结:

怎么使用递归

  • 问题可以拆解成子问题,通过子问题的结果获取最终答案。
  • 子问题都有相似的处理逻辑,只是数据规模不一样。
  • 子问题有终止条件。

递归中注意的问题

  • 堆栈溢出。
  • 递归中重复计算相同规模的结果。
  • 千万别陷入思维误区,不要试图理解每一个递归子问题的过程。