算法题解--二叉树系列(基础)

81 阅读10分钟

前言

最近作者正在疯狂刷算法,刷到了二叉树部分,所以本文来分享一下二叉树部分的基础知识,本文关于二叉树的理论基础部分较多,重在介绍二叉树的定义,分类、特性,以及二叉树的遍历方式的实现,后续会更新文章介绍二叉树的算法应用题,让我们开始吧!

注意:本文内容代码演示方式全部是以JS的方式,会针对JS的算法实现做特别的介绍(如涉及到闭包、函数方式等),所以本文内容主要针对JS的学习者。


二叉树的定义

二叉树是一种树形数据结构,每个节点最多有两个子节点,分别称为左子节点(left child) 和右子节点(right child),由一个根节点和两个互不相交的子树(左子树和右子树)组成的结构是二叉树。

注意:空树(没有节点的树)也是二叉树

image.png


二叉树的分类

对于一般的算法题来讲,我们可能会遇到以下的二叉树分类

满二叉树

满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。

image.png

完全二叉树

完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。完全二叉树不容易区分,下面给出几个例子,助于我们区分

image.png

二叉搜索树

前面介绍的树,都没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左、右子树也分别为二叉排序树

下面这两棵树都是搜索树

image.png

平衡二叉搜索树

平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

image.png



二叉树的存储方式

实际上,二叉树既可以用数组存储也可以用链表存储,但是在一般的算法题内,咱们都是用链表存储的,所以下面就详细探讨链表存储的实现方式。


JS实现:

function TreeNode(val, left, right) {
    this.val = (val===undefined ? 0 : val)
    this.left = (left===undefined ? null : left)
    this.right = (right===undefined ? null : right)
}

我们可以看到,JS是通过构造函数实现的,我们要构造一个二叉树可以通过下面两种方式

//方法1
const root = new TreeNode(1);
let lchild = new TreeNode(2);
let rchild = new TreeNode(3);
root.left = lchild;
root.right = rchild;

//方法2:更直观的创建方式:直接通过构造函数参数绑定子节点(推荐)
const root = new TreeNode(
    1,                          // val
    new TreeNode(2),            // left
    new TreeNode(3)             // right
);

二叉树的遍历方式

现在来到本文的重点以及难点:二叉树的遍历,二叉树的遍历分为如下方式:

  • 深度优先遍历(栈或递归实现)

    • 前序遍历(递归法,迭代法)
    • 中序遍历(递归法,迭代法)
    • 后序遍历(递归法,迭代法)
  • 广度优先遍历(队列实现)

    • 层次遍历(迭代法)

对于深度优先遍历,咱们有时会搞不清前中后三种顺序的遍历的区别,这里可以用一个技巧记忆理解:

“前、中、后”指得就是 中间节点的位置!!!

看如下中间节点的顺序,就可以发现这一点。

  • 前序遍历:中左右
  • 中序遍历:左中右
  • 后序遍历:左右中

递归实现前中后序遍历

我们首先使用递归的方式来实现深度优先遍历,也就是前中后序的遍历。

前序遍历

var preorderTraversal = function(root) {
    let res = [];
    const dfs = function (root) {
        if (root === null) return;
        //先序遍历所以从父节点开始
        res.push(root.val);
        //递归左子树
        dfs(root.left);
        //递归右子树
        dfs(root.right);
    }
    //只使用一个参数 使用闭包进行存储结果
    dfs(root);
    return res;
}

这里dfs函数体里面的内容大家应该都能看懂,就是传统的递归嘛。

这里着重给大家讲解一下这里闭包的体现。

闭包体现在哪里?

在这段代码中

  • res 是在 preorderTraversal 函数作用域内声明的变量。
  • dfs 函数内部访问了 res,即使 dfs 是在 preorderTraversal 外部调用的(递归调用时),它仍然能访问 res
  • dfs 函数“记住”了 res,这就是闭包的体现。
