16.1-AVL树(数据结构基础篇)

95 阅读11分钟

二叉排序树(二叉搜索树、二叉查找树)基础知识

二叉排序树也叫二叉搜索树、二叉查找树,他相较于普通二叉树的性质如下:

二叉排序树的性质

二叉排序树的任意左子树的值都小于他的根节点的值,任意右子树的值都大于他的根节点的值,二叉排序树的中序遍历结果是一个单调递增的有序序列。

二叉排序树性质的维护

插入新节点

00001

从上面的操作过程我们可以看出二叉排序树的插入具体过程,总结一下:

将待插入节点值与根节点比较,如果比根节点小,则递归在左子树中查找,否则递归在右子树中查找,直到找到一个节点比当前值小且不存在右子树时插入到其右子树当中,或者找到一个节点必当前值大且不存在左子树时插入到其左子树当中。

删除节点

删除叶子节点(出度为0的节点)

删除叶子节点比较简单,就像一个无儿无女,无牵无挂的孤寡老人,即使家产再丰厚,如果哪天撒手人寰了,也就一了百了了,根本不用担心自己的子女是否会因争夺家产而兄弟相残,这里也一样,直接删除叶子节点即可。

删除出度为1的节点

度为1的节点,就相当于有一个独生子女的富翁,假如哪天要不行了,只要自己爸爸还在,还可以把自己的子女交给爸爸照顾。此时,只需要将他唯一的子节点挂在他自己的父节点上,然后自己就可以安心驾鹤西去了。由于二叉排序树的性质,左子树的节点都会小于根节点,如果删除的节点在左子树上,那我们直接把它的子节点重新放到他的父节点的左子树即可。如果删除的节点再右子树上,则直接放到他的父节点的右子树即可。

删除出度为2的节点

要删除度为2的节点,我们首先要搞清楚以下几个概念。

前驱节点

在一个二叉排序树中,根节点的前驱节点就是他左子树的最大值

后继节点

在一个二叉排序树中,根节点的后继节点就是他右子树的最小值

那么,我们要如何找到一个二叉排序树根节点的前驱节点和后继节点呢?我们来思考一个问题:既然前驱节点是左子树中的最大值,而因为二叉排序树的性质,所有的右子树都会大于根节点,那么,我们要找到左子树的最大值,是不是就可以在左子树中一直查找右子树,直到某个节点不存在右子树为止,那么这个节点就是二叉排序树根节点的前驱节点了。后继节点也是相同道理,在右子树中,一直往左子树查找,直到找到一个节点没有左子树为止,这个节点就是二叉排序树的后继节点了。

找到了前驱节点和后继节点之后,我们要删除度为2的节点就简单了,用前驱节点或后继节点替换根节点,然后删除原先的前驱或者后继节点就变成了删除度为1或度为0的节点的问题了。

总结一下:

删除度为2的节点首先要找到一个节点的前驱节点或者后继节点,然后用前驱节点或后继节点替换这个节点,接下来问题就转变成了删除原先前驱或后继节点位置上的节点(这个接待的度只能为0或1,不可能为2,因为如果度为2是不可能是前驱节点和后继节点的),即转变成删除一个度为1或度为0的节点的问题。

关于复杂问题简单化的思考

在树形结构中,类似这种将复杂度极高的度为2的删除问题转换成复杂度较低的度为1或0的问题极为常见。我们需要有一种将复杂问题简单化的能力,尽可能的将一个复杂问题想办法拆借或转换成若干简单问题来求解。

二叉搜索树的代码实现


type TreeNode<T=any> = {
    key: number,
    data?: T,
    left: TreeNode<T>,
    right: TreeNode<T>
};

const nil: TreeNode = {
    key: -1,
    data: null,
    left: null,
    right: null
};

