javascript 实现及操作排序二叉树

199 阅读8分钟

        近些年,考察算法已经成为了前端面试的趋势,越来越多大厂喜欢面试二叉树、排序、动态规划等问题。下面介绍一下js实现排序二叉树,以及排序二叉树的节点操作,以帮助大家熟悉此类知识点的内容。

        概念:排序二叉树,即二叉查找树(Binary Search Tree),(又:二叉搜索树,二叉排序树)它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为排序二叉树。排序二叉树作为一种经典的数据结构,它既有链表的快速插入与删除操作的特点,又有数组快速查找的优势;所以应用十分广泛,例如在文件系统和数据库系统一般会采用这种数据结构进行高效率的排序与检索操作。(from 百度) 

        翻译一下,排序二叉树有以下几个特点:

 (01) 若任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值;

 (02) 任意节点的右子树不空,则右子树上所有结点的值均大于它的根结点的值;

 (03) 任意节点的左、右子树也分别为二叉查找树。

 (04) 没有键值相等的节点(no duplicate nodes)。

为了简单起见,如无特别强调,后文中二叉排序树、二叉搜索树、二叉查找树,以及二叉树等叫法统统均指代排序二叉树

1、最简单实现一个排序二叉树

        如果我们想将形如 [8, 3, 10, 1, 6, 14, 4, 7, 13] 这样的数组转化为排序二叉树,应该如何处理?

function BinaryTree() {    
// key是传入的节点,left和right分别指向其左右子元素    
    var Node = function (key) {
        this.key = key;
        this.left = null;
        this.right = null;
    }
    // root代表根节点
    var root = null;
    // 向排序二叉树插入节点的主要方法,判断新插入的节点和旧节点值之间的关系
    var insertNode = function (node, newNode) {
        // 如果新加入的节点值小于旧节点值,证明新节点将放在旧节点的左子树里
        if (newNode.key < node.key) {
            if (node.left == null) {
                node.left = newNode;
            } else {
            // 如果旧有节点有左子树了,那么必然需要递归,向其左子树的孙子节点寻找插入的时机
                // 直到找到旧节点的某个最小的,没有左节点的子孙节点处,把新节点插进去
                insertNode(node.left, newNode)
            }
        } else {
            // 同理,如果新节点的值大于旧节点的值,那么新节点必然放入旧节点的右侧子树里,
            //至于放在什么层级,要看旧子树的右节点是否存在。
            // 如果不存在,可以直接把新节点塞到旧节点的右子树里,如果存在,
            // 那么只能递归寻找新节点的插入时机。
            if (node.right == null) {
                node.right = newNode;
            } else {
                insertNode(node.right, newNode)
            }
        }
    }
    // 生成二叉树方法——插入节点
    this.insert = function (key) {
        var newNode = new Node(key);
        // 先判断根节点有没有,如果没有,新建一个节点当做根节点
        if (root == null) {
            root = newNode;
        } else {
            // 如果已经有了根节点,使用insertNode方法向二叉树内添加节点。
            insertNode(root, newNode);
        }
    }}

        ok,通过上述方法,我们可以看到,构建一个基本的排序二叉树不太困难,不过是通过递归,不断地判断二叉树的根节点以及其左右子节点与插入节点之间的大小关系,然后通过遍历,就可以把上述的数组转化为排序二叉树了。

var nodes = [8, 3, 10, 1, 6, 14, 4, 7, 13];
var binaryTree = new BinaryTree();
nodes.forEach(function (key) {
     binaryTree.insert(key)
})

当然,也可以按照社区里大家的方案,多调用几遍insert方法,如:

binaryTree.insert(8);
binaryTree.insert(3);
binaryTree.insert(10);
binaryTree.insert(1);
......

这样插入节点之后,我们可以获得标准的排序二叉树,形如这样:

2、操作排序二叉树节点

① 查找最小值节点与最大值节点

        根据排序二叉树的特性可知,其最小节点一定是根节点的左子树里的某个节点,只要不断按照每个节点的左子节点向下找,直到没有值,那么找到的最后一个节点肯定是二叉树的最小节点,相应的,查找排序二叉树最子树的最右子节点,就是二叉树的最大值节点

function BinaryTree() {    
// key是传入的节点,left和right分别指向其左右子元素    
    var Node = function (key) {
        this.key = key;
        this.left = null;
        this.right = null;
    }
………………………………………………………………………………

    // 查找二叉树最小节点
    this.min=function(){
         return minNode(root)
    }
    var minNode =function(node){
        if (node!==null){
            while(node && node.left !==null){
                // 只要当前节点有左子树,就证明二叉树里有更小的节点,
                // 那么就通过递归一直访问到左子树的叶子节点,当没有左子树了,证明找到了最小值。
                node=node.left;
            }
            return node.key;
        }
        return null
    }
……………………………………………………………………………………

    //查找二叉树的最大节点,同样的道理,只要有右子树,右子树还有下一级右子树,
    //则代表本二叉树里有更大节点。
    this.max=function(){
        return maxNode(root)
    }
    var maxNode=function(node){
        if(node!==null){
            while(node && node.right!==null){
                node=node.right;
            }
            return node.key;
        }
        return null
    }
}

