同事想用递归,被我一个深度遍历打断施法

前言

某一天,我正在愉快的敲着代码,听到隔壁同事在嘀咕一个功能的实现方案,疑似树形结构数据的查询。抱着好奇好学的心态,于是我凑了过去。

我: 你是在搞多层题组的查询吗?

同事: 是啊,题组里还有题组,最底层的题组里放着题目id,想封装个函数找出一个题组下的所有题目id。

我: 那你有什么方案实现吗

同事: 看这种树形的数据结构,打算用递归一层一层找了。

我: 那倒不至于,可以借鉴下栈形式的深度遍历思想,一个循环就搞定了,复杂度低,代码易于理解,还不会爆栈。

同事: 还有这种操作?讲讲怎么实现?

随后,我拿起本子和笔跟同事讲解了起来...

深度遍历

深度遍历的思想主要概括为两点:

  • 以一个未被访问过的顶点作为起始点,沿着顶点的边一直向下查找未被访问过的点
  • 当查找不到可以访问的点,则返回上一个顶点,继续向下查找,直到所有的点都被访问过

简单来说就是“一条路走到黑,不撞南墙不回头”,也突出了“”的特点。

实现深度遍历有递归和非递归版本的,本文主要讲解非递归版本的,使用数据结构来实现,其特点是“先入后出”。在深度遍历中,通常会选择最新的数据最为候补顶点,在候补顶点的管理上就可以使用栈。

TTQLPU.jpg

图解过程

俗话说的好,千言万语不如一张图。下面将通过图解的变化来解析深度遍历的过程。在此之前先拟定好一份数据结构。

{
  value: 'A',
  children: [
    {
      value: 'B',
      children: [
        {
          value: 'D',
          children: [
            {
              value: 'H',
              children: []
            }
          ]
        },
        {
          value: 'E',
          children: []
        }
      ]
    },
    {
      value: 'C',
      children: [
        {
          value: 'F',
          children: []
        },
        {
          value: 'G',
          children: []
        }
      ]
    }
  ]
}
复制代码

把数据转为二叉树结构,像这样:

T7VbHU.png

1. 此时从顶点A开始遍历

T7HfoD.png

A进栈,栈顶代表当前顶点

T7b57T.png

2. 向下查找到B

T7qQ3j.png

A出栈,B、C进栈

A遍历后,代表已访问,栈中不需要存放,出栈。

可能你会好奇为什么是B、C一起进栈,而不是B进栈,还记得我们上面说的“当查找不到可以访问的点,则返回上一个顶点”,所以我们需要在栈中先存放好之后会遍历的候补顶点,以便于后续返回查找顶点,而此时的C就是候补顶点。如果不理解,也没关系,跟着流程走下去就会慢慢明白了。现在只需要记住栈顶的点就是当前顶点即可。

TH4u7Q.png

3. 向下查找到D

T7LaeP.png

B出栈,D、E进栈

TH4lhn.png

4. 向下查找到H

T7L5YF.png

D出栈,H进栈

TH4NBF.png

5. 向下无结点,返回上一个顶点E

T7OOjs.png

H出栈

T7XdPS.png

6. 向下无结点,返回上一个顶点C

T7XrKs.png

E出栈

T7X4xJ.png

7. 向下查找到F结点

T7jeMj.png

C出栈,F、G进栈

T7j3JU.png

8. 向下无结点,返回上一个顶点G

T7jWwt.png

F出栈

TH42He.png

9. 遍历结束

G出栈,栈空,向下查找无结点,整个遍历过程结束。

JS实现

/**
 * 深度遍历查找
 * @param {*} tree 树形数据
 * @param {*} target 想要查找的目标
 */
function DFS (tree, target) {
  // 模拟栈,管理结点
  let stack = [tree]
  while (stack.length) {
    // 栈顶节点出栈
    let node = stack.pop()
    // 查找到目标,退出
    if (node.value === target) {
      return target
    }
    if (node.children && node.children.length) {
      // 将候选顶点入栈,进行下一次循环
      stack.push(...node.children.reverse())
    }
  }
}
复制代码

reverse 是为了遵循深度遍历的思想,沿着边向下遍历。回想上面的流程图,A遍历完后,下一个节点是B,如果这里不进行反转,直接push,栈内就会变成[B, C],从栈顶取出的结点将会是C。

解决问题

回顾下需要解决的问题,查找多层题组下的题目,每一层题组下可能有题组列表,可能有题目,不管层级多深,需要的是找到题组下所有题目并存储起来。

深度遍历是为了查找某一个结点,而参照这个思想去解决上面所说的场景时,需要做些变式。

  • 不需要查找结点,而是全部遍历找出题目子级,遍历过程不设置目标终止条件
  • 不需要强行遵循沿边遍历,即不用reverse
  • 栈会将已访问的数据出栈,需要另建一个数组存储所需的数据

得出方案:

/**
 * 查找题组下的所有题目
 * @param {*} tree 树形数据
 */
function findQuestions (tree) {
  // 模拟栈,管理结点
  let stack = [tree]
  let res = []
  while (stack.length) {
    // 栈顶结点出栈
    let node = stack.pop()
    // 题组有题目
    if (node.question_list && node.question_list.length) {
      // 存储需要的数据
      res.push(...node.question_list)
    }
    // 题组下还有组
    if (node.sub_group_list && node.sub_group_list.length) {
      // 将候选顶点入栈,进行下一次循环
      stack.push(...node.sub_group_list)
    }
  }
  return res
}
复制代码

总结

本文通过图解析深度遍历的过程,并参考其思想做了些变式应用在实践中。个人觉得这种使用栈管理树形数据遍历的思想非常不错,相比于递归实现,代码复杂度和空间复杂度要低而且易于理解,最重要的是递归在每往下进行一次都会占据内存,数据层级一旦深,就会引发栈溢出的错误。在以后的业务场景中,如果遇到树形结构遍历时不妨试试这种思想。

Guess you like

Origin juejin.im/post/7049882206000381965