标签 树 下的文章

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/invert-binary-tree

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

翻转一棵二叉树。

示例:

输入:

     4
   /   \
  2     7
 / \   / \
1   3 6   9

输出:

     4
   /   \
  7     2
 / \   / \
9   6 3   1

二、题解

简单题,有两种思路:递归和队列。

2.1 递归

每遍历到一个节点,调整左右子树的值。然后分别递归遍历左右子树。

TreeNode *invertTree(TreeNode *root) {
    TreeNode *p;

    if (root == nullptr) {
        return nullptr;
    }

    // 调整左右子树
    p = root->left;
    root->left = root->right;
    root->right = p;

    // 遍历左右子树
    invertTree(root->left);
    invertTree(root->right);

    return root;
}

2.2 使用队列

把根节点放到队列中,然后依次访问队列中的节点,把每个节点的左右子树位置对换,然后把左右子节点放到栈中继续遍历。

TreeNode *invertTree(TreeNode *root) {
    queue<TreeNode *> q;
    TreeNode *node, *tmp;

    if (root == nullptr) {
        return nullptr;
    }

    // 根节点入队
    q.push(root);
    while (!q.empty()) {
        // 取队首元素
        node = q.front();
        q.pop();

        // 调换左右子树
        tmp = node->left;
        node->left = node->right;
        node->right = tmp;

        // 左子树入队
        if (node->left) {
            q.push(node->left);
        }
        // 右子树入队
        if (node->right) {
            q.push(node->right);
        }
    }

    return root;
}

三、备注

这个问题是受到Max Howell 原问题启发的 :

谷歌:我们90%的工程师使用您编写的软件(Homebrew),但是您却无法在面试时在白板上写出翻转二叉树这道题,这太糟糕了。

其中的一个评论给也很有意思:

如果我是你,我会把白板从下到上翻转。然后告诉他:那,这就是我翻转的二叉树。

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/binary-tree-postorder-traversal

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

给定一个二叉树,返回它的 后序 遍历。

例如输入[1,null,2,3] :

   1
    \
     2
    /
   3 

输出:

[3,2,1]
进阶: 递归算法很简单,你可以通过迭代算法完成吗?

二、题解

参考:二叉树的后序遍历

三、代码

3.1 通过栈+链表实现

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    vector<int> postorderTraversal(TreeNode* root) {
        stack<TreeNode *> s;
        list<TreeNode *> l;
        vector<int> v;
        
        TreeNode *p;
        if (root == nullptr) {
            return v;
        }
        
        s.push(root);
        while (!s.empty()) {
            p = s.top();
            s.pop();
            l.push_front(p);
            if (p->left) {
                s.push(p->left);
            }
            if (p->right) {
                s.push(p->right);
            }
        }
        
        while(!l.empty()) {
            v.push_back(l.front()->val);
            l.pop_front();
        }
        
        return v;
    }
};

3.2 通过2次压栈实现

class Solution {
public:
    vector<int> postorderTraversal(TreeNode* root) {
        TreeNode *p;
        vector<int> v;
        stack<TreeNode *> st;
        if (root == nullptr) {
            return v;
        }
        
        st.push(root); st.push(root);
        while (!st.empty()) {
            p = st.top();
            st.pop();
            if (!st.empty() && st.top() == p) {
                if (p->right) {
                    st.push(p->right); st.push(p->right);
                }
                if (p->left) {
                    st.push(p->left); st.push(p->left);
                }
            } else {
                v.push_back(p->val);
            }
        }

        return v;
    }
};

一、后序遍历

后序遍历逻辑:优先访问左、右子节点,然后访问当前节点。

一个后序遍历的示例,它的后序遍历结果为[4, 2, 5, 6, 3, 1]:

二、非递归实现

后序遍历的非递归实现比前序和中序的非递归实现要复杂很多,因为每个节点都可能有2个子节点,遍历的时候节点中并没有保存当前的状态,无法确定两个子节点是否已经访问过了。因此,下面采用两个方法来实现非递归访问。

2.1 实现方式一

通过栈+链表配合,流程如下:

  1. 根节点入栈,然后循环遍历访问栈顶元素。
  2. 每访问一个元素,把该元素插入到链表开头,然后分别把左、右子节点压栈。
  3. 循环1-2步,完成后把链表中的节点顺序输出。

代码实现:

void postorder_traversal2(tree_node<T> *node, vector<T> &v) {
    stack<tree_node<T> *> s;
    list<tree_node<T> *> l;
    typename list<tree_node<T> *>::iterator it;
    tree_node<T> *p;
    if (node == nullptr)
        return;

    s.push(node);
    while (!s.empty()) {
        // 取栈顶元素
        p = s.top();
        s.pop;
        // 插入到链表的开头
        l.push_front(p);
        // 左子树入栈
        if (p->lchild)
            s.push(p->lchild);
        // 右子树入栈
        if (p->rchild)
            s.push(p->rchild);
    }

    // 结果放到vector中去
    while (!l.empty()) {
        p = l.front();
        l.pop_front();
        v.push_back(p->val);
    }
}