class Tree<T> {
    public root: TreeNode<T> = nil;
    constructor(key?: number, data?: T){
        if(key!==undefined) {
            this.root = this.insert(this.root, key, data);
        }
    }
    // 根据数据创建二叉树节点
    public createNewNode(key: number, data?: T): TreeNode<T> {
        return {
            key,
            data,
            left: nil,
            right: nil
        };
    }
    // 删除以root为根节点的二叉树的所有节点
    public clear(root: TreeNode<T>): void {
        if(root===nil) return;
        this.clear(root.left);
        this.clear(root.right)
        console.log(`删除节点:${root.key}`);
        root = nil;
    }
    // 中序遍历打印输出
    output(root: TreeNode<T>): void {
        if(root === nil) return;
        this.output(root.left);
        console.log(`${root.key}: left<${root.left.key}>; right<${root.right.key}>;`);
        this.output(root.right);
    }
    // 查找并返回一颗以root为根节点的二叉搜索树的根节点root的前驱节点
    getPreeccessor(root: TreeNode<T>): TreeNode<T> {
        let tmp = root.left;
        while(tmp.right !== nil) tmp = tmp.right;
        return tmp;
    }
    // 插入节点
    public insert(root: TreeNode<T>, key: number, data?: T): TreeNode<T> {
        // 如果root节点不存在,则创建一个新的树节点,有了这个逻辑,我们后面可以无需关系左右子树是否存在就可以
        // 肆无忌惮的直接递归插入,因为如果不存在会创建新的节点并插入
        if(root === nil) return (this.root = this.createNewNode(key, data));
        // 如果当前节点的值等于key,则直接返回当前节点
        if(root.key === key) return root;
        // 如果root不存在右子树时,依然进入此分支,是因为上面我们判断了,如果root不存在则创建一个新的节点,因此,我们使用root.right去接收这个节点
        // 假如root.right存在,按照我们的逻辑,最终会返回一个插入了目标节点的根节点,也就是之前的right节点,并没有变化
        if(root.key < key) root.right = this.insert(root.right, key, data);
        else root.left = this.insert(root.left, key, data);
        // 更新当前二叉树的根节点
        this.root = root;
        return root;
    }
    // 从二叉搜索树中删除某个节点
    public erase(root: TreeNode<T>, key: number): TreeNode<T> {
        // 如果节点不存在则直接返回nil
        if(root === nil) return (this.root = root);
        // 如果根节点的key比待删除的key小,说明待删除的节点在右子树,因此去右子树中删除,同上面一样,我们用root.right去接收删除key节点后的右子树
        if(root.key < key) root.right = this.erase(root.right, key);
        else if(root.key > key) root.left = this.erase(root.left, key);
        else {
            // 处理度为0或度为1的节点删除逻辑,当度为0时,因为没有左右子树,所以tmp为nil,并返回,上面会接收返回的节点挂在相应子树上
            // 如果度为1时,则会返回不为nil的子树
            if(root.left === nil || root.right === nil) {
                const tmp = root.left === nil ? root.right : root.left;
                root = nil;
                return tmp;
            } else {
                // 删除度为2的节点
                // 先找到当前节点的前驱节点
                const tmp = this.getPreeccessor(root);
                // 用前驱节点覆盖当前节点
                root.key = tmp.key;
                root.data = tmp.data;
                // 然后再去左子树中删除前驱节点即可,这样就转换了了删除度为0或1的节点的问题了
                root.left = this.erase(root.left, tmp.key);
            }
        }
        // 更新当前二叉搜索树的根节点
        this.root = root;
        return root;
    }
}

const arr = [3,2,1,5,4,7,9,8,6];
const avlTree = new Tree<number>();

arr.forEach(item => {
    console.log(`\n============ 插入元素[${item}]开始 ==============\n`);
    avlTree.insert(avlTree.root, item);
    avlTree.output(avlTree.root);
    console.log(`\n============ 插入元素[${item}]结束 ==============\n`);
});

const delArr = [5,3,2];
delArr.forEach(item => {
    console.log(`\n============ 删除元素[${item}]开始 ==============\n`);
    avlTree.erase(avlTree.root, item);
    avlTree.output(avlTree.root);
    console.log(`\n============ 删除元素[${item}]结束 ==============\n`);
});

AVL树基础知识

为什么要有AVL树

之前学习过普通二叉树、完全二叉树等树形结构,大家应该都很清楚,一颗二叉树最怕的是什么,最害怕的就是“退化”。本来霸气凌然的“裂空座”竟然退化成了“绿毛虫”,这谁能忍?例如按照以下顺序将每个节点插入到二叉排序树中:

00002

如果按照上面的顺序插入的话,我们的二叉排序树就变成了一个链表了。我们都知道,一个二叉树,我们可以使用二分思想,使其搜索复杂度达到logn,但一旦二叉树退化成了链表,他的搜索复杂度直接就变成O(n)了。

而我们今天要学习的平衡二叉排序树,即AVL树就是为了防止二叉树退化而诞生的。

