图15.png

壹 ❀ 引

我在从JS执行栈角度图解递归以及二叉树的前、中、后遍历的底层差异一文中,从一个最基本的数组遍历引出递归,在掌握递归的书写规则后,又从JS执行栈角度解释了二叉树三种深度优先(前序、中序后序)的底层差异,帮助大家站在模板的角度上深入理解模板。而二叉树还剩一种广度优先(也称层序遍历)也使用广泛,但考虑到篇幅问题,所以还是打算另开一篇文章讲解。

其实相对深度优先,广度优先的模板要好理解的一些,毕竟它没有让人头疼的递归,且我们只用维护好一个队列queue(先进先出)即可,但考虑到刚接触算法人基础不同,我们还是用图解以及更直白的话术来讲解层序遍历的模板,那么本文开始。

贰 ❀ 壹 从队列与栈说起

其实通过前文二叉树深度遍历的讲解,所谓前序、中序与后序遍历的差异,本质上是将做一件事的动作放在了递归前后不同位置,而这个原理其实依赖了JS执行栈先进后出的特性,这是JS底层帮我们做的事。那我们自己能不能模拟栈呢?显然是可以的,通过数组的pushpop方法,比如数字1 2 3我可以先全部push到一个空数组中变成[1, 2, 3],然后再利用pop依次弹出就能倒序访问变成3 2 1

  1. const stack = [];
  2. // 先进
  3. stack.push(...[1, 2, 3]);
  4. while (stack.length > 0) {
  5. // 后出
  6. console.log(stack.pop());// 3 2 1
  7. }

用图来表示这个过程:

图1.png

而栈的特性是先进后出,我们是不是一样可以用数组模拟这个过程,别忘了数组还有shift方法:

  1. const stack = [];
  2. stack.push(...[1, 2, 3]);
  3. while (stack.length > 0) {
  4. console.log(stack.shift()); // 1 2 3
  5. };

图2.png

理解了用数组模拟队列的过程,那么层序遍历就理解了一半了。

叁 ❀ 图解层序遍历

我们来看一个最基本的二叉树层序遍历例子,如下,如果要层序遍历输出自然是[1,2,3,4,5,6,7],那么怎么做?

图3.png

固定套路,我们可以先定义一个承装结果的数组ans,以及一个模拟队列的数组queue,整个遍历的过程都会检查**queue**的长度,只要length > 0证明还有元素,那就标明可以继续做我们要做的事。

很明显面对上图的二叉树,第一次层自然是根节点,那我们就可以将根节点先塞到queue中:

图4.png

然后开始遍历queue做我们要做的操作,做什么呢?当然是将当前节点的值存起来,之后因为节点1已经访问过了,遵循先进先出的特性,我们需要将1从数组头部弹出。那么问题来了,1要是弹出queue长度直接为0遍历结束,但我们希望继续检查第二层,我们是不是得将1的子节点依次也pushqueue中,于是就有了下面这一步,将2 3入队列,并将1弹出:

图5.png

同理,因为当前queue长度为2,很明显我们能继续遍历,依次将2,3存入ans数组,之后再将2的子节点4,53的子节点6,7存入queue,同时2,3出队列。

图6.png

之后我们再将4,5,6,7的值存入ans,但由于这四个节点没有自己的子节点,所以我们只用让它们四个出队列,也由于没有新的节点进来,queue长度为空,终于整个查找过程结束,我们顺序得到ans [1,2,3,4,5,6,7]

在通过图解解释了这个过程后,我们就能很清晰定义出层序遍历的模板:

  1. const levelTraverse = (root) => {
  2. if (root === null) return;
  3. const queue = [];
  4. const ans = [];
  5. queue.push(root);
  6. // 当队列为空时跳出遍历
  7. while (queue.length > 0) {
  8. // 当前节点出队列
  9. const node = queue.shift();
  10. // 做你要做的操作
  11. ans.push(node.val);
  12. // 别忘了让node的左右子节点进入队列
  13. // 因为不是递归了,加入前还是有必要检查有没有子节点
  14. if (node.left !== null) {
  15. queue.push(node.left);
  16. }
  17. if (node.right !== null) {
  18. queue.push(node.right);
  19. }
  20. };
  21. return ans;
  22. }

肆 ❀ 一道基础的层序遍历面试题