2.2 实现方式二

非递归的后序遍历和前、中序遍历一样,也依赖栈这个数据结构,原理:

  1. 把根节点压栈两次,然后循环判断,当栈不为空的时候,取出栈顶元素。
  2. 把取出的栈顶元素和栈当前的栈顶元素对比,如果相等,则把右子节点和左子节点也分别压入栈两次;如果不相等,则访问当前元素。
压栈两次的作用:判断第几次访问节点,第一次访问到节点的时候要先访问子节点,第二次才是访问自己。

一个特别要注意的地方是:栈是先进后出的,所以在压入左右子节点的时候,要先压入右子节点。

代码实现:

void postorder_traversal(tree_node<T> *node, vector<T> &v) {
    stack<tree_node<T> *> st;
    tree_node<T> *p;
    
    if (node == nullptr)
        return;

    // 根元素压栈2次
    st.push(node);
    st.push(node);
    while (!st.empty()) {
        // 弹出栈顶元素
        p = st.top();
        st.pop();
        if (!st.empty() && p != st.top()) { // 如果栈顶元素和刚取出的相等,说明还没有访问过
            // 先压入右子节点
            if (p->rchild) {
                st.push(p->rchild);
                st.push(p->rchild);
            }
            // 再压入左子节点
            if (p->lchild) {
                st.push(p->lchild);
                st.push(p->lchild);
            }
        } else { // 已经访问过了
            v.push_back(p->data);
        }
    }
}

三、递归实现

/*
 * 后序遍历的递归实现
 * @node 需要遍历的节点
 * @v 保存遍历结果的数组
 */
void postorder_traversal(tree_node<T> *node, vector<T> &v) {
    if (node == nullptr)
        return;
    
    if (node->lchild)
        postorder_traversal(node->lchild);
    if (node->rchild)
        postorder_traversal(node->rchild);
    v.push_back(node->data);
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/binary-tree-inorder-traversal

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

给定一个二叉树,返回它的中序遍历。

例如输入: [1,null,2,3]

   1
    \
     2
    /
   3

输出:

[1,3,2]
进阶: 递归算法很简单,你可以通过迭代算法完成吗?

二、题解

参考:二叉树的中序遍历(递归和非递归实现)

三、代码

3.1 非递归代码

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> v;
        stack<TreeNode *> st;
        TreeNode *p;
        p = root;
        while (p || !st.empty()) {
            // 循环遍历左子树
            while (p) {
                st.push(p);
                p = p->left;
            }

            if (!st.empty()) {
                // 弹出栈顶节点
                p = st.top();
                st.pop();
                // 访问节点
                v.push_back(p->val);
                // 访问右子树
                p = p->right;
            }
        }
        return v;
    }
};

3.2 递归代码

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
private:
    void inorderTraversal(TreeNode *root, vector<int> &v) {
        if (root == nullptr) {
            return ;
        }
        if (root->left) {
            inorderTraversal(root->left, v);
        }
        v.push_back(root->val);
        if (root->right) {
            inorderTraversal(root->right, v);
        }
    }
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> v;
        inorderTraversal(root, v);
        return v;
    }
};

一、中序遍历

中序遍历过程:先访问左子节点,然后访问当前节点,最后访问右子节点。

以下试一次中序遍历过程:

二、非递归实现

非递归方式遍历依赖栈来实现,因为要先访问子节点,然后访问父节点,因此必须要有一个数据结构来保存已经父节点。

实现原理:

  1. 每次遍历到一个节点,不访问它,先压栈,然后对左子树循环做这个操作。
  2. 遍历左节点直到左节点为空时,取出栈顶元素,访问它。
  3. 然后对这个栈顶元素的右节点重复执行第一个步骤。

大致的逻辑和前序遍历差不多,相对于前序遍历而言,中序遍历知识把访问节点的时机延后了一些。前序遍历可以参考二叉树的先序遍历

代码描述:

/*
 * 中序遍历的非递归实现
 * @node 需要遍历的节点
 * @v 保存遍历结果的数组
 */
void in_order_traversal_by_stack(tree_node<T> *node, vector<T> &v) {
    stack<tree_node<T> *> st;
    tree_node<T> *p;

    p = node;
    while (p || !st.empty()) {
        // 左子树压栈
        while (p) {
            st.push(p);
            p = p->lchild;
        }

        if (!st.empty()) {
            // 取出栈顶元素
            p = st.top();
            st.pop();
            // 访问节点
            v.push_back(p->data);
            // 设置指针为右子节点
            p = p->rchild;
        }
    }
}