② 查找一个值是否位于二叉树之中

        对于查找某一个给定值是否位于二叉树中,思路也比较简单,只要将给定的值先与二叉树的根节点比较,如果给定的值比较大,则与根节点的右子树里的节点进行比较,否则就与左子树里的节点进行比较,通过递归的方法,层层比较,直到发现与给定值相等,或遍历至二叉树最末端。

function BinaryTree() {  

    ………………………………………… 分割线以上代表前述的二叉树方法…………………………………………………………

    // 查找某一个值是否在二叉树中
    this.search =function(key){
        searchNode(root,key)
    }
    var searchNode = function(node,key){
        // 如果没有传入从哪个点开始搜索,则直接返回,不搜索任何节点
        if (node===null){
            return false
        }
        // 如果搜索的值小于当前节点,则用递归,进入其左节点,比较左节点和给定节点大小
        if(key<node.key){
            return searchNode(node.left,key)
        }else if(key>node.key){
            // 如果搜索之大于当前节点,使用递归,进入给定节点的右节点,比较右节点和给定节点的大小
            return searchNode(node.right,key)
        }else{
            return true;
        }
    }
}

③ 反转二叉树 

       不知道大家还记不记得,当年的一个程序员圈里的梗,Homebrew的作者Max Howell,就是因为没在白板上写出翻转二叉树,最后被Google拒绝了。

       上面的梗只是个引子,主要想引出反转二叉树的方法。所谓反转二叉树,就是实现一个方法,输入已经成型的二叉树,输出二叉树的镜像,即交换所有节点及子节点的左右子树。

        其实具体思路还是遍历整个二叉树,并且设置临时变量,将每个节点的左右子树或者左右子节点值进行交换(注:这些二叉树操作均有递归和非递归的实现方案,这里为理解容易,只提供递归方案,非递归方案大家可以自行百度谷歌)

function BinaryTree() {  
    ………………………………………… 分割线以上代表前述的二叉树方法…………………………………………………………
    var mirrorRecursively = function(node){
        if(!node|| (node.left == null && node.right== null)){return}
        var temp = node.left;
                node.left = node.right;
                node.right= temp;
        if(node.left!=null){
            mirrorRecursively(node.left)
        }
        if(node.right!=null){
            mirrorRecursively(node.right)
        }
    }
}

④ 删除二叉树的某个节点

删除二叉树的某个节点,可能会有几种情况,一是二叉树要删除的节点位于二叉树最末端,那么删除节点只要给该节点的key值赋为null即可

function BinaryTree() {
    //…………………………………………    分割线以上代表前述的二叉树方法…………………………………………………………
    // 删除某个节点
    this.remove = function (key) {
        root = removeNode(root, key)
    }
    var removeNode = function (node, key) {
        if (node === null) {
            return null;
        }
        // 如果输入的节点值比当前的节点值小,那么证明输入的节点应该在当前节点的左子树中,
        // 这样通过我们在当前节点的左子树中递归遍历,就能找到对应的节点。
        // 如:遍历找到最后,找到了二叉树的叶子节点,比如调用的是binaryTree.remove(1),
        // 这个节点既没有左子树也没有右子树,将其赋值为null,
        // 退出这层循环之后,会进入node.left=removeNode(node.left,key)
        // 将node这个节点的左子节点置为null, 这样这个节点就在二叉树之内被抹除了
        if (key < node.key) {
            node.left = removeNode(node.left, key)
            return node;
        } else if (key > node.key) {
            node.right = removeNode(node.right, key)
            return node;
        } else {
            // 这里处理的是,当节点为叶子节点,没有子节点的情况下,
            // 把当前的node置为null,就是删除了当前节点,然后返回这个空节点
            if (node.left === null && node.right === null) {
                node = null;
                return node;
            }
            // 这里处理的情况是,要删除的节点只有右子树没有左子树,
            // 那么把右子树的节点值赋给当前节点,然后返回这个改变后的节点
            // 这个返回的节点返回到的肯定是key<node.key 或者key>node.key这两个分支里,即
            // 把被删除节点的右孩子节点的值赋给了其父级节点的左孩子或者右孩子节点
            if (node.left === null) {
                node = node.right;
                return node;
            } else if (node.right === null) {
                //只有左子树没有右子树
                node = node.left;
                return node;
            } else {
                // 左右子树都有,此时要删除节点需要先找到所要删除的节点,
                // 然后遍历其右子树,把其右子树里的所有值里最小的值拿出来,替换要删除的节点,
                // 并把这个最小值从原来的右子树里删掉。这样处理过后,二叉树仍然是保持平衡性质,
                // 符合二叉树要求。
                // 所以我们这里需要一个寻找到右子树最小节点的方法 
                var aux = findMinNode(node.right); //找到目标节点的右子树里的最小值
                node.key = aux.key; //把右子树里最小值的节点的取值赋给当前要删除的节点
                node.right = removeNode(node.right, aux.key)
                return node;
            }
        }
    }
    var findMinNode = function (node) {
        // 与前面查找最小值节点的方法类似,只是这个用来查找某个节点右子树里的最小值
        if (node) {
            while (node && node.left !== null) {
                node = node.left; //把更小的值赋给node,让函数循环下去,找到最小值
            }
            return node;
        }
        return null;
    }
}

