浅谈递归回溯DFS和BFS

4,084 阅读8分钟

前言

我们在解题的过程中经常遇到各种题型的分类,其中有几类是经常交替出现或者同时出现的

  • 递归

  • 回溯

  • DFS

  • BFS

在网上查找相关的概念解释文章会发现,似乎没有一个统一明确的说法。而本篇文章主要用于记述自己对于它们的理解与总结,也许有些地方和大神们理解的有出入,切莫见怪。

递归(Recursion)

递归(英語:Recursion),又译为递回,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。

简单理解,递归就是在函数中调用自身。

我们经常使用递归将复杂的问题分解为简单的问题,来看个简单的例子斐波那契数列

斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。

const F = (n) => {
  // 结束条件
  if (n === 0) return 0
  if (n === 1) return 1

  // 调用自身
  return F(n - 1) + F(n - 2) 
}

我们以F(4)为例,看看是如何将复杂问题分解为简单问题的。

WechatIMG592.png

可以看到递归会出现大量重复的计算,例如在上面的例子中,F(2)被调用了2次,而F(1)被调用了3次。所以说,递归的效率其实比较低,存在大量重复计算。当然,我们可以通过额外数组或对象来保存已经求解的F(n),可以大大减少重复计算。

DFS(Depth-First-Search)

深度优先搜索算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会尽可能深的搜索树的分支。当节点v的所在边都己被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。

简单地说,DFS属于一种遍历算法,不同于一维数组的从左至右遍历,DFS主要用于遍历多维数据结构如。而且DFS在遍历时遇到多个子节点,会递归遍历其中一个,直到叶子节点为止,可以称之为一种不撞南墙不回头的方式。

DFS递归的关系为:DFS一般是通过递归来实现的。(DFS也可以通过栈结构实现)

我们通过个二叉树的例子来理解。

const tree = {
  data: 'A',
  left: {
    data: 'B',
    left: {
      data: 'D',
    },
    right: {
      data: 'E'
    }
  },
  right: {
    data: 'C'
  }
}

const DFS = (node) => {
  // 处理当前节点
  console.log(node.data)

  // 递归遍历左右子树
  // 在递归的过程中先搜索左子树,搜索左子树中又会先搜索左子树的左子树,形成深度优先搜索
  if (node.left) DFS(node.left)
  if (node.right) DFS(node.right)
}

DFS(tree) // A B D E C

我们通过图片来还原下我们的遍历过程

屏幕快照 2022-01-15 下午12.51.29.png

在上面二叉树的遍历中,我们使用绿色来标记当前搜索路径,可以发现DFS总是先往深处遍历直到叶子节点为止,所以最先的遍历路径为A -> B -> D。当搜索到叶子节点时会回退到上级节点继续寻找未遍历的子节点,在例子中是E,所以第二条搜索路径为A -> B -> E

当然 A -> B路径为之前遍历的节点,不会重复遍历它们。所以最后的输出顺序为A -> B -> D -> E -> C

BFS(Depth-First-Search)

广度优先搜索算法(英语:Breadth-First Search,缩写为BFS),又译作宽度优先搜索,或横向优先搜索,是一种图形搜索算法。简单的说,BFS是从根节点开始,沿着树的宽度遍历树的节点。

简单地说,BFSDFS一样,用于遍历多维数据结构BFSDFS的区别在于,BFS是对数据进行分层搜索,先处理本层所有节点,再往下处理下一层节点。

BFSDFS都是用于对多维数据的搜索,两者的区别在于遍历的顺序不同,DFS属于不撞南墙不回头BFS则是层层递进

BFS递归的关系:可以通过递归实现BFS,也可以不使用递归实现BFS。(BFS可以通过队列实现)

我们继续通过上面的二叉树例子来理解

const tree = {
  data: 'A',
  left: {
    data: 'B',
    left: {
      data: 'D',
    },
    right: {
      data: 'E'
    }
  },
  right: {
    data: 'C'
  }
}

const BFS = (nodes) => {
  // 出口
  if (!nodes.length) return

  const next = []

  // 遍历搜索本层数据
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i]
    
    // 处理节点
    console.log(node.data)

    // 收集下层数据
    if (node.left) next.push(node.left)
    if (node.right) next.push(node.right)
  }

  // 递归调用遍历下层数据
  BFS(next)
}