var preorderTraversal = function(root){
    let res = []; // 外部作用域的变量

    const dfs = function(root) {
        if (root === null) return;
        res.push(root.val); // 内部函数访问外部变量 res
        dfs(root.left);
        dfs(root.right);
    };

    dfs(root); // 调用 dfs
    return res; // 返回 res
}

这里解释一下为什么dfs是在preorderTraversal外部调用,虽然在词法上看是在内部定义并调用的。

实际上,第一次dfs是在内部调用的,之后的每一次递归调用都是在外部调用,请看递归调用的执行上下文。

  • 首次调用 dfs(root) : 发生在 preorderTraversal 函数内部,(在首次调用后,preorderTraversal 函数本身已经暂停。)

  • 递归调用 dfs(root.left) 和 dfs(root.right) : 这些调用虽然是递归,但每次调用都会生成新的 dfs 函数执行上下文。每个执行上下文中没有自己的 res,但通过作用域链,它们都能访问到 preorderTraversal 作用域中的 res

总结:当 dfs 在 preorderTraversal 内部被定义时,它已经捕获了外部作用域(即 preorderTraversal 的作用域)中的变量 res无论 dfs 是首次调用还是递归调用,它始终通过闭包访问同一个 res


无闭包写法?

不使用闭包的话,必须将res作为参数传递,不依赖闭包

var preorderTraversal = function(root) {
    const res = [];
    dfs(root, res); // 显式传递 res
    return res;
};

function dfs(node, res) {
    if (node === null) return;
    res.push(node.val);
    dfs(node.left, res); // 每次递归都要传递 res
    dfs(node.right, res);
}

你会发现,还是使用闭包更方便优雅


中序遍历、后序遍历

中序遍历和后续遍历其实和前序遍历三者可以互相改写,只需要换下位置就行。

中序遍历

var inorderTraversal = function(root) {
    let res = [];
    const dfs = function (root) {
        if (root === null) {
            return;
        }
        dfs(root.left);
        res.push(root.val);
        dfs(root.right);
    }
    dfs(root);
    return res;
}

后序遍历

var postorderTraversal = function(root) {
    let res = [];
    const dfs = function (root) {
        if (root === null) {
            return;
        }
        dfs(root.left);
        dfs(root.right);
        res.push(root.val);
    }
    dfs(root);
    return res;
}

栈实现前中后序遍历

由于递归实际上是利用了计算机的调用栈来管理函数的调用和返回,所以所有可以用递归实现的算法都可以用栈实现

对于遍历二叉树,我们手动维护一个栈结构来进行。

在用栈模拟递归的过程中,我们需要访问节点(遍历节点入栈),我们还需要处理节点(将元素放入结果集),如果用栈实现的话,我们需要区分当前节点是访问的节点还是处理的节点,所以我们需要一个标记,把访问的节点和处理的节点都放入栈中,但是要对处理的节点做标记!

这里我给一个在面试中更容易写出来的方法,boolean标记法
一个 boolean 值跟随每个节点,false (默认值) 表示需要为该节点和它的左右儿子安排在栈中的位次,true 表示该节点的位次之前已经安排过了,可以收割节点了。

下面给出中序遍历的代码:

var postorderTraversal = function(root) {
    const result = [];
    const stack = [];
    if (root !== null) {
        stack.push({ node: root, visited: false }); // 初始节点入栈,标记未访问
    }

    while (stack.length > 0) {
        const { node, visited } = stack.pop(); // 弹出栈顶元素

        if (visited) {
            // 已经被访问过,可以收割节点了
            result.push(node.val);
        } else {
            // 未访问的节点,按"右中左"顺序重新入栈(中序遍历实际顺序是"左中右")
            
            
            // 右子节点先入栈(后处理)
            if (node.right !== null) {
                stack.push({ node: node.right, visited: false });
            }
            
            stack.push({ node, visited: true }); // 当前节点标记为已访问

            // 左子节点后入栈(先处理)
            if (node.left !== null) {
                stack.push({ node: node.left, visited: false });
            }
        }
    }

    return result;
};