概念

AVL树(平衡二叉排序树),在二叉排序树的基础上,对左右子树的高度做了一定的限制,设左子树的树高为HL,右子树的树高为HR,那么,一颗平衡二叉排序树必定满足:|HL - HR|<=1。即左右子树的高度差不超过1。

这样,由于对每一个节点的左右子树都做了限制,所以整棵树不会退化成链表。

平衡化旋转

AVL树为了确保他的平衡性,即左右子树高度差不超过1,采用了左旋右旋的方式进行调整,左旋右旋是一个对称的操作,类似于加法减法,一颗二叉排序树A通过左旋n次得到二叉排序树B,那么二叉排序树B必然可以通过右旋n次重新变回二叉排序树A。

AVL树的左旋

图片来自于网络

如上示意图,假如我们捏住E点,将整颗二叉树往左边甩,那么,此时,原本在E点右子树的S点就变成了E的父级,而E则变成了S的左子树根节点,因为我们这棵树要保持二叉树的性质,只能有两个出度,原先S就有两个子节点,此时,我们可以将S原先的左子树当做E的右子树,这样就不再违反二叉树出度最大为2的性质了,并且左旋之后,我们依然维护了二叉排序树的性质。

AVL树的右旋

图片来源于网络

右旋是左旋的对称操作,假如我们捏住S点,将整颗二叉树往右边甩,此时,E成了S的父节点,而S成了E的右子树根节点,因为我们这棵树要保持二叉树的性质,只能有两个出度,原先E就有两个子节点,此时,我们可以将E原先的右子树当做S的左子树,这样就不再违反二叉树出度最大为2的性质了,并且右旋之后,我们依然维护了二叉排序树的性质。

AVL树的几种失衡类型

在我们对AVL树进行插入或删除操作时,就有可能使得AVL树失衡,即插入新节点或删除节点后,导致左右子树的高度差超过1,主要包括以下几种失衡类型:

LL型

00003

失衡调整方法:由于左子树更高,所以,我们抓着0节点进行一次右旋,这样就可以让当前失衡的树重归平衡

Xnip2021-08-14_14-21-50

证明推导上述结论:

00004

RR型

与LL型相对的,RR型代表站在0点上失衡了,他的右子树更高,且他的右子树的右子树要比右子树的左子树更高。

失衡调整方法:由于右子树更高,所以,我们抓着0节点进行一次左旋,这样就可以让当前失衡的树重归平衡

LR型

00005

左子树的右子树更高

失衡调整方法:由于是左子树的右子树更高,那么我们可以先抓着左子树根节点1进行一个左旋,将LR类型的失衡先转换成LL类型的失衡,然后再抓第一个开始失衡的节点0,进行一次右旋即可

证明推导上述结论:

00006

RL型

右子树的左子树更高

失衡调整方法:由于是右子树的左子树更高,那么我们可以先抓着右子树子树根节点2进行一个右旋,将RL类型的失衡先转换成RR类型的失衡,然后再抓第一个开始失衡的节点0,进行一次左旋即可

AVL树的平衡调整发生在什么时候

由于我们AVL树的插入操作时一个递归的过程,因此,我们需要在递归的回溯过程中,检测AVL树是否失衡,一旦失衡则立即通过左旋或右旋(具体使用左旋还是右旋需要根据具体情况而定)进行平衡调整操作。因此,AVL树的平衡调整发生在递归回溯阶段。

插入调整思维训练

00007

00008

00009

00010

图片转存失败,建议将图片保存下来直接上传

AVL树代码实现


type AVLTreeNode<T=any> = {
    key: number,
    data?: T,
    h: number,
    left: AVLTreeNode<T>,
    right: AVLTreeNode<T>
};

/**
 * 由于AVL树的平衡调整是一个动态的过程,涉及到大量的空节点判断,为了使逻辑更加清晰精简,音符虚拟空节点概念
 * 用nil替代所有的null
 */
const nil: AVLTreeNode = {
    key: -1,
    data: null,
    h: 0,
    left: null,
    right: null
};

