2019年4月

一、平衡二叉树

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)

一、二叉搜索树

1.1 什么是二叉搜索树

算法导论中对二叉搜索树(Binary Search Tree, 简称BST)的定义:

设x是二叉搜索树中的一个节点,如果y是x左子树中的一个节点,那么y.key<=x.key。如果y是x右子树中的一个节点,那么y.key>=x.key。

以下两棵树就是二叉搜索树:

其中a是一个包含6个节点,高度为2的二叉搜索树。b是一个包含相同关键字、高度为4的低效二叉树。

注意:树中包含了两个相同的节点5,在实际用途中,很少把相同的关键字放到二叉搜索树中。

二叉搜索树主要的操作:

  • SEARCH:查找某个节点在树中的位置
  • MINIMUM:求以指定节点为根的树中的最小元素
  • MAXIMUM:求以指定节点为根的树中的最大元素
  • PREDECESSOR:求指定节点的前驱节点
  • SUCCESSOR:求指定节点的后继节点
  • INSERT:在树中插入节点
  • DELETE:删除节点

定理:二叉搜索树中的以上各个操作都是在O(h)时间内完成。

1.2 树节点定义

二叉搜索树的节点需要包含以下元素:

  • 节点的键值,也就是key
  • 父节点和左右子节点的指针

假设key值类型为int,那么树节点的结构应该为:

struct bst_tree_node_st {
    int key;
    struct bst_tree_node_st *lchild;
    struct bst_tree_node_st *rchild;
    struct bst_tree_node_st *parent;
};

二、二叉搜索树操作

2.1 查找操作( SEARCH)

通过二叉搜索树的性质很容易得到查找算法:从树根开始查找,沿着树的路径一直向下。对每个遇到的节点x,比较关键字k和x.key,如果两个关键字相等,查找终止。如果k小于x.key,在x的左子树中继续查找。否则就在x的右子树中继续查找。

伪代码如下(来自《算法导论》):

TREE-SEARCH(x, k)
    if x == NIL or k == x.key
        return x
    if k < x.key
        return TREE-SEARCH(x.left, k)
    else return TREE-SEARCH(x.right, k)

上面采用了递归的方法来进行查找,递归最明显的特点就是简单,代码量小。但是在树高度很高时候,递归就会产生巨大的内存消耗和更多的CPU消耗,因为要频繁对函数及参数压栈,并且函数中栈是有大小限制的,不能无限递归。

大部分时候建议使用非递归查找,非递归查找的伪代码实现为:

ITERATIVE-TREE-SEARCH(x, k)
    while x ≠ NIL and k ≠ x.key
        if k < x.key
            x = x.left
        else x = x.right
    return x

非递归对应的C语言描述:

struct bst_tree_node_st *bst_tree_search(struct bst_tree_st *t, int key) {
    struct bst_tree_node_st *p = t->root;
    while (p != NULL && key != p->key) {
        if (key < p->key)
            p = p->lchild;
        else
            p = p->rchild;
    }
    return p;
}

2.2 求最大元素(MAXIMUM)和最小元素(MINIMUM)

根据二叉树的性质可知:最小的节点一定是最左边的节点,最大的节点一定是最右边的节点。

例如对下面这个二叉树而言,最小的元素是2,最大的元素是20

图2.2.1

找最小的元素只要一直在左子节点中查找,直到节点的左节点是NULL为止。相反,找最大的元素,只要在节点的右节点中查找,直到右节点是NULL为止。

/*
 * 得到某个子树中的最小元素节点
 * @root 待查找的子树根节点
 * @return 返回最小元素节点的指针,如果最小元素就是node,那么返回node
 */
struct bst_tree_node_st *bst_tree_minimum(struct bst_tree_node_st *root) {
    struct bst_tree_node_st *p = root;
    while (p != NULL && p->lchild != NULL)
        p = p->lchild;
    return p;
}

/*
 * 得到某个子树中的最大元素节点
 * @root 待查找的子树根节点
 * @return 返回最大元素节点的指针,如果最大的元素就是node,那么返回node
 */
struct bst_tree_node_st *bst_tree_maximum(struct bst_tree_node_st *root) {
    struct bst_tree_node_st *p = root;
    while (p != NULL && p->rchild != NULL)
        p = p->rchild;
    return p;
}