这套代码很统一,因此前序和后序遍历也能直接用,只需要更换下中节点入栈的顺序即可

前序遍历

var preorderTraversal = function(root) {
    const result = [];
    const stack = [];
    if (root !== null) {
        stack.push({ node: root, visited: false });
    }

    while (stack.length > 0) {
        const { node, visited } = stack.pop();

        if (visited) {
            result.push(node.val);
        } else {
            // 前序遍历顺序:根-左-右,所以入栈顺序是右-左-根(因为栈是后进先出)
            if (node.right !== null) {
                stack.push({ node: node.right, visited: false });
            }
            if (node.left !== null) {
                stack.push({ node: node.left, visited: false });
            }
            stack.push({ node, visited: true });
        }
    }

    return result;
};

后序遍历

var postorderTraversal = function(root) {
    const result = [];
    const stack = [];
    if (root !== null) {
        stack.push({ node: root, visited: false });
    }

    while (stack.length > 0) {
        const { node, visited } = stack.pop();

        if (visited) {
            result.push(node.val);
        } else {
            // 后序遍历顺序:左-右-根,所以入栈顺序是根-右-左
            stack.push({ node, visited: true });
            if (node.right !== null) {
                stack.push({ node: node.right, visited: false });
            }
            if (node.left !== null) {
                stack.push({ node: node.left, visited: false });
            }
        }
    }

    return result;
};


用队列实现层序遍历

以下是用队列实现层序遍历的代码,其实这套代码和我们图论里面的广度优先搜索是很相似的

var levelOrder = function(root) {
    //二叉树的层序遍历
    let res = [], queue = [];
    queue.push(root);
    if(root === null) {
        return res;
    }
    while(queue.length !== 0) {
        // 记录当前层级节点数
        let size = queue.length;
        //存放每一层的节点
        let curLevel = [];
        for(let i = 0;i < size; i++) {
            let node = queue.shift();
            curLevel.push(node.val);
            // 存放当前层下一层的节点
            node.left && queue.push(node.left);
            node.right && queue.push(node.right);
        }
        //把每一层的结果放到结果数组
        res.push(curLevel);
    }
    return res;
};

下面通过图片给大家捋一遍过程,咱们初始的二叉树如下

image.png


按照步骤 获取当前队列size后,进行size次操作,将当前队列里的元素移出队,并且当前队列的所有元素放入curLevel数组中,待size操作结束后,再将curLevel数组放入result数组中。

遍历到第一层

image.png


遍历到第二层 image.png


遍历到第三层

image.png

这时queue为空了,所以咱们就会跳出while(queue.length !== 0)这个循环,至此层序遍历结束。

我想你需要注意以下两点:

  1. 在整个过程中,我们的队列可以用数组模拟,因为在JS中,数组有一个很好用的方法'.shift()',可以从数组中删除第一个元素,并返回该元素的值,这个操作可以模拟队列中的移除队头并返回队头的值。

  2. 代码中的node.right && queue.push(node.right);逻辑与(&&) 的写法,如果&&左边的为真,则执行右边的,如果&&左边为假,则直接返回node.right(这里返回值没人接受,所以可以无视),不会执行右边的push操作。



总结

这篇文章主要是对二叉树在算法中的一些基础理论知识讲解了一遍,以后我们解决二叉树的问题,总是离不开以上的二叉树基础,俗话说的好,磨刀不误砍柴功,我相信只有牢牢掌握好了二叉树在算法上的基础理论知识,以后才能解决更复杂的二叉树的算法问题!


🌇结尾

本文部分内容参考程序员卡尔的:代码随想录

感谢你看到最后,最后再说两点~
①如果你持有不同的看法,欢迎你在文章下方进行留言、评论。
②如果对你有帮助,或者你认可的话,欢迎给个小点赞,支持一下~
我是3Katrina,一个热爱编程的大三学生

(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除。)

作者:3Katrina
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。