三、递归实现

递归实现很简单,不做详细描述:

/*
 * 中序遍历的递归实现
 * @node 需要遍历的节点
 * @v 保存遍历结果的数组
 */
void in_order_traversal(tree_node<T> *node, vector<T> &v) {
    if (node == nullptr)
        return;

    if (node->lchild)
        in_order_traversal(node->lchild, v);

    v.push_back(node->data);

    if (node->rchild)
        in_order_traversal(node->rchild, v);
}

一、题目描述

给定一个二叉树,返回它的前序遍历结果。

例如输入二叉树[1,null,2,3]

   1
    \
     2
    /
   3 

输出:

[1,2,3]
进阶: 递归算法很简单,你可以通过迭代算法完成吗?

二、题解

二叉树的前序遍历,递归和非递归方式。

参考:二叉树的前序遍历

三、代码

3.1 递归代码

class Solution {
    void preorderTraversal(TreeNode *root, vector<int> &v) {
        if (root == nullptr) {
            return;
        }
        v.push_back(root->val);
        if (root->left) {
            preorderTraversal(root->left, v);
        }
        if (root->right) {
            preorderTraversal(root->right, v);
        }
    }
public:
    vector<int> preorderTraversal(TreeNode* root) {
        vector<int> v;
        preorderTraversal(root, v);
        return v;
    }
};

3.2 非递归代码

class Solution {
public:
    vector<int> preorderTraversal(TreeNode* root) {
        stack<TreeNode *> st;
        TreeNode *p;
        vector<int> v;

        p = root;
        while (p || !st.empty()) {
            while (p) {
                // 遍历当前节点
                v.push_back(p->val);
                st.push(p);
                // 遍历左子树
                p = p->left;
            }

            // 通过栈来遍历右子树
            if (!st.empty()) {
                p = st.top()->right;
                st.pop();
            }
        }

        return v;
    }
};

一、先序遍历

先序遍历的意思是:先遍历当前节点,再分别遍历左、右子节点。

例如一棵二叉树为:

它的先序遍历序列(红色虚线标出来的)为:[1, 2, 4, 3, 5 6]。

二、递归实现

递归的实现很简单,先访问当前节点,然后分别递归访问左右子节点。

/*
 * 先序遍历的递归实现
 * @node 需要遍历的节点
 * @v 保存遍历结果的数组
 */
void pre_order_traversal(tree_node<T> *node, vector<T> &v) {
    if (node == nullptr)
        return;

    // 遍历当前节点
    v.push_back(node->data);

    // 遍历左子树
    if (node->lchild)
        pre_order_traversal(node->lchild);

    // 遍历右子树
    if (node->rchild)
        pre_order_traversal(node->rchild);
}

三、非递归实现

非递归的方式依赖栈来实现,逻辑:

  1. 访问根节点,把当前节点压栈。然后设置当前节点为左子节点,重复过程,直到左子节点为空。
  2. 设置当前节点为栈顶节点的右子节点,重复1过程。

3.1 图文描述

以上面的二叉树为例,首先访问根元素1,压栈:

然后继续访问1节点的左节点2,压栈:

节点2没有左子节点,退出左树遍历。然后取出栈顶元素2,访问它的右子节点4:

右子节点没有子节点,因此继续从栈顶取元素1,访问它的右子节点3并压栈:

节点3有左子节点,顺着遍历左子节点5,节点5入栈:

此时5没有没有左子节点了,于是取出栈顶元素5,访问它的右子节点。但是它没有右子节点,继续取出栈顶元素3,访问它的右子节点6:

访问完6后,因为没有子节点了。所以继续从栈里取元素,但是此时栈也为空了,遍历结束。

3.2 代码描述

/*
 * 先序遍历的非递归实现
 * @node 需要遍历的节点
 * @v 保存遍历结果的数组
 */
void pre_order_traversal(tree_node<T> *root, vector<T> &v) {
    stack<tree_node<T> *> st;
    tree_node<T> *p;

    p = root;
    while (p != nullptr || !st.empty()) {
        // 遍历左子树
        while (p) {
            // 访问当前节点
            v.push_back(p->data);
            // 当前节点入栈
            st.push(p);
            // 遍历左子节点
            p = p->lchild;
        }

        // 取栈顶元素,访问父亲节点的右子节点
        if (!st.empty()) {
            p = st.top()->rchild;
            st.pop();
        }
    }
}

一、题目

给定一颗二叉树和其中的一个节点,如何找出中序遍历序列的下一个节点?树中的节点除了有两个分别指向左右子节点的指针,还有一个指向父节点的指针。

以下面的二叉树为例,它的中序遍历序列是:[2, 4, 1, 5, 3, 6],节点4的下一个节点是1,节点3的下一个节点是5。

