和面试官谈二叉树, 如何让自己的回答 : 思考 > 撸码 ?

189 阅读9分钟

前言

当有人问你二叉树的表示和遍历 , 我们也许都能抬手就敲 , 就算不知道 , 看几分钟代码 , 也能迅速记忆 ,一顿操作猛如虎 , 代码也可以写得有模有样 。

但却缺乏思考 .

一旦在面试中 , 面试官问你为什么这样写 ,就可能表现不佳 , 即使可以说出一点 , 我们也可能只是在玩概念 , 没有深度 。

脱离了高中的应试教育 , 在大学,我自己批给自己的自由 (懂得都懂) , 让我更有时间去思考 , 而非简陋的记忆(水课考试速成还是 ,哈哈🤡) .

思想无价而永恒 , 记忆机械而短暂 , 让我们一起做一个有思想灵魂的代码人 !

面试题

面试官 : 请用 js 代码表示这颗树


        A
       / \
      B   C
     / \  / \
    D   E F  G
    

leetcode 上不需要你表示一棵树 ,导致很多人 , 都不知道怎么写 , 然而 , 学过数据结构 , 并且有一定的思考 , 也可以较快学会 。

关于一颗二叉树的表示 , 我们可以根据节点之间的关系 :

父节点 为 i , 则左节点为 2i +1 , 右节点为 2i + 2

所以一颗二叉树 ,可以利用父子关系 , 在数组中通过下标计算来表示每个节点的位置 , 在数学关系上 , 形成一颗树 。

但是 , 却失去了二叉树本身的结构 。

而我们实际开发中 , 常用链表的形式来表示二叉树 , 因为一个节点有左节点,右节点 , 不就是链表中 : 一个元素节点 , 有左指针 , 右指针吗 ?

我们对上面这颗树进行抽象 。

可是 js 有指针吗 ?

js 中没有指针 , 但是 js 有引用类型 ! 某种程度上类似于指针的概念 。

在 js 中 , 对象就是一个引用类型 , 变量储存在栈内存中 , 值储存在堆内存中 .

由于栈内存中储存堆内存的地址 , 所以我们可以理解为"变量" 指向 "值" ;

说到这里 , 还是不能表示一颗树出来 , 你还要理解树的递归性 , 树的结构本身就是递归圣体 ,

在不断的递归着这个结构 :


                                     根节点
                                     /    \
                                  左节点  右节点

比如下面的图 :

对于整棵树 , 根节点 A , 左节点 B ,右节点 C

而对于左子树 , 根节点为 B , 左节点为 D , 右节点为 E

对于右子树 , 根节点为 C , 左节点为 F , 右节点为 G


        A
       / \
      B   C
     / \  / \
    D   E F  G
    

所以我们需要三个变量来分别表示 : val (根节点) , left (左节点) , right (右节点) 。

请看下面这段代码 :


const root = {
    val: 'A',
    left: {
      val: 'B',
      left: {
        val: 'D'
      },
      right: {
        val: 'E'
      }
    },
    right: {
      val: 'C',
      left: {
        val: 'F'
      },
      right: {
        val: 'G'
      }
    }
  }

面试官 : 请遍历这颗树

我对 dsf 和 bfs 的理解

遍历这颗树 , 我们要明白 , 树的遍历方式有什么 ?

  • 深度优先遍历 (dfs) : 俗称"爆搜" , 指每条路死劲搜 , 直到搜到结果或者越界了 , 才沿原路返回(回溯) , 回到之前原点 , 再换条没走过的路 , 继续死劲搜 , 终止条件同上 , 如此递归 . (这句话后面是突破点 )
    • 前序遍历
    • 中序遍历
    • 后序遍历
  • 广度优先遍历 (bfs): 一层一层搜 , 一圈一圈搜 。
    • 层序遍历

深度优先遍历

三种遍历的统一思考