2.3 求前驱(PREDECESSOR)和后继(SUCCESSOR)

节点x的前驱节点是指中序遍历中顺序在x之前的元素,节点x的后继节点指中序遍历中顺序在x之后的元素。

以2.3节中的二叉树为例,其中序遍历的结果为:2 3 4 6 7 9 13 15 17 18 20。对于元素6而言,它的前驱节点是4,后继节点是7

图2.2.1

求前驱节点的过程:

  • 如果节点有左子节点,那么前驱节点一定是左子树中的最大元素。例如6的前驱是415的前驱是13
  • 如果节点没有左子节点,那么它的前驱就是以它或者以它任一祖先为右儿子的节点,例如上图中节点9的前驱是7,节点7的前驱是6

求后继节点的过程:

  • 如果节点有右子节点,那么后继节点一定是右子树中最小的元素。例如15的后继节点是1718的后继节点是20
  • 如果节点没有右子节点,那么它的后继就是以它或者以它任一祖先为左儿子的节点,例如上图中的13的后继节点是1517的后继节点是18
可以得到的一个结论:树中最小的元素没有前驱节点,树中最大的元素没有后继节点。

求前驱节点和后继节点的C语言描述:

/*
 * 求树中某个节点的后继节点
 * @node 当前节点,如果没有后继节点,返回NULL
 */
struct bst_tree_node_st *bst_tree_successor(struct bst_tree_node_st *node) {
    struct bst_tree_node_st *x = node, *p;
    if (x->rchild)
        return bst_tree_minimum(x->rchild);

    p = x->parent;
    while (p != NULL && p->rchild == x) {
        x = p;
        p = x->parent;
    }
    return p;
}

/*
 * 求树中某个节点的前驱节点
 * @node 当前节点,如果没有前驱节点,返回NULL
 */
struct bst_tree_node_st *bst_tree_predecessor(struct bst_tree_node_st *node) {
    struct bst_tree_node_st *x = node, *p;
    if (x->lchild)
        return bst_tree_maximum(x->lchild);

    p = x->parent;
    while (p != NULL && p->lchild == x) {
        x = p;
        p = p->parent;
    }
    return p;
}

2.4 插入节点(INSERT)

插入节点和搜索节点的工作方式一样,先根据元素大小找到合适的位置,然后作为子节点插入,每次插入的节点必定是叶子节点

例如在下面的树中插入元素13,首先要找到它的父节点15,然后作为它的左子节点插入:

C语言实现:

/*
 * 在树中插入节点
 * @t 树
 * @node 新节点
 */
void bst_tree_insert(struct bst_tree_st *t, struct bst_tree_node_st *node) {
    struct bst_tree_node_st *x, *y;
    y = NULL;
    x = t->root;
    // 找到合适的插入位置
    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;
}

2.5 删除节点(DELETE)

2.5.1 删除节点的基本操作

相对来说,删除节点要比插入节点复杂许多,它有三种不同的情况:

  1. 如果待删除节点z没有孩子节点,那么直接把它删除,修改父节点指针即可完成。
  2. 如果待删除节点z只有一个孩子,那么将这个孩子替换到z的位置,修改z的父节点指针,指向z的子节点。
  3. 如果待删除节点z有两个孩子,找到z的后继节点y,让y占据z的位置,同时还要让y的右子节点(如果有的话)占据y的位置。

前面两种情况相对简单,甚至可以被合并成一种情况。而第三种情况最为复杂,它还能继续向下分解:

  • z的后继节点y就是z的右子节点
  • z的后继节点y不是z的右子节点,这种情况下可以推断出y一定不含左子节点,但此时y的右子节点x又有两种情况:

    • x为空
    • x不为空

下面分别对以上几种情况进行讨论:

2.5.2 删除节点只有一个儿子

只有左子节点的情况

只有右子节点的情况

不管是左儿子,还是右儿子,直接替换原删除节点即可。

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

对应情况:

只需要把z的位置替换成y就可以了。

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

后继节点y不是z的右儿子,那么y必定没有左儿子(如果y有左儿子的话,那么z的后继节点肯定就是y的左儿子而不会是y)。

情况对应下图:

此时要做的两步操作:

  1. y的右儿子(不管是否为NULL)替换成y
  2. y替换成z

2.5.4 删除节点的代码