二、分析

画出上面这棵二叉树的中序遍历过程为:

根据中序遍历的特性,可以整理出来的下一个节点的规律:

  1. 如果节点有右子节点,那么下一个节点一定在右子树中,这种又分成了2种情况。

    • 右儿子没有子节点,那么下一个节点就是子节点(节点2
    • 如果右子节点有子节点,下一个节点就是右子节点种最左的节点(节点1)。
  2. 如果节点没有右子节点,并且是父节点的左子节点,那么下一个节点就是父亲节点(节点5)。
  3. 如果节点没有右子节点,并且是父节点的右子节点,那就递归查找父节点,直到一个父节点是祖父节点的左子树(节点4)。如果找到根节点都没有找到,就说明该节点已经是最后一个了(节点6)。

三、代码

类节点定义:

template<typename T>
class tree_node {
private:
    T data;
    tree_node *lchild;
    tree_node *rchild;
    tree_node *parent;
};

查找中序遍历序列中下一个节点的成员函数:

// 获取中序遍历的下一个节点
const tree_node<T> *get_next_node_in_order() const {
    const tree_node<T> *p, *father;
    if (rchild) { // 有右节点,在右子树中查找
        p = rchild;
      
        // 循环遍历左子树
        while (p->lchild)
            p = p->lchild;
      
        return p;
    } else if (parent) {
        p = this;
        father = parent;
      
        // 只要不是父节点的左子节点,一直往上找
        while (father && p != father->lchild) {
            p = father;
            father = father->parent;
        }

        // 返回父亲节点
        return father;
    }
    return nullptr;
}

四、单元测试

单元测试主要覆盖几个点:

  • 节点有右子节点。
  • 节点没有右子节点,存在一个父节点是祖先节点的左子节点。
  • 节点没有右子节点,不存在一个父节点是祖先节点的左子节点。
/*
 * 测试案例:查找二叉树中序遍历的下一个节点
 *         1
 *      /     \
 *     2       3
 *      \     / \
 *       4   5   6
 */
TEST(bin_tree, get_next_node_in_order) {
    bin_tree<int> tree;
    const tree_node<int> *node;
    int in[] = {2, 4, 1, 5, 3, 6}; // 先序遍历序列
    int pre[] = {1, 2, 4, 3, 5, 6}; // 中序遍历序列
    
    // 根据先序遍历序列和中序遍历序列生成二叉树
    tree.construct(pre, in, 6);
    EXPECT_TRUE(tree.get_root());

    // 定位到中序遍历的开始节点2,然后依次获取下一个节点,和in数组对比
    node = tree.get_root()->get_lchild();
    for (int i = 0; i < 6; i++) {
        EXPECT_TRUE(node) << i;
        EXPECT_EQ(node->get_data(), in[i]);
        node = tree.get_next(node);
    }
    EXPECT_EQ(node, nullptr);
}

测试结果:

数据结构之B树

一、B树的基本概念

B树是一种多叉树,被广泛应用于数据库索引中。它也是一种特殊的搜索树,和搜索树最大的不同在于它的每个节点都包含了n个关键字和n+1个指向子节点的指针。它的表现形式为:

B树

B树的特点:

  1. 假设x.key为当前节点中的关键字,x.child.key是子节点中的关键字,那么它们之间存在以下关系:
    x.child.key <= x.key1 <= x.child2.key <= x.key2 <= x.child3.key <= ... <= x.keyn
  2. 每个节点的关键字个数都有上界和下界,上下界用B树的最小度数t ≥ 2来表示。
  3. 除了根节点以外,每个节点必须有t - 1个关键字,除了根节点以外,每个节点都有t个孩子。
  4. 每个节点最多有2t - 1个关键字,最多有2t个孩子。当一个节点恰好有2t - 1个关键字时,称该节点是满的。
  5. t = 2的时候B树最简单,每个内部节点有2、3或者3个孩子,也被称作2-3-4树。
  6. t值越大,B的高度就越小。

为什么B树广泛应用于索引中

因为磁盘读取的最小单位是扇区,每个扇区是512字节。操作系统读取磁盘的最小单位是块,一个块包含了若干个扇区(一般一个块是4096字节,包含8个扇区)。如果和红黑树或其他二叉搜索树一样,每个节点只保存一个数据,那么磁盘读取的效率就相当低了。如果需要读取多个数据,就要执行多次磁盘IO操作才能完成任务了,而磁盘IO在系统中属于较为耗时的操作,因此多次IO势必导致效率大大降低。

B树就是为了改进这一问题而衍生出来的,B树的节点一般设置为磁盘的块大小,也就是4K,里面包含了多个数据节点的内容,这样一次IO就能读到多个数据内容。并且由于B树也具有搜索树的性质,因此很快就能定位到数据内容。

二、B树操作

B树的主要操作有两个:分裂和合并。因为B树的每个节点包含关键字的数量为[t - 1, 2t - 1],当节点的关键字数量超出后,就要对节点进行分裂操作,分裂操作会导致B树高度增加。当节点关键字被删除,数量不满足条件时就要合并两个节点,合并节点会导致B树高度下降。

3.1 增加节点

以一个度为4的B树为例,插入S后,B树节点的关键字数量变成了7,需要进行分裂。

B树分裂

此时对节点的分裂过程为:

  1. 新生成节点,将S右边的关键字和子节点移到新节点中。
  2. S左边的关键字和子节点保存在当前节点。
  3. S节点往上提,放到父节点中的合适位置。
  4. 如果S节点上提导致父节点的数量也超出了,还需要继续对父节点进行分裂。
注意:每次上提到父亲节点的关键字都是被分裂节点的中间关键字。

分裂示例

以下是度为3的B树分裂的过程,每个节点最多有5个关键字,最少2个关键字。

初始时的B树:

B树分裂-1

插入B,这只是一个简单的对叶节点插入的过程,插入后不会影响其他节点,B树的条件也还满足,直接插入就行:

B树分裂-2

插入Q,因为插入Q后会导致RSTUV节点关键字超出,因此要分裂这个节点。T节点作为中间节点放到父节点中(也可以把S提到父节点,T放在UV节点):

B树分裂-3

插入L,它被放到JK节点之后,也是一个简单的叶节点插入。但是因为根节点的关键字满了,所以对根节点分裂,此时将P提出来作为根节点,树的高度加1:

B树分裂-4

插入F,放在ABCDE节点之后,插入后将导致节点分裂,节点C提到父节点:

B树分裂-5

2.3 删除节点

在B树中删除节点,将会引发节点的合并。相对于增加节点来说,删除节点的远比增加节点要复杂。

以上面的B树为例,初始状态为:

删除节点-1

删除关键字F,作为叶子节点,删除F后并没有影响到B树的性质,直接删除即可。得到以下B树:

删除关键字F

删除关键字M,因为M所处的节点是内部节点,删除M后会导致NO关键字所在的节点没有父节点。此时需要把M的前驱关键字L替换掉M,然后删掉L

也可以把M的后继关键字N替换上来,但是M替换后会导致子节点不满足关键字数量条件。

删除节点M

删除关键字GG所处的节点也是内部节点,删除后会导致DE或者JK所处的节点没有父节点,此时也需要和上面删除M一样在子节点中找到前驱或者后继替换上来。但是这里不同的是,两个子节点都是只有t - 1个关键字,再从中拿掉一个关键字后会导致子节点关键字数量不满足。此时就需要合并两个子节点,然后直接删除G节点:

删除节点G

删除关键字D,D所处的节点是叶子节点,可以和删除节点F一样,直接删除。但是这里也有一个不同的点是,父节点和父节点的兄弟节点此时都只有t - 1个节点,此时除了删除节点D以外,还要合并父亲节点,此时树的高度减一:

删除节点D.png

删除关键字BB所在的节点关键字数量是t - 1,删除B后会导致节点的最小关键字数量不满足条件。因此要从父节点或者兄弟节点借一个关键字。此时就分为两种情况:

  1. 如果兄弟节点的关键字个数也是t - 1,那么直接和兄弟节点合并,从父节点提取一个关键字下来(下面删除C时候的场景)。
  2. 如果兄弟节点的关键字个数大于t - 1(目前就是这个情况,兄弟节点有3个关键字),此时就从父节点借一个关键字C替换掉B,借掉C后,就相当于删除了一个内置节点的元素,所以父亲节点要从它后继节点中找一个关键字补上,也就是E。最终的结果就是用C覆盖B,再用E覆盖原来C的位置,再删除E

删除关键字B

删除节点C,此时的情况就是上面的第一种情况了,兄弟节点的关键字个数是t - 1,要合并两个节点,得到:

删除关键字C

一、平衡二叉树

1.1 什么是平衡二叉树

平衡二叉树(AVL树)是二叉搜索树的一种,它是一种高度平衡的二叉树,树中的每一个节点,两个子节点的高度差最多为1。

在最坏的情况下,BST树高度差可以达到n,查找操作时间复杂度也会达到O(n),此时树是一个链式形状,如下图所示:

对这棵树而言,它和链表几乎没有差别,反而比链表多了一些内存占用。而AVL树的出现就是为了解决这种情况,在AVL树中,这些元素将会被排布成:

此时查找一个元素的时间复杂度就是O(lgn),节省了大量的时间。

AVL树所包含的操作和BST树一样:

  • SEARCH:查找某个节点在树中的位置,实现和BST完全一致
  • MINIMUM:求以指定节点为根的树中的最小元素,实现和BST完全一致
  • MAXIMUM:求以指定节点为根的树中的最大元素,实现和BST完全一致
  • PREDECESSOR:求指定节点的前驱节点,实现和BST完全一致
  • SUCCESSOR:求指定节点的后继节点,实现和BST完全一致
  • INSERT:在树中插入节点,实现在BST基础上修改
  • DELETE:删除节点,实现在BST基础上修改
网上绝大部分AVL树的实现都是通过递归来的,为了秉持《算法导论》中的优良传统,也不打算使用递归来实现。而算法导论又没有针对AVL的章节,只在红黑树的课后习题13-3中提到了,并没有实现。除了INSERT和DELETE操作以外,其他操作都和BST树一致,下面就专门针对这两个操作对AVL树进行分析。

1.2 AVL树节点

相对BST树的节点来说,AVL树节点内部多了一个height成员,表示当前节点的高度。每当某个节点的左右子节点高度差超过1时(这个高度差被称为平衡因子,一般是右子节点的高度减去左子节点的高度),就需要对该节点进行旋转调整。

struct avl_tree_node_st {
    int key;
    int height; // 当前节点的高度
    struct avl_tree_node_st *parent;
    struct avl_tree_node_st *lchild;
    struct avl_tree_node_st *rchild;
};

关于初始时节点的高度为0还是为1的问题

大部分情况下,新建一个节点,会将它的高度设置为0,这比较符合主观的逻辑,因为它没有任何子节点。

这种情况下计算一个节点的高度的步骤为:

  1. 如果该节点是叶子节点,得到高度为0
  2. 如果该节点不是叶子节点,得到高度是左右子节点中高度更高的加1

这里要处理的一个特殊情况就是规则1:判断节点是否为叶节点,不是叶节点才能通过规则2计算。如果直接按照规则2来计算的话叶子节点高度是1,和预定条件相违背。

而如果初始化时,设置节点的高度为1,此时计算一个节点的高度就只有一个操作:

  • 它的高度等于其左右子节点中高度更大的加1

不用判断是否为叶子节点,在程序处理中更好处理一点。而实际上,高度对我们来说不重要,重要的是平衡因子

因此,AVL树节点的高度初始时设置为1更好一点。

二、左旋和右旋

AVL树最重要的操作就是左旋和右旋,当一个节点不平衡后,都需要通过这两个操作达到平衡:

对左旋和右旋的描述:

  • 对节点y右旋:y将变成其左子节点x的右子节点,原来x的右子节点β变成y的左子节点。
  • 对节点x左旋:x将变成其右子节点y的左子节点,原来的y的左子节点β变成x的右节点

两个操作是相反的,彼此旋转都可以得到对方,以下是一个左旋的例子:

左旋的代码实现:

static void _left_rotate(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;
    // x表示左旋节点,y表示x的右子节点
    x = node;
    y = x->rchild;

    // 修改x节点的右子节点指向
    x->rchild = y->lchild;
    if (y->lchild)
        y->lchild->parent = x;

    // 修改父节点使其子节点指向新的x节点
    y->parent = x->parent;
    if (x->parent == NULL)
        t->root = y;
    else if (x->parent->lchild == x)
        x->parent->lchild = y;
    else
        x->parent->rchild = y;

    // 重新设置x和y的关系
    y->lchild = x;
    x->parent = y;

    // 修改旋转后节点的高度
    x->height = NEW_HEIGHT(x);
    y->height = NEW_HEIGHT(y);
}

右旋和左旋基本一致,只是指针方向相反了。

三、二叉树操作

3.1 计算节点高度

获得一个节点的高度是O(1)的,因为节点内部已经有一个height成员,直接拿就行了。

static inline int _node_height(struct avl_tree_node_st *node) {
    if (node == NULL)
        return 0;
    return node->height;
}

而当一个节点更新之后,从这个节点往上的根节点高度都有可能更新,需要重新设置高度,此时更新的操作就是比较左右子节点的高度,在更高的基础上加1,C语言中可以直接使用宏定义完成:

#define NEW_HEIGHT(node)    \
    max(_node_height(node->rchild), _node_height(node->lchild)) + 1

计算某个节点的平衡因子:

static inline int _get_balance(struct avl_tree_node_st *node) {
    if (node == NULL)
        return 0;
    return _node_height(node->rchild) - _node_height(node->lchild);
}

3.2 增加节点

增加节点的前置操作和BST一致,给节点找到一个合适的位置先插入。然后再判断其是否满足平衡的条件,如果不满足,对它进行调整。增加节点有会有下面四种不平衡的情况。

3.2.1 LL型

下面的树中插入元素5导致树失衡,此时元素30的平衡因子为-2

这种情况属于LL型不平衡状态,只要对不平衡的节点30执行右旋操作即可解决不平衡。

3.3.2 LR型

下面的树中插入元素15,此时元素30失衡,平衡因子为-2

这种情况属于LR型不平衡状态,要先对插入元素的父节点10进行左旋,转换成LL型再对30右旋解决平衡。

3.3.3 RR型

下面的树中插入元素55,元素30失衡,平衡因子为2

这种情况属于RR型不平衡状态,对30执行左旋即可恢复平衡。

3.3.4 RL型

在下面的树中,插入45导致30失衡,平衡因子为2

这种情况属于RL型不平衡状态,先对50右旋,转换成RR型再对30左旋,恢复平衡。

3.3.5 插入操作实现

插入操作和BST的插入操作一模一样,只是在插入完成后,新增了一步对节点的平衡操作。

void avl_tree_insert(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;
    x = t->root;
    y = x;
    while (x != NULL) {
        y = x;
        if (node->key < x->key)
            x = x->lchild;
        else
            x = x->rchild;
    }

    node->parent = y;
    if (y == NULL)
        t->root = node;
    else if (node->key < y->key)
        y->lchild = node;
    else
        y->rchild = node;

    // 平衡节点
    if (y != NULL)
        avl_tree_insert_balance(t, node);
}

3.3.6 插入平衡

上面说过:插入节点后,有可能导致其父节点甚至祖父节点的高度变化。因此,平衡节点时,要从新增节点的父节点开始,一直到根节点,重新更新路径上每个节点的高度并计算平衡因子,一旦平衡因子不符合条件,则按照上面的四种情况旋转。

void avl_tree_insert_balance(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;
    // is_modify用来表示是否被旋转过了,如果旋转过了,旋转操作里面已经更新了高度,无需再更新高度
    int height, balance, is_modify;
    // x是新增节点
    x = node;
    // y是新增节点的父节点,从父节点开始,重新更新节点高度
    y = node->parent;

    while (y) {
        is_modify = 0;
        balance = _get_balance(y);
        if (balance > 1) {
            // 右子树高度高于左子树高度
            x = y->rchild;
            if (x->key < node->key) {
                // RR
                _left_rotate(t, y);
            } else {
                // RL
                _right_rotate(t, x);
                _left_rotate(t, y);
            }
            is_modify = 1;
        } 

        if (balance < -1) {
            // 左子树高度高于右子树高度
            x = y->lchild;
            if (x->key > node->key) {
                // LL
                _right_rotate(t, y);
            } else {
                // LR
                _left_rotate(t, x);
                _right_rotate(t, y);
            }
            is_modify = 1;
        }

        if (is_modify == 0) {
            // 没有执行旋转,判断高度是否有改变
            height = NEW_HEIGHT(y);
            if (height != y->height)
                y->height = height;
            else
                break; // 如果节点高度没有改变的话,也不会再影响父节点的高度,直接跳出循环
        }
       
        y = y->parent;
    }
}

3.3 删除节点

删除节点的操作比插入操作要复杂,同样也是先利用BST中的删除操作,执行元素删除。然后再对删除后的节点进行平衡操作。删除一个元素也是四种失衡场景,也是LL/LR/RR/RL,恢复平衡的方式和增加一致:

3.3.1 LL型

3.3.2 LR型

3.3.3 RR型

3.3.4 RL型

3.3.5 如何确认是哪种类型的旋转

删除节点失衡后,对失衡节点的旋转和调整和新增节点一模一样。

但是和新增节点不同,新增节点通过判断父节点的高度和平衡因子很容易就能分辨出旋转类型。删除节点后,当发现失衡时当前节点已经是失衡节点了,并不能直接确定属于哪种失衡。

处理办法:假设失衡节点是z,令yz的子节点中高度更高的一个,令xy的子节点中高度更高的一个,例如:

通过比较三者的key值就可以确定旋转的类型,此处y.key > z.key,属于R型,而x.key > y.key,也是R型。两者结合起来就是RR型,元素30应该执行左旋。

特殊情况

z失衡时,左右子节点的高度必定不同。但是y的两个子节点高度有可能相同,这样就会产生两组特殊情况:

此时y的左右节点高度都是一致的,而实际上这种情况属于LL型,应该对30右旋。

相反的,下面这种情况应该属于RR型,要对30左旋:

3.3.6 删除节点后如何更新高度

增加节点后,从增加节点的父节点开始更新高度。但是删除和新增不同,删除都是通过叶子节点替代的方式来处理的,直接从删除节点的父节点开始更新高度不准确。此时可以分为以下几种情形:

删除节点只有一个节点

使用子节点L替换父节点后,L的高度不会增加,但是原来z的父节点高度减少了,要从z的父节点开始更新高度。

删除节点有两个节点且后继节点是删除节点的右儿子

在BST删除一个节点z时,如果节点有两个儿子,那么就要在右子树中找到最小的元素y替换被删除元素的位置。这个最小的元素y有两种情况:yz的右儿子和y不是z的右儿子

yz的右儿子时:

y替代z后,yx的高度都不会变化,同样只要从z的父节点开始更新高度就行了。

但是当y不是z的右儿子时,删除节点z后,x的高度不会变化,从x的新父亲r开始的节点就有可能发生变化,例如下图中的r的高度就减少了1:

此时要从y的原始父节点开始更新高度。并且还要注意的是:被删除节点z的父节点的高度也变化了,修改r的高度之后还要继续向上修改其祖先节点的高度。

3.3.7 删除代码

AVL删除和BST类似,只是多了一个x记录删除后需要开始更新高度的起始节点。

void avl_tree_delete(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;

    // x记录节点删除后需要开始更新高度的起始节点
    x = node;
    // y是实际要进行替换删除的节点
    y = node->parent;

    if (node->lchild == NULL || node->rchild == NULL) {
        y = node->lchild ? node->lchild : node->rchild;
        _transplant(t, node, y);
        // 只有一个子节点,需要更新高度的节点是被删除节点的父节点
        x = node->parent;
    } else {
        y = tree_minimum(node->rchild);
        x = y;
        if (y != node->rchild) {
            // 不是右子节点,需要更新高度的节点是y的父节点r
            x = y->parent;
            _transplant(t, y, y->rchild);
            y->rchild = node->rchild;
            y->rchild->parent = y;
        }
        _transplant(t, node, y);
        y->lchild = node->lchild;
        y->lchild->parent = y;
    }
    // 删除完成之后,修改各节点高度,调整平衡
    if (x != NULL)
        avl_tree_delete_balance(t, x);
}

3.3.8 更新节点高度和平衡

根据章节3.3.1-3.3.5可以得到删除节点后的平衡处理代码:

void avl_tree_delete_balance(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y, *z;
    int height, balance, is_modify;

    // z是被删除节点后需要开始更新高度的起始节点
    // z失衡后,y是z的子节点中高度最高的,x是y的子节点中高度较高的
    z = node;

    while (z) {
        is_modify = 0;
        balance = _get_balance(z);

        if (balance > 1) {
            // R型
            y = _node_height(z->rchild) > _node_height(z->lchild) ? z->rchild : z->lchild;
            // 注意>=符号,不是>号
            x = _node_height(y->rchild) >= _node_height(y->lchild) ? y->rchild : y->lchild;
            if (x->key > y->key) {
                // RR
                _left_rotate(t, z);
            } else {
                // RL
                _right_rotate(t, y);
                _left_rotate(t, z);
            }
            is_modify = 1;
        }

        if (balance < -1) {
            y = _node_height(z->rchild) > _node_height(z->lchild) ? z->rchild : z->lchild;
            x = _node_height(y->rchild) > _node_height(y->lchild) ? y->rchild : y->lchild;
            if (x->key < node->key) {
                // LL
                _right_rotate(t, z);
            } else {
                // LR
                _left_rotate(t, y);
                _right_rotate(t, z);
            }
            is_modify = 1;
        }

        if (is_modify == 0) {
            height = NEW_HEIGHT(z);
            z->height = height;
        }

        z = z->parent;
    }
}

四、关于节点增加或删除后更新祖先节点高度的终止条件

当插入或者删除节点的时候,会引起其祖先节点的高度修改,从而产生不平衡。因此,每次增加或者修改元素之后,需要从该节点的父节点开始,在去往根节点的路径上,逐个对每个节点的高度更新。

由于树的高度是左右子节点的高度决定的,那么可以得到一个结论:如果左右子节点的高度没有变化的话,其祖先节点的高度一定不会变化,其平衡因子也不会变化。因此,对于增加节点而言,如果在到根路径上的某个节点高度没有变化的话,就无需再继续向上更新了。例如:

增加2535元素并没有引起其父节点高度的变化,因此其祖父节点的高度也不会变化,在更新完其父节点的高度后就可以终止循环了。

在增加和删除操作中,都有一个is_modify变量来记录该节点是否失衡被旋转了。如果这个字段的值是0,表示该节点没有失衡,没有执行旋转操作,需要更新该节点的高度。如果这个字段是1,表示节点被旋转了,旋转之后的节点高度已经被重新赋值了,无需再次修改。这也就是为什么把计算平衡因子放在计算节点高度之后了,避免重复更新高度。

height的作用就是判断节点高度是否变化了,如果没有变化,根据上面的出的结论,就无需继续遍历祖先节点了,祖先节点的高度一定不会变化,跳出循环即可。

五、参考文档

BST树实现

AVL Tree | Set 1 (Insertion)

AVL Tree | Set 2 (Deletion)