所谓 ,前序 , 后序 , 中序遍历 都是以根节点的遍历顺序为标准的 , 比如

前序遍历 : 中 -> 左 -> 右 ("中" 指根节点 , "左"指左节点 , "右"指右节点)

中序遍历 : 左 -> 中 -> 右

后序遍历 : 左 -> 右 -> 中

那么如何实现呢 ?

我们可以利用递归 , 上文提到 , 树的递归 , 其实是在递归这样的结构 :


        根节点
        /    \
     左节点  右节点    
     

那么遍历呢 ? (举前序遍历为例)


前序遍历就是在递归如下遍历顺序

  • 中 -> 左 -> 右 ("中" 指根节点)

1)前序遍历先输出根节点

    function preOrder(root) {
        console.log(root.val); // 中
    }
    

2)之后输出左节点

那么再次调用 preOrder() , 只不过此时 root = root.left (左节点)

    function preOrder(root) {
        console.log(root.val); // 中
        preOrder(root.left);   // 左
    }
    

3)同理 : 输出右节点也是调用 preOrder() , 传入右节点


    function preOrder(root) {
        console.log(root.val); // 中
        preOrder(root.left);   // 左
        preOrder(root.right);  // 右
    }
    
    

至此 , 递归体完成 , 即不断递归"中 , 左 , 右"


可是什么时候终止 ?

这里的终止 , 是单层递归的终止 , 比如我上面对深度优先遍历的解释

指每条路死劲搜 , 直到搜到结果或者越界了 , 才沿原路返回 , 回到之前没搜过的点 , 再换条路 , 继续死劲搜 , 终止条件同上 , 如此递归 .

你想象你在一个十字路口 , 你有四条路可选 , 你不知道哪条路的终点有你的梦想 , 所以你只能爆搜, 四条都走一遍。

走第一条路 , 到终点 , 原路返回(回溯) , 回到十字路口 , 由于第一条路走过了 , 所以不会再走。

同理 , 第二 ,三 ,四条路 ,都是这样搜 , 每次都会回到十字路口 , 终止条件就是走到终点 , 只是结束了一条路的探索 , 也就是我上面说的 , 结束单层递归 ,当四条路都搜完了 , 回到十字路口 , 发现无路可走(都走过了) , 此时 , 梦想破裂 , 人生绝望 , 人生苦海的递归结束

类比到树 :

节点为空就像每条路的终点, 即单层递归的终点 , 当所有节点访问完 , 即当所有路走完的时候 ,才是彻底结束递归


同样以 前序遍历 为例 :

十字路口 : A

第一条路 : 左子树 BDE


        A
       / \
      B   C
     / \  / \
    D   E F  G

    function preOrder(root) {
        if (!root) return;
        console.log(root.val); // 中
        preOrder(root.left);   // 左
        preOrder(root.right);  // 右
    }
    
    

看上面代码 , 首先输出 A

之后 执行函数preOrder(root.left);// 左 (此时 root.left = B)

该函数在遍历左子树 BDE (大家可以跟着代码流走一走) , 当遍历到 E 的时候 , E左节点为 NULL , 结束该层递归 。

此时 preOrder(root.left);函数结束

控制台输出 : A B D E

接下来要执行

preOrder(root.right);// 右

第二条路 : 右子树 CFG


        A
       / \
      B   C
     / \  / \
    D   E F  G
    function preOrder(root) {
        if (!root) return;
        console.log(root.val); // 中
        preOrder(root.left);   // 左
        preOrder(root.right);  // 右
    }
    
    

第一条路走到终点后 , 回到原点(A) , 继续执行 preOrder(root.right); // 右这里的 root.rigth = C , A的右节点。

而该函数是在遍历右子树 , 控制台会继续输出 , C F G , 当走到 G 的时候 , G左节点为 NULL , 终止该层递归 .

两条路走完后 , 所有节点都被访问 , 这才是彻底结束递归