BFS([tree])

我们通过图片来还原遍历过程

屏幕快照 2022-01-15 下午2.29.11.png

首先搜索第一层,也就是根节点A,然后再搜索第二层B -> C,最后搜索第三层D -> E。所以输出顺序为 A -> B -> C -> D -> E

这边再给大家看看非递归实现BFS的方式。

const tree = {
  data: 'A',
  left: {
    data: 'B',
    left: {
      data: 'D',
    },
    right: {
      data: 'E'
    }
  },
  right: {
    data: 'C'
  }
}

const BFS = () => {
  // 借助队列先进先出
  const queue = [tree]

  // 遍历队列
  while(queue.length) {
    // 弹出首节点
    const node = queue.shift()

    // 处理首节点
    console.log(node.data)

    // 往队尾添加左右子节点
    if (node.left) queue.push(node.left)
    if (node.right) queue.push(node.right)
  }
}

BFS([tree])

BFS和DFS对比

我们在上面已经说过两者的差异主要在于遍历顺序的区别,我们这边主要来说说其遍历实现中借助的数据结构有何区别。

先说结论

  • BFS 借助队列实现

  • DFS 借助栈实现

屏幕快照 2022-01-15 下午12.51.29.png

我们先来看看DFS,从图中可以看出,在遍历的过程中,通过 A -> B -> D,之后推出 D 再搜索 E。再依次推出 EB 再推入 C。在这样一个往末尾推入推出的过程实际就是操作的方式,后进先出。

其中的栈是通过函数的递归调用及函数执行结束形成的

屏幕快照 2022-01-15 下午2.29.11.png

BFS的第二种实现方式很好地解释了BFS是借助队列模式实现的,其操作方式为先进先出。第一种实现方式同理为队列的实现,在此就不多说了。

回溯(backtracking)

回溯法(英语:backtracking)是暴力搜索法中的一种。回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现,现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。

简单地说,回溯通过试错来寻找答案。它将一件事件分为多步解决,在尝试的过程中如果发现当前分步无法解得正确答案(这时会放弃当前步我们称之为剪枝)则回退到上步或者上上步,继续尝试其它解决方案。

递归回溯的关系:回溯主要体现的是不断试错及回退的算法思想,而递归主要的是指函数调用自身的算法操作,我们经常使用递归来实现回溯

DFS回溯的关系:DFS主要表示多维数据的遍历搜索算法,但是其搜索过程中使用的推入推出就很符合回溯的思想,如果我们需要寻找答案,比如目标节点或者目标路径,就可以说当前的DFS是一种回溯。也所以说,DFS天然地符合了回溯的思想。

我们来看个回溯的例子(leetcode-77

屏幕快照 2022-01-15 下午3.43.23.png

var combine = function(n, k) {
  // 答案
  const ans = []

  const backTrack = (list, m) => {
    // 不符合条件剪枝
    if (m - 1 > n) return

    // 当前步已经不符合答案对其剪枝
    if (list.lngth > k) return
    
    if (list.length === k) {
      // 添加正确答案
      ans.push([...list])
      return
    }

    // 我们把当前步的操作分为两种情况

    // 1.选择当前数字
    list.push(m)
    backTrack(list, m + 1)
    // 递归完成后应该弹出当前选择以达到回溯
    list.pop()

    // 2.不选择当前数字
    backTrack(list, m + 1)
  }

  backTrack([], 1)

  return ans
};

上面我们通过例子来说明了如何实现回溯。实际上面的算法,虽然原始数据是一维数组,但实际上我们可以将其认为是一种DFS算法。我们展示下上面回溯的实现就可以明白。

combine(4, 3)为例子,红线部分为剪枝。

屏幕快照 2022-01-15 下午4.04.08.png

总结

我们来总结下本篇的知识点

  1. 递归是指函数调用自身

  2. DFS是通过递归实现的深度优先遍历算法(修正:可不通过递归)

  3. BFS是和DFS对应的广度优先遍历算法,可使用递归或者不使用递归实现

  4. DFS借助栈实现,而BFS借助队列实现

  5. 回溯是种分步试错的算法思想,错了就要回退上一步所以称为回溯。回溯经常使用递归来实现,而DFS的实现就很符合递归的思想。(回溯经常通过某些条件来剪枝)