3、排序二叉树的遍历(深度优先与广度优先)

① 二叉树的深度优先遍历

        二叉树的深度优先遍历方法比较多,但是由于树本身就是递归方法定义的,深度优先遍历最好理解的方法和最少的代码量也都是由递归方法实现的。一般面试中提到的深度优先遍历往往指前序遍历、中序遍历和后序遍历

function BinaryTree() {
    //…………………………………………    分割线以上代表前述的二叉树方法…………………………………………………………
    // 中序遍历,先根节点,然后左子树,最后右子树
    this.inOrderTraverse = function(callback){
        inOrderTraverseNode(root,callback)
    }
    var inOrderTraverseNode=function(node,callback){
        if(node!=null){
        //如果根节点node存在,先访问其左子树,然后访问根节点,调用回调,再访问其右子树,
        // 这就是所谓的中序遍历,中序遍历的作用在于,
        // 完成了中序遍历,数组或者二叉树就转变为按数据大小升序排列的数组
            inOrderTraverseNode(node.left,callback)
            callback(node.key)
            inOrderTraverseNode(node.right,callback)
        }
    }
    // 前序遍历,先访问根节点,再访问左子树,再访问右子树
    this.preOrderTraverse=function(callback){
        preOrderTraverseNode(root,callback)
    }
    var preOrderTraverseNode=function (node,callback) {
        if(node!=null){
        //如果根节点node存在,先访问根节点,然后访问其左子树,调用回调,再访问其右子树,
        // 这就是所谓的前序遍历,前序遍历的意义在于,
        // 使用前序遍历,其实是按照二叉树的顺序访问了各个节点,
        // 通过这个遍历方法可以完整地复制二叉树,如果重新生成一颗二叉树,
        // 要比通过前序遍历复制一颗二叉树的效率低了十倍。
            callback(node.key)
            preOrderTraverseNode(node.left,callback)
            preOrderTraverseNode(node.right,callback)
        }
    }
    // 后序遍历,先访问右子树,再访问左子树,再访问根节点。根节点在最后,所以叫后序遍历
    this.postOrderTraverse=function(callback){
        postOrderTraverseNode(root,callback)
    }
    var postOrderTraverseNode = function (node,callback) {
        if(node!=null){
        //如果根节点node存在,先访问左子树,再访问右子树,最后访问根节点并输出其值。
        // 后序遍历的意义在于,如果对二叉树做破坏性的操作,例如遍历删除节点之类
            preOrderTraverseNode(node.left,callback)
            preOrderTraverseNode(node.right,callback)
            callback(node.key)
        }
    }
}

② 二叉树的广度优先遍历

function BinaryTree() {
    //…………………………………………    分割线以上代表前述的二叉树方法…………………………………………………………
    var levelOrderTraversal = function(node) {
    var levelOrderTraversal = function (node, callback) {
        if (!node) {
            throw new Error('Empty Tree')
        }
        var que = []
        que.push(node) 
       while (que.length !== 0) {
            callback(node);
            node = que.shift()
            console.log(node.value)   
            // 这其实是广度优先遍历用在二叉树的写法,判断左右子树有没有值,如有,则push到队列里
            if (node.left) que.push(node.left)
            if (node.right) que.push(node.right)
        }
    }
}

        对于广度优先遍历算法来说,二叉树只是多叉树的特定类别之一,多叉树的遍历与二叉树遍历的唯一区别就是子元素的多少。一般多叉树的广度优先遍历写成以下这样

for (var i = node.children.length - 1; i >= 0; i--) { 
          que.push(node.children[i]); 
}

4、总结

         以上就是排序二叉树常用常考的部分知识点,二叉树的理解难度并不高。对于二叉树的操作,每种方法都有递归方法和非递归方法,本文只实现了递归方法,某些大厂还特别要求非递归方法,还请大家自行思考和推演