class AVLTree<T> {
    public root: AVLTreeNode<T>;
    constructor(key: number, data?: T){
        this.root = this.createNewNode(key, data);
    }
    public createNewNode(key: number, data?: T): AVLTreeNode<T> {
        return {
            key,
            data,
            h: 1,
            left: nil,
            right: nil
        };
    }
    public clear(root: AVLTreeNode<T>): void {
        if(root===nil) return;
        this.clear(root.left);
        this.clear(root.right)
        console.log(`删除节点:${root.key}`);
        root = nil;
    }
    public reCalcHeight(root: AVLTreeNode<T>): void {
        root.h =  Math.max(root.left.h, root.right.h) + 1;
    }

    output(root: AVLTreeNode<T>): void {
        if(root === nil) return;
        console.log(`${root.key}[${root.h}]: left<${root.left.key}>; right<${root.right.key}>;`);
        this.output(root.left);
        this.output(root.right);
    }
    // 查找并返回一颗以root为根节点的二叉搜索树的根节点root的前驱节点
    getPreeccessor(root: AVLTreeNode<T>): AVLTreeNode<T> {
        let tmp = root.left;
        while(tmp.right !== nil) tmp = tmp.right;
        return tmp;
    }
    public insert(root: AVLTreeNode<T>, key: number, data?: T): AVLTreeNode<T> {
        if(root === nil) return this.createNewNode(key, data);
        if(root.key === key) return root;
        if(root.key < key) root.right = this.insert(root.right, key, data);
        else root.left = this.insert(root.left, key, data);
        // 重新计算当前节点的高度
        this.reCalcHeight(root);
        return this.maintain(root);
    }
    public erase(root: AVLTreeNode<T>, key: number): AVLTreeNode<T> {
        if(root === nil) return root;
        if(root.key < key) root.right = this.erase(root.right, key);
        else if(root.key > key) root.left = this.erase(root.left, key);
        else {
            if(root.left === nil || root.right === nil) {
                const tmp = root.left === nil ? root.right : root.left;
                root = nil;
                return tmp;
            } else {
                const tmp = this.getPreeccessor(root);
                root.key = tmp.key;
                root.left = this.erase(root.left, tmp.key);
            }
        }
        this.reCalcHeight(root);
        return this.maintain(root);
    }
    // 左旋操作
    public leftRotate(root: AVLTreeNode<T>): AVLTreeNode<T> {
        const newRoot = root.right;
        root.right = newRoot.left;
        newRoot.left = root;
        this.reCalcHeight(root);
        this.reCalcHeight(newRoot);
        return newRoot;
    }
    // 右旋操作
    public rightRotate(root: AVLTreeNode<T>): AVLTreeNode<T> {
        const newRoot = root.left;
        root.left = newRoot.right;
        newRoot.right = root;
        this.reCalcHeight(root);
        this.reCalcHeight(newRoot);
        return newRoot;
    }
    // 平衡调整
    public maintain(root: AVLTreeNode<T>): AVLTreeNode<T> {
        // 如果左右子树高度差小于2则无需调整
        if(Math.abs(root.left.h - root.right.h) < 2) return (this.root = root);
        const tmp = root.key;
        // 判断失衡类型,首先判断是LX型还是RX型
        if(root.left.h > root.right.h) { // 说明是LX型
            let flag = false;
            // 判断是LL型还是LR型
            if(root.left.right.h > root.left.left.h) {// LR型
                console.log(`LR型失衡, 拽着${root.left.key}节点左旋`);
                root.left = this.leftRotate(root.left);
                flag = true;
            }
            !flag && console.log(`LL型失衡,拽着${root.key}节点右旋`);
            root = this.rightRotate(root);
        } else {// RX型
            let flag = false;
            if(root.right.left.h > root.right.right.h) {// RL型
                console.log(`RL型失衡,拽着${root.right.key}节点右旋`);
                root.right = this.rightRotate(root.right);
                flag = true
            } 
            !flag && console.log(`RR型失衡,拽着${root.key}节点左旋`);
            root = this.leftRotate(root);
        }
        //  旋转有可能会导致根节点改变,因此需要更新根节点
        return (this.root = root);
    }
}

const arr = [2,1,5,4,7,9,8,6];
const avlTree = new AVLTree<number>(3);

arr.forEach(item => {
    console.log("\n============ 开始 ==============\n");
    avlTree.insert(avlTree.root, item);
    avlTree.output(avlTree.root);
    console.log("\n============ 结束 ==============\n");
});

avlTree.erase(avlTree.root, 5);
avlTree.erase(avlTree.root, 3);
avlTree.erase(avlTree.root, 2);
avlTree.output(avlTree.root);