先序遍历

        A
       / \
      B   C
     / \  / \
    D   E F  G

    function preOrder(root) {
        if (!root) return;
      
        console.log(root.val); // 中
        preOrder(root.left);   // 左
        preOrder(root.right);  // 右
    }
    

中序遍历

        A
       / \
      B   C
     / \  / \
    D   E F  G

    function midOrder(root) {
        if (!root) return;
      
        midOrder(root.left);    // 左
        console.log(root.val); // 中
        midOrder(root.right);   // 右
      }
      

后序遍历

        A
       / \
      B   C
     / \  / \
    D   E F  G

    function postOrder(root) {
        if (!root) return;
      
        postOrder(root.left);  // 左
        postOrder(root.right); // 右
        console.log(root.val); // 中
      }
      
      

广度优先遍历

层序遍历

顾名思义 , 就是一层一层的遍历 , 比如以下树的遍历 ;


        A
       / \
      B   C
     / \  / \
    D   E F  G
    

应当输出 :

A B C D E F G

这个怎么想 ?

也许有人上来就告诉你 , 这个要用队列 ,可是为什么要用到队列呢 ? 不知道大家想过没有 ,

在之前我也没有想过 , 但写这篇文章的时候 , 看着标题 : 思考 > 撸码 , 我不禁 push 自己来思考这个问题 。

我说说我的理解 :

首先要思考一下两点

  • 为什么要额外添加一个数据结构 ?
  • 这个数据结构又为什么是队列 ?

还是看这个图 :


        A
       / \
      B   C
     / \  / \
    D   E F  G

我们可以发现 , 对于树的节点而言 , 它的底层是一个有着左右指针的节点 , 这也就表明了 ,一个树的节点 , 只能访问它的左节点和右节点,

比如通过 B 可以访问 DE 节点 , 但是不能访问到同一级的节点 C , 也就是说 , 如果只是单纯的遍历树的每一个节点 , 我们不能得到与当前遍历节点同一级的节点 ,比如 B C , 要得到 B C , 就必须要在遍历到 A的 时候 ,就赶紧用小本本记录下来 , 错过了这个村 ,就没有这个店了 ! 这个小本本就是某种数据结构 , 比如数组 , 队列 ,栈 ……

用数组吗 ? 用栈 ? 用队列 ?

到底如何选择 ?

首先 , 数组肯定不是 , 因为元素要动态迭代 , 比如

第一层 , 存 A 就行

第二层 , 存 B C ,A 不要了

这个过程就恰好是队列的思想 , A 出队列 , B C 入队列 , 就得到了第二层元素

同理 , 第三层 , 就把第二层的元素BC踢出 , 把第三层DEFG元素入队 , 这样就得到第三元素 ,要注意的是 , 第三层元素是第二层的子节点 , 所以踢出第二层元素的时候 , 注意榨取它的剩余价值 , 把它的子节点元素记录下来 。

我画个图 ,来更好表达我上面的话 :


        A
       / \
      B   C
     / \  / \
    D   E F  G
    
function levelOrder(root) {
  if (!root) return [];

  const result = [];
  const queue = [root]; // 作为整棵树的根节点入队

  while (queue.length) {
    const current = queue.shift(); //看成父节点出队列(实际可能是一颗树)
    if (current.left) queue.push(current.left); //看成左边子节点入队 (实际左子树入队)
    if (current.right) queue.push(current.right); //看成右边子节点入队 (实际右子树入队)
    result.push(current.val); // 记录出队的节点,经过上面的三行代码,一定是一层一层的出队,然后入队
  }

  return result;
}

const result = levelOrder(root)


result.forEach(item =>{
  process.stdout.write(item+" ")
})

    
    

image.png

总结


思想的厚度 >> 撸码 , 在大学 , 给自己批假 , 撸码闲暇之际 , 用来思考 , 有时候真的很有趣 !


我是一个在校大学生 , 欢迎大家一起交流 !