添加一个辅助操作TRANSPLANT,英文意思是移植,表示把一个节点用另一个节点代替:

static void transplant(struct bst_tree_st *t,
    struct bst_tree_node_st *old_node,
    struct bst_tree_node_st *new_node) {
    if (old_node->parent == NULL)
        t->root = new_node;
    else if (old_node == old_node->parent->lchild)
        old_node->parent->lchild = new_node;
    else
        old_node->parent->rchild = new_node;
    
    // 如果替换新节点不是NULL,设置新节点的父指针指向
    if (new_node != NULL)
        new_node->parent = old_node->parent;

然后在TREE_DELETE操作中调用TRANSPLANT完成删除操作:

/*
 * 删除节点
 * @t 树
 * @node 待删除的节点
 */
void bst_tree_delete(struct bst_tree_st *t, struct bst_tree_node_st *node) {
    struct bst_tree_node_st *y;
    if (node->lchild == NULL || node->rchild == NULL)
        // 至少有一个子节点为空,直接用子节点替换
        _transplant(t, node, node->lchild ? node->lchild : node->rchild);
    else {
        // 得到右子树中的最小节点,即节点在右子树中的后继节点
        y = bst_tree_minimum(node->rchild);
        if (y->parent != node) {
            // y不是删除节点的子节点,用y的右节点替换y
            _transplant(t, y, y->rchild);
            y->rchild = node->rchild;
            y->rchild->parent = y;
        }
        // 用y替换删除节点
        _transplant(t, node, y);
        y->lchild = node->lchild;
        y->lchild->parent = y;
    }
}

一、结构体描述

结构体对齐是C/C++优化结构体内存排布的一种机制,它的出现是为了解决跨总线寻址的问题。

例如对于以下结构:

struct stu {
    char a;
    int b;
};

如果不执行结构体对齐,它在内存中的排布应该是这样的:

对于字段b而言,它占四个字节,跨了两个总线地址,如果要取出b的值就要寻址两次。而如果把结构设计成以下形式:

对b的访问就只用寻址一次了,所以,为了减少cpu消耗,编译器会对结构进行优化,会对内存进行对齐。

二、对齐原则

结构体对齐遵循的原则:

  1. 第一个成员的首地址为0,每个成员的首地址是自身大小的整数倍,不够时填充空字节
  2. 结构体总大小是结构体中最大元素的整数倍,存在结构体嵌套时,最大元素是内部的结构体成员而不是这个结构体

2.1 规则一

struct Stu {
    char name[7];
    int age;
    char sex;
    short id;
    int friends;
};
  1. name成员放在Stu成员的最开始,它占了7个字节,位于0-6
  2. age占四个字节,它本应该从第7个字节存放,但是7不能整除age的大小,它被放在第8个字节
  3. sex占一个字节,放在第12位
  4. id占两个字节,本应放在第13字节,但13无法整除2,此时会在id前面填充一个字节对齐
  5. friends放在第16个字节开始

使用vs查看内存分布可以看到,name和sex后面被填充了一个字节:

1>class Stu    size(20):
1>    +---
1> 0    | name
1>      | <alignment member> (size=1)
1> 8    | age
1>12    | sex
1>      | <alignment member> (size=1)
1>14    | id
1>16    | friends
1>    +---

2.2 规则二

struct Nginx {
    struct Stu stu;
    char c;
};

当结构体中包含结构体时,此时最大的元素是name,它对齐后占8个字节,为了维持对齐的特性,会在Nginx成员后面继续填充:

1>class Nginx    size(24):
1>    +---
1> 0    | Stu stu
1>20    | c
1>      | <alignment member> (size=3)
1>    +---

可以看到c后面填充了八个字节。

一、问题描述

搭了一个开源图床,因为备案的原因部署在海外。访问速度太慢准备上CDN(有一个已备案的域名),但是在部署CDN的途中就出现了各种问题。鼓捣了几天终于解决了,记录下踩坑记录和解决方案。

软件环境:

图床是直接用docker部署了,没有再手动去折腾lnmp环境了,别人已经准备好的docer环境拿过来就直接用了。

用一个nginx作为CDN和图床服务的代理客户端,不直接把图床暴露出来了,也是一般服务的部署方式。

平常的服务大部分都是这么干的,用起来很舒服。

这个部署在http环境下是没有问题的,能正常使用。但是把http改成https的时候就出问题了,一开始的问题就是跨域:

因为nginx是配置的http反向代理:proxy_pass http://127.0.0.1:xxxx,到docker中的也是http,所以它返回到前端的js/css资源也是http的。而此时CDN开启了强制https,浏览器中已经是https,跨域就这么产生了。

为了解决这个问题,就在docker的web环境中也加一个强制https跳转,想着apache也强制https,那么就不会返回http资源,跨域解决。

想象是美好的,现实是残酷的,这么一配置之后,CDN就挂了,原因:重定向次数过多

于是分析了一下,这么干确实有问题:

  1. nginx反向代理是用的http,对apache来说它收到了http请求,于是302重定向到https的页面。
  2. 然后CDN就访问重定向的https的页面,但是nginx收到之后又代理到http。

于是乎,CDN和apache之间就产生了某种误会,导致302死循环诞生!因此,这么干不行!得另寻它法。

那么现在的问题就产生了:如何圆润的以https的方式访问到docker内部的资源?

方案一:nginx和apache改成https通信

这个方案要做的工作:

  1. 手动生成https证书
  2. 在apache内部署https
  3. nginx和apache通过https代理

apache不熟悉,看了https部署比nginx要麻烦一些,而且内部通信也用https对性能有影响,所以就不考虑了(虽然这个方法可能比较有效,但是内心比较排斥apache,甚至想把docker中的apache换掉都不想用它配置https)。

方案二:使用nginx的sub_filter替换http为https

验证了一下,不可行,因为实际上返回到客户端的资源文件都是相对文件目录,不是http的全路径资源。

于是就崩了,百度了几天也没有办法解决。最后,在google上搜,竟然一下就找到了解决方案。

二、完美解决方案

设置http头部X-Forwarded-Proto,这个头部的作用是用于识别协议(HTTP 或 HTTPS),主要针对内部访问重定向时的协议。因此,只要在反向代理时添加以下配置就好了:

proxy_set_header X-Forwarded-Proto $scheme;

$scheme是nginx的内部变量,表明当前访问的协议,当前如果是https,那么转发到后台服务的时候就是https。这样问题就解决了。

一、归并排序

归并排序的思想是:依次把数组分成若干个小组进行排序,然后逐步把每个小组进行排序合并。

最经典的介绍归并排序的示例图为:

第一步:把8个元素分别分成4个小组排序,得到1, 2/3, 4/5, 6/7, 8四组。

第二步:把1, 23, 4合并,5, 67, 8合并,得到1, 2, 3, 45, 6, 7, 8两组。

第三部:把剩下的两组合并,得到有序的数组1 2 3 4 5 6 7 8,排序完成

归并排序的时间复杂度为O(lgn)

二、合并两个有序数组

合并两个有序数组需要用到一个辅助数组,保存排序后的元素。具体的逻辑是:分别使用两个指针,指向两个数组的首端,比较两个指针指向的元素,把小的放到辅助数组里面,指针后移,重复上面的操作。

例如上面合并1, 2, 3, 45, 6, 7, 8两组元素,设置指针i和j分别指向两个数组,此时a[i] > b[j],把b[j]放到数组里面去:

然后依次执行,直到b数组中的元素放完:

再把a中所有的元素放到有序数组中去,排序就完成了:

合并部分的代码:

// mid是i的结束位置,right是j的结束位置,tmp是临时数组
while (i <= mid && j <= right) {
    if (data[i] <= data[j])
        tmp[k++] = data[i++];
    else
        tmp[k++] = data[j++];
}

三、归并排序实现


/*
 * @data 待排序数组
 * @tmp 临时数组
 * @left 当前需要排序的区间开始
 * @right 当前需要排序的区间结束
 */
static void _merge_sort(int *data, int *tmp, uint left, uint right) {
    uint mid, i, j, k, cur;
    if (left == right)
        return;

    mid = (left + right) / 2;
    i = left;
    j = mid + 1;
    k = left;
    
    // 分组排序
    _merge_sort(data, tmp, left, mid);
    _merge_sort(data, tmp, mid + 1, right);

    // 合并有序分组
    while (i <= mid && j <= right) {
        if (data[i] <= data[j])
            tmp[k++] = data[i++];
        else
            tmp[k++] = data[j++];
    }

    // 处理两个数组多余的元素
    while (i <= mid)
        tmp[k++] = data[i++];
    while (j <= right)
        tmp[k++] = data[j++];

    // 拷贝已经排好序的到正式需要排序的数组
    for (i = left; i <= right; i++)
        data[i] = tmp[i];
}

/*
 * @data 待排序数组
 * @len 数组长度
 */
void merge_sort(int *data, const uint len) {
    // 为临时数组分配空间
    int *tmp = malloc(sizeof(int) * len);
    if (tmp == NULL)
        return;

    _merge_sort(data, tmp, 0, len - 1);

    // 删除临时数组的空间
    free(tmp);
    tmp = NULL;
}

一、计数排序

其基本思想为:假设n个输入的元素中的每一个都是在0到k之间的一个整数,对于每一个输入元素x,确定小于x的元素个数,直接把x放在它输出的数组中的位置上。例如有17个元素小于x,则x就应该在数组的第18个位置上。当有几个元素相同时,这一方案就要略作修改,不能都放在同一个位置上。

计数排序需要提供两个辅助数组用来计数,一个用来保存已经排好序的元素,一个用来保存每个元素在数组中出现的次数。计数排序是不稳定的排序算法,时间复杂度(n),空间复杂度是O(n + k),其中k是排序数组元素的范围。

例如以下数组A:

在通过计数后,得到的辅助数组C为:

对于元素1而言,它前面有两个0,因此排序后它的位置在第三个,即下标为2的位置上,但是它的数量为0,就不应该给他排序。而元素2是有的,它的位置应该是0的数量和1的数量(如果有元素1的话)之和的后面一位,在这里是第3位,下标为2。这里类似斐波那契数列:每个元素都等于前面两个的和。

因此,有必要对辅助数组C做处理,设置其所在的位置为前面所有元素数量之和,这样就不用每次都重新计算前面的数量了(参考斐波那契数列计算):

要注意的是,对于2这个元素来说,它出现了两次,如果不做特殊处理,它们处的位置都是第四个位置。要想办法把两个2分别放到自己合适的位置上去。可以采取的办法是从后往前排序,每排完一个数字,当前数字的值减一。

图例:

第一步,A的最后一个元素是3,得到C[3]的值为7,把3先放到第7个位置,并把C[3]的值减一:

第二步,现在A的最后一个元素(未排序的)是0,得到C[0]为2,放到第二个位置,C[0]减一:

第三步,重复以上步骤,直到A中所有的元素都排序完毕:

此时,整个数组就是有序的了。

二、计数排序的代码实现

// 数据可能出现的最大值
const unsigned int max_val = 100;
void count_sort(int data[], const unsigned int n) {
    // 计数数组
    int cnt[max_val] = {0};
    // 生成辅助数组
    int *tmp = new int[n];
    unsigned int i;

    // 初始化计数数组
    for (i = 0; i < n; i++) {
        cnt[data[i]]++;
    }

    // 累加所有的数量
    for (i = 1; i < max_val; i++) {
        cnt[i] += cnt[i - 1];
    }

    // 计数排序
    for (i = n - 1; i >= 0 && i < n; i--) {
        tmp[cnt[data[i]] - 1] = data[i];
        cnt[data[i]]--;
    }

    // 拷贝辅助数组到排序数组
    for (i = 0; i < n; i++) {
        data[i] = tmp[i];
    }
}

一、堆排序原理

通过最大堆的性质可以知道:一个堆中最大的元素总是在堆顶的,即数组下标0的位置。基于这一点,我们可以每次都把堆中的最大值提取出来,放到当前数组的后面。然后重新构建最大堆,重复这个过程,以此来完成一个数组的排序。

例如,一个已知的最大堆为:

堆排序-1

把最大的元素16提取出来,放到最后。然后重新建堆,此时堆中最大的元素为15,更新后的堆为:

堆排序-2

再把15提取出来,重新建堆,得到:

最大堆-3

如此往复,直到最后堆中的元素只有一个,就完成了整个数组的排序。

二、代码实现

堆排序的关键点在于构造堆,如何构造堆可参考数据结构之堆。基于模板的最大堆化函数实现:

template <typename T>
static void max_heapify(T *data, size_t len, size_t idx) {
    size_t largest, lchild, rchild;

    lchild = LCHILD(idx);
    rchild = RCHILD(idx);

    if (lchild < len && data[lchild] > data[idx])
        largest = lchild;
    else
        largest = idx;

    if (rchild < len && data[rchild] > data[largest])
        largest = rchild;

    if (idx != largest) {
        my_swap(data[idx], data[largest]);
        max_heapify(data, len, largest);
    }
}

实现堆排序,堆排序的关键点在于从后往前排:

template <typename T>
static void heap_sort(T *data, size_t len) {
    size_t i, mid;
    mid = len / 2;

    // 建堆
    for (i = mid - 1; i >= 0 && i < len; i--) {
        max_heapify(data, len, i);
    }

    // 堆排序,从后往前
    for (i = len - 1; i >= 1; i--) {
        my_swap(data[i], data[0]);
        max_heapify(data, --len, 0);
    }
}

时间复杂度

堆排序的时间主要消耗再建堆上面,每次拿掉一个元素之后,都重新执行最大堆化

每次构造最大堆的时间复杂度为O(log(n)),因此堆排序的总时间复杂度为n(log(n)),n代表元素个数。

一、堆

堆是一种数据结构,通常通常所说的堆即二叉堆。二叉堆是一个数组,可以被看成一个完全二叉树,如下图所示:

他在数组中的表现形式为:

通过数组很容易得到每个父节点和其子节点的关系,假设数组的起始下标为0,那么有:

PARENT(i) = (i - 1) / 2  --> 如下标1和2的数组元素,其父节点是下标0的元素。
LEFT_CHILD(i) = (i * 2) + 1   
RIGHT_CHILD(i) = (i * 2) + 2  -->如下标为0的数组元素,其左右子节点的下标分别是1和2。

因此可以直接在程序中定义:

#define PARENT(i) ((i - 1) >> 1)
#define LEFT_CHILD(i) (((i) << 1) + 1)
#define RIGHT_CHILD(i) (((i) << 1 ) + 2)

二、最大堆和最小堆

堆有最大堆和最小堆之分,在最大堆中,除了根节点以外的所有节点i都要满足:A[PARENT[i]] >= A[i],所有的子节点的值不会超过其父节点的值。因此,在最大堆中,最大的元素存放在根节点中。

而最小堆和最大堆相反,除了根节点以外的所有节点i都要满足:A[PARENT[i]] <= A[i], 所有子节点的值都大于等于其根节点的值。因此,最小堆中根节点的元素是最小的。

在堆排序算法中,使用的是最大堆,最小堆通常用于构造优先级队列。

以最大堆为例,其包含的操作为:

  • MAX_HEAPIFY:用来维护一个最大堆。
  • BUILD_MAX_HEAP:从一堆无序的数组中构造出一个最大堆。
  • HEAPSORT:执行一次堆排序过程。

三、最大堆

3.1 维护最大堆

把一个堆构造成最大堆的流程:

  1. 从A[i], A[LEFT[i]], A[RIGHT[i]]中选出最小的,保存其下标largest
  2. 如果largest不等于i,交换A[i]和A[largest]

以下图为例,当前堆中4处于一个非正确的位置:

首先把4和其儿子中最大的14交换,得到以下堆:

交换后4依旧不是最小的元素,继续交换48

4当前已经是叶子节点了,此时对4的最大堆化操作就完成了。并且此时的堆也就是一个最大堆。

不难看出:对一个高度为h的树来说,这个操作的时间复杂度为O(h)

对应的c代码:

static void _max_heapify(int *data, const uint len, const uint i) {
    unsigned int lchild, rchild;
    unsigned int largest;

    lchild = LEFT_CHILD(i);
    rchild = RIGHT_CHILD(i);

    // 得到父子节点中最小的元素
    if (lchild < len && data[i] < data[lchild])
        largest = lchild;
    else
        largest = i;

    if (rchild < len && data[largest] < data[rchild])
        largest = rchild;

    // 交换最小的元素
    if (i != largest) {
        swap_int(&data[i], &data[largest]);
        _max_heapify(data, len, largest);
    }
}

3.2 建堆

建堆的过程实际上是执行多次MAX_HEAPIFY,我们只需对2/n以内的元素都执行MAX_HEAPIFY操作即可完成一个最大堆的构建。

因为[n/2, n)之间的元素都是叶子节点,所以无需对它们进行转换操作。

要注意的是建堆必须从下往上,否则可能出现堆只是局部有效,对全局而言并非有效:

以上图为例,如果从上到下建堆,在调整完4的位置之后,14被放在了树根。而实际上被放在树根的应该是16,因为接下来就不会调整14了,它的位置就永远不对,这个堆也就不是一个符合要求的堆。

代码

static void _build_max_heap(int *data, const uint len) {
    int i;
    for (i = (len / 2) - 1; i >= 0; i--) {
        _max_heapify(data, len, i);
    }
}

四、堆排序

参考:排序算法之堆排序

题目要求:随机输出100以内的不重复数字

解法一:暴力求解

最简单也最容易想到的解法:

  1. 创建含有100个元素的数组data[100],全部置零
  2. 生成100以内的随机数r
  3. 如果data[r]等于0,设置data[r]=1
  4. 如果data[r]等于1,重复第二步

此算法的时间复杂度为O(n^2),越到后面,碰撞的机会也越来越大,最坏的情况下,时间复杂度远不止O(n^2)。

解法二:Fisher-Yates shuffle洗牌算法

算法的逻辑为:

  1. 创建一个长度为n的数组(假设下标从1开始),每个元素的值都置为其下标
  2. 设max=n,从n开始到1逐步递减
  3. 生成[1, max]之间的随机数r,把data[r]和data[max]交换,max减一
  4. 重复第三步,直到max小于1

2.1 过程图解

假设要生生十个随机数,创建十个元素的数组,初时时的状态为:

+--+--+--+--+--+--+--+--+--+--+
| 1| 2| 3| 4| 5| 6| 7| 8| 9|10|
+--+--+--+--+--+--+--+--+--+--+
                             ^
                            max   

逐步生成随机数的过程:

max = 10, r = 3
        +--------------------+
        v                    v
+--+--+--+--+--+--+--+--+--+--+
| 1| 2|10| 4| 5| 6| 7| 8| 9| 3|
+--+--+--+--+--+--+--+--+--+--+

max = 9, r = 7
                    +-----+
                    v     v
+--+--+--+--+--+--+--+--+--+--+
| 1| 2|10| 4| 5| 6| 9| 8| 7: 3|
+--+--+--+--+--+--+--+--+--+--+

max = 8, r = 1
  +--------------------+
  v                    v
+--+--+--+--+--+--+--+--+--+--+
| 8| 2|10| 4| 5| 6| 9| 1: 7| 3|
+--+--+--+--+--+--+--+--+--+--+

max = 7, r = 5
              +-----+
              v     v
+--+--+--+--+--+--+--+--+--+--+
| 8| 2|10| 4| 9| 6| 5: 1| 7| 3|
+--+--+--+--+--+--+--+--+--+--+

...

2.2 代码实现

void shuffle(int *data, int n) {
    int i, r;
    for (i = 0; i < n; i++) {
        data[i] = i;
    }
    for (i = n; i > 0; i--) {
        r = rand() % n;
        swap(&data[r], &data[i - 1]); // 交换
    }
}

单测案例:

TEST(SHUFFLE_TEST, FEATURE_TEST) {
    int i, data[TEST_NODE_COUNT], verify[TEST_NODE_COUNT];
    shuffle(data, TEST_NODE_COUNT);
    memset(verify, 0, sizeof(int) * TEST_NODE_COUNT);
    for (i = 0; i < TEST_NODE_COUNT; i++) {
        verify[data[i]] ++;
    }
    for (i = 0; i < TEST_NODE_COUNT; i++) {
        ASSERT_EQ(verify[i], 1) << verify[i];
    }
}

int main(int argc, char **argv) {
    srand(time(NULL));

    testing::InitGoogleTest(&argc, argv);

    RUN_ALL_TESTS();
    return 0;
}

在visual studio中,使用extern "C"语句会导致后面的整个代码块都被缩进:

对强迫症患者来说这里看起来很不舒服,而且使用这个语句也只是为了使C兼容CPP,本身写的就是C而已,并不希望这里有缩进。

解决方案:

#ifndef __BST_TREE_H__
#define __BST_TREE_H__

#ifdef __cplusplus
extern "C" {
#endif
// 添加下面三行
#if 0
}
#endif

struct bst_tree_node_st {
};

#if 0
{
#endif
#ifdef __cplusplus
}
#endif

#endi