那么图解完层序遍历模板,我们趁热打铁来做道题。我现在公司有一个同事一直负责他们部门的前端招聘工作,有一次他跟我说今天一个来面试候选人有点厉害,算法10分钟就写出来了,我出于兴趣就问他考的什么算法题?于是他就把题目发给我了,题目如下:

  1. // 树结构的广度优先遍历(BFS)-(建议做题时长:20分钟)
  2. // 从后端拿到了一个文档节点树结构,请按广度优先遍历(BFS)的顺序,返回该树结构的所有节点名字。
  3. // 树节点列表的数据说明:
  4. // 节点定义
  5. const NodeType = {
  6. name: '<Title>' // 节点名字
  7. children: [], // 子节点列表
  8. }
  9. // 样例数据
  10. const ExampleTreeRoot = {
  11. name: "Top",
  12. children: [{
  13. name: "Level 1",
  14. children: [{
  15. name: "Level 1-1",
  16. children: [],
  17. },
  18. {
  19. name: "Level 1-2",
  20. children: []
  21. }
  22. ],
  23. },
  24. {
  25. name: "Level 2",
  26. children: [{
  27. name: "Level 2-1",
  28. children: [],
  29. },
  30. {
  31. name: "Level 2-2",
  32. children: []
  33. }
  34. ]
  35. }
  36. ]
  37. }
  38. // 按广度优先遍历返回的结果:
  39. [
  40. "Top",
  41. "Level 1",
  42. "Level 2",
  43. "Level 1-1",
  44. "Level 1-2",
  45. "Level 2-1",
  46. "Level 2-2"
  47. ]

我一看,这不就是个最基础的层序遍历吗,你就拿这个考验干部,大家可以结合上面的讲解思路自己做做此题,答案如下:

  1. const levelTraverse = (root) => {
  2. if (root === null) return;
  3. const queue = [];
  4. const ans = [];
  5. queue.push(root);
  6. while (queue.length > 0) {
  7. const node = queue.shift();
  8. ans.push(node.name);
  9. queue.push(...node.children);
  10. };
  11. return ans;
  12. }

我就跟他说,你这考的也太简单了,不如再做个变体,我希望层序遍历输出的同时,同层的元素能装在同一个数组里:

图7.png

大家可以先自己尝试下怎么写,其实思路还是一样,只是在遍历某一层时,我们需要额外再定一个levelArr来装这一层的数组,代码如下:

  1. const levelTraverse = (root) => {
  2. if (root === null) return;
  3. const queue = [];
  4. const ans = [];
  5. queue.push(root);
  6. while (queue.length > 0) {
  7. // 创建每一层的数组
  8. const levelArr = [];
  9. // 获取每一层的长度
  10. const len = queue.length;
  11. // 每一层的操作单独再交给一个for来处理
  12. for (let i = 0; i < len; i++) {
  13. const node = queue.shift();
  14. levelArr.push(node.name);
  15. queue.push(...node.children);
  16. }
  17. ans.push(levelArr);
  18. };
  19. return ans;
  20. }

这种做法跟之前有什么区别呢?之前我们始终维护一个queue,然后不断的入队列——判断队列长度——出队列——记录值——将子加入队列。但是这个过程很明显不能让每一层的元素在同一个数组,因此我们每次处理某一层之前,先创建一个levelArr,之后我们肯定得知道当前层有几个数组,因此额外需要遍历一次,依次将当前层元素的值加入到levelArr,直到这一层遍历完成,我们值记录也完成了,同时这一层节点的子节点也被加入到queue了。

简单点来理解就是,我们通过queue控制还能不能遍历,通过内部的for来控制每一层具体的遍历操作,思维转换也比较简单,将原先while的处理逻辑丢到for里面即可,而需要做几次呢?我们可以通过queue的长度来决定,你要相信,当进入到下一轮queue时,queue一定只包含了某一层的元素。

虽然这里不是递归,但是while的迭代本质上跟递归没什么差异,上篇文章我教大家将while改成写递归也是这个目的,你只用关注当前需要做什么,后续的迭代(递归)会帮你做相同的事,大概就是这么个意思了。

那么在讲解完这个之后,我相信大家也能轻易拿下 leetcode 102. 二叉树的层序遍历,这个题难度还是中等,但是我相信对于现在的你而言非常简单,当然,我还是直接套模板给大家提供一个思路:

  1. var levelOrder = function (root) {
  2. // 这里是题目特性,它希望空时返回空数组
  3. if (root === null) return [];
  4. const ans = [];
  5. const queue = [];
  6. queue.push(root);
  7. while (queue.length > 0) {
  8. const len = queue.length;
  9. const levelArr = [];
  10. for (let i = 0; i < len; i++) {
  11. const node = queue.shift();
  12. levelArr.push(node.val);
  13. if (node.left) {
  14. queue.push(node.left);
  15. };
  16. if (node.right) {
  17. queue.push(node.right);
  18. };
  19. }
  20. ans.push(levelArr);
  21. }
  22. return ans;
  23. };

伍 ❀ 总

OK,那么到这里,我们花了两个篇幅分别解释了二叉树的深度优先以及广度优先的模板含义,我相信在理解二叉树不同遍历方式到底是如何运转之后,对于模板的理解也会更上一个层次。也希望这两篇文章,在之后的二叉树算法中能为大家带来一定帮助,那么到这里本文结束。