二叉树

656 阅读8分钟

二叉树的出现是为了解决什么问题?

将链表插入的灵活性和有序数组查找的高效性结合起来

二叉树的特性是什么?

二叉树定义:

二叉树是n(n>=0)个结点的有限集合,它的每个结点至多只有两颗子树。它或者是空集,或者是由一个根节点及两颗互不相交的分别称为这个根的左子树和右子树的二叉树组成

基本术语

一个结点拥有的子树数称为度,一棵树中结点最大的度数称为该树的度,度数为0的结点称为叶子或者终端结点,这里需要注意的是 度是子树数目,所以二叉树每个结点的度是0-2之间

结点关系

树中某个结点子树的根称为该结点的孩子,相应的该结点称为孩子结点的双亲。同一双亲的孩子之间互为兄弟。

树的深度(高度)

树中结点的层次是从根算起,根为第一层,其余结点的层次等于其双亲结点的层数加1,树中结点的最大层次称为树的深度或者高度

二叉树性质

性质1:在二叉树的第i层上至多有2i-1个结点

性质2:深度为k的二叉树至多有2k-1个结点(k>=1)

性质3:对于一颗二叉树T,若其终端结点树为n0,度数为2的结点数为n2,则n0=n2+1

什么是二叉查找树?

一颗二叉查找树(BST)是一颗二叉树,其中每个结点都含有一个Comparable的键(以及关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树任意结点的键。

二叉树的实现

需要实现的功能如下:

 遍历,插入,删除,搜索

二叉树结点的基本结构

class TreeNode {
    constructor(key, value) {
        //唯一的键
        this.key = key;
        //值
        this.value = value;
        //左节点
        this.left = null;
        //右结点
        this.right = null;
        //计数
        this.N = 0;
        //父节点方便删除使用
        this.parent = null;
    }
    //键的比较
    comparable(key) {
        // return this.key>key;
        if (this.key > key) {
            return -1
        } else if (this.key == key) {
            return 0
        } else {
            return 1
        }
    }
}

树的遍历

二叉树的遍历是一种将非线性结构转换为线性结构的过程,对于二叉树而言树中每个结点都可能有两个后继结点,这导致存在多条遍历路线,因此需要寻找一种规律,以便系统的访问树中各个结点。
(1)前序遍历二叉树的递归定义
         若二叉树非空,则依次进行操作:①访问根节点;②前序遍历左子树;③前序遍历右子树。
(2)中序遍历二叉树的递归定义
         若二叉树非空,则依次进行操作:①中序遍历左子树;②访问根节点;③中序遍历右子树
(3)后续遍历二叉树的递归定义
         若二叉树非空,则依次进行操作:①后续遍历左子树;②后续遍历右子树;③访问根节点
便于记住顺序的方式就是前中后序遍历是根据根的位置划分的,根在第一位是前序,根在中间是中序,根在后面是后序

以上是一颗二叉树
前序遍历的顺序是:A->B->C->D->E->F->G->H
中序遍历的顺序是:C->B->D->A-F->E->H->G
后续遍历的顺序是:C->D->B->F->H->G->E->A

遍历功能实现(递归)

//树的遍历:递归实现
//前序
function preOrder(node){
    //先输出根
    console.log(node.key);
    //输出左
    if(node.left!=null){
        preOrder(node.left);
    }
    //输出右
    if(node.right!=null){
        preOrder(node.right);
    }
}
//中序
function inOrder(node){
    //输出左
    if(node.left!=null){
        preOrder(node.left);
    }
    //输出根
    console.log(node.key);
    //输出右
    if(node.right!=null){
        preOrder(node.right);
    }
}
//后序
function postOrder(node){
    //输出左
    if(node.left!=null){
        preOrder(node.left);
    }
    
    //输出右
    if(node.right!=null){
        preOrder(node.right);
    }
    //输出根
    console.log(node.key);
}

插入功能实现

/**
 * 传入一个数组生成树
 * @param {*} list
 * @returns {TreeNode} 返回生成的树
 */
function add(list) {
    let len = list.length;
    if (len == 0) {
        return;
    }
    let first = list[0]
    let node = new TreeNode(first.key, first.value);
    for (let i = 1; i < len; i++) {
        addTreeNode(node, list[i]);
    }

    return node;
}


/**
 * 传入父节点和键值对
 * @param {*} node
 * @param {*} kv
 */
function addTreeNode(node, kv) {
    //true代表当前key小于结点key是左节点
    //计数
    node.N++;
    if (node.comparable(kv.key) == -1) {
        addLeft(node, kv);
    } else {
        addRight(node, kv);
    }
}

//添加左节点
function addLeft(node, kv) {
    //如果这个结点的左节点为null,则实例化新节点插入
    if (node.left == null) {
        node.left = new TreeNode(kv.key, kv.value);
        node.left.parent = node;
    } else {
        //如果不是则递归调用addTreeNode再次判断插入到左右结点
        addTreeNode(node.left, kv);
    }
}


//添加左节点
function addRight(node, kv) {
    if (node.right == null) {
        node.right = new TreeNode(kv.key, kv.value);
        node.right.parent = node;
    } else {
        addTreeNode(node.right, kv);
    }
}

搜索实现

二叉查找树特点是左边的小于父级右边的大于父级,所以实际上查找起来类似于二分查找。具体步骤就是给定一个key,判断大于当前节点还是小于当前节点,如果大于就查找右边结点,如果小于就查找左边结点,然后之后的子节点再进行类似的比较。

//搜索结点某个key
function search(node, key) {
    //与当前节点比较
    let com = node.comparable(key);
    //如果是0代表命中查询返回结点
    if (com == 0) {
        return node;
    } else if (com == -1) {
        //代表比当前节点小递归查询左节点
        return search(node.left, key);
    } else {
        //代表比当前节点大递归查询右结点
        return search(node.right, key);
    }
}

删除操作

在进行删除操作以前首先需要了解如何找打一个结点的最大结点和最小结点,基于二叉查找树的特性能得知,左边的结点永远是小于父节点而右边的结点大于父结点,所以根据这个特性查找最小结的原理就是一直查询当前结的left结点直到查询到left为null 的结点便是最小结点,最大结点同上

查找最小结点操作:

//查找最小结点
function searchMin(node) {
    //当左节点为null代表没有比当前结点更小的结点了
    if (node.left == null) {
        return node;
    }
    //递归查找
    return searchMin(node);
}

查找最大结点操作:

//查找最大结点
function searchMax(node) {
    // 当右结点为null代表没有比当前结点更大的结点了
    if (node.right == null) {
        return node;
    }
    //递归查找
    return searchMax(node);
}

那么为什么删除操作要查找最大最小结点呢?

要解决这个问题首先来看一下删除的时候可能会面临的几种情况:

  1. 删除的结点没有左右子结点
  2. 删除的结点有左边子结点
  3. 删除的结点有右边子结点
  4. 删除的结点有左右子结点

1.删除的结点没有左右子结点

这个最简单,我们只需要用上面实现的search方法找到当前的结点,然后再通过parent结点找到父级节点,判断下左右然后将left或者right指针置为null即可

2.删除结点有左边子结点

这个意味删除之后要考虑到这个结点左边子结点的安置问题,如何安置?因为二叉树左边的结点都小于父节点,所以二叉树左边结点的左子结点也小于被删除的父节点所以也小于被删除的父节点的父节点所以直接用删除结点的父节点的left指向被删除结点的左子结点即可。

3.删除结点有右边子结点

同上

4.删除的结点有左右子结点

这个是删除操作中最复杂的一部分,复杂的地方在于如何给左右两个子结点找到一个合适的结点来当父级,这个结点要满足什么条件?要满足的条件是比左边的都大,比右边的都小。所以这个时候查找最小节点的功能就用上了,根据二叉查找树的性质,我们只需要知道右边子结点的最小值,那么很显然这个最小值对于左边子结点来说却是最大值,因为右边结点大于左边结点值,所以只需要找到右边最小值的结点来替代删除的结点即可保证树的有序性。

删除结点代码:

function del(node, key) {
    // 找到要删除的node
    let targetNode = search(node, key);
    //获取到父级
    let targetNodeParent = targetNode.parent;
    //第一种情况:没有子节点
    if (targetNode.left == null && targetNode.right == null) {
        if (!targetNodeParent) {
            return null;
        }
        targetNodeParent.N=targetNodeParent.N-1;
        if (targetNodeParent.comparable(targetNode.key) == -1) {
            targetNodeParent.left = null;
        } else {
            targetNodeParent.right = null;
        }
        return node;
    }
    if (targetNode.left != null && targetNode.right == null) {
        //当左边不为空的时候,寻找左边结点的最右边结点
        targetNode.left.parent=targetNodeParent;
        if (!targetNodeParent) {
            return targetNode.left;
        }
        targetNodeParent.N=targetNodeParent.N-1;
        if (targetNodeParent.comparable(targetNode.left.key) == -1) {
            targetNodeParent.left = targetNode.left;
        } else {
            targetNodeParent.right = targetNode.left;
        }
        return node;
    }

    if (targetNode.left == null && targetNode.right != null) {
        targetNode.right.parent=targetNodeParent;
        if (!targetNodeParent) {
            return targetNode.right;
        }
        targetNodeParent.N=targetNodeParent.N-1;
        if (targetNodeParent.comparable(targetNode.right.key) == -1) {
            targetNodeParent.left = targetNode.right;
        } else {
            targetNodeParent.right = targetNode.right;
        }
        return node;
    }

    //两边都不为空
    let minNode = searchMin(targetNode.right);
    //删除掉该结点
    if (minNode.comparable(targetNode.right.key) != 0) {
        del(targetNode.right, minNode.key);
    }
    minNode.right = targetNode.right;
    minNode.left = targetNode.left;
    minNode.right.parent = minNode;
    minNode.left.parent = minNode;
    minNode.parent = targetNodeParent;
    if (!targetNodeParent) {
        return minNode;
    }
    targetNodeParent.N=targetNodeParent.N-1;
    if (targetNodeParent.comparable(minNode.key) == -1) {
        targetNodeParent.left = minNode;
    } else {
        targetNodeParent.right = minNode;
    }
    return node;



}

分析

使用二叉查找树的算法的运行时间取决于树的形状,一颗平衡的树能保证所有的查找都在~lgn次比较内结束,但是如果不是平衡的二叉树,则时间复杂度可能是O(n),所以需要在插入的时候对树进行平衡,防止出现极端情况。这便是AVL树、红黑树、2-3树。
最好情况

最好情况
最差情况

参考

算法4