算法导论 - 二叉搜索树(BST)

二叉搜索树

顾名思义,一棵二叉搜索树是以一棵二叉树来组织的。如下图所示,这样一棵树可以使用一个链表数据结构来表示,其中每个结点就是一个对象,除了key外,每个结点还包括属性left 、right、和p,它们分别指向结点的左孩子、右孩子和双亲。如果某个孩子结点和父结点不存在,则相应属性的值为nil,根结点是树中唯一父指针为nil的结点

1.png

二叉搜索树中的关键字总是以满足二叉搜索树性质的方式来存储:

设x是二叉搜索树中的一个结点,如果y是x的左子树中的一个结点,那么y.key \leq x.key。如果y是x的右子树中的一个结点,那么y.key \geq x.key

如图(a)中,树根的关键字是6,在其左子树中有关键字2、5和5,它们均不大于6;而其右子树中有关键字7和8,它们均不小于6。所以这个性质在树中的每个结点都成立。

二叉搜索树的性质允许我们通过一个简单的递归算法来按序输出二叉搜索树中的所有关键字,算法是我们熟悉的中序遍历

typedef struct Node
{
    struct Node *p;
    struct Node *left;
    struct Node *right;
    int key;
} Node;

void InorderTreeWalk(Node *x) // 中序遍历树x
{
    if (x != nullptr) {
        InorderTreeWalk(x->left); // 递归左孩子
        cout<<x->key<<endl; // 输出根节点关键字
        InorderTreeWalk(x->right); // 递归右孩子
    }
}

遍历一棵有n个结点的二叉搜索树,需要耗费O(n)的时间,因为初次调用后,对于树中的每个结点这个过程恰好要自己调用两次;一次是它的左孩子,另一次是它的右孩子

查询二叉搜索树

2.jpeg

我们经常需要查找一个存储在二叉搜索树中的关键字,下面将讨论并且说明在任何高度为h的二叉搜索树上,如何在O(h)时间内完成每个操作

查找

使用下面的代码在一棵二叉搜索树中查找一个具有给定关键字的结点,输入一个指向树根的指针和一个关键字k,如果这个节点存在,那么返回关键字为k的指针,否则为nil

Node * TreeSearch(Node *x, int k)
{
    if (x == nullptr || k == x->key) {
        return x;
    }

    if (k < x->key)
    {
        return TreeSearch(x->left, k);
    }
    else
    {
        return TreeSearch(x->right, k);
    }
}
  • 这个过程是从树根开始查找的,并沿着这颗树中的一条简单路径向下进行。对于遇到的每个结点x,比较关键字k与x.key,如果两个关键字相等,查找就终止了,该结点就是我们需要查找的结点。

  • 如果k小于x.key,则查找x的左子树,因为二叉搜索树的性质告诉了我们k不可能在右子树中。相应的,如果k大于x.key,查找在右子树进行。

  • 从树根开始递归期间遇到的结点就形成了一条向下的简单路径,所以TreeSearch的运行时间是O(h),其中h是树的高度

除了递归方式,采用迭代方式是效率更高的方式

Node * InteractiveTreeSearch(Node *x, int k)
{
    while (x != nullptr && k != x->key) {
        if (k < x->key)
        {
            x = x->left;
        }
        else
        {
            x = x->right;
        }
    }
    return x;
}

最大关键字元素和最小关键字元素

通过从树根开始沿着left孩子指针直到遇到一个nil,

  • 最小关键字元素
Node *TreeMinimum(Node *x)
{
    while (x->left != nullptr) {
        x = x->left;
    }
    return x;
}

二叉树的性质保证了上面查找的正确性。

如果结点x没有左子树,那么由于x的右子树中的每个关键字都至少大于或等于x.key,则以x为根的子树中的最小关键字是x.key。如果结点x有左子树,那么由于其右子树中没有关键字小于x.key,且在左子树中的每个关键字不大于x.key,则以x为根的子树中的最小关键字一定在以x.left为根的子树中

  • 最大关键字元素

最大元素一定是沿着right孩子指针不断向下查找,直到遇到第一个空指针;

Node *TreeMaxmum(Node *x)
{
    while (x->right != nullptr) {
        x = x->right;
    }
    return x;
}

这两个过程在一棵高度为h的树上均能在O(h)的时间内执行完。

后继和前驱

给定一棵二叉搜索树,有时候需要按中序遍历的次序查找它的后继。如果所有的关键字互不相同,则一个结点x的后继是大于x.key的最小关键字的结点

后继

Node *TreeSuccessor(Node *T, int t)
{
    // 查找结点
    Node *x = TreeSearch(T, t);
    Node *y = nullptr;

    // 右子树非空,即查找最小关键字
    if (x->right != nullptr) {
        y = TreeMinimum(x->right);
        return y;
    }

    // 查找结点的父指针
    y = x->p;
    while (y != nullptr && x == y->right) {
        x = y;
        y = y->p;
    }
    return y;
}

如果结点x的右子树非空,那么x的后继恰好是x右子树中的最左结点。也可以理解为最小关键字

2.jpeg

如果结点x的右子树为空并有一个后继y,那么y就是x的最底层祖先,并且y的左孩子也是x的一个祖先(比如:上图中关键字为13的结点的后继是关键字为15的结点)。为了找到y,只需要简单地从x开始沿树而上直到遇到这样一个结点:这个结点是它的双亲的左孩子

前驱

Node *TreePredecessor(Node *T, int t)
{
    // 查找元素t
    Node *x = TreeSearch(T, t);
    Node *y = nullptr;

    // 左子树非空
    if (x->left != nullptr)
    {
        return  TreeMaxmum(x->left);
    }

    // 查结点的父指针
    y = x->p;
    while ( y != nullptr && x == y->left) {
        x = y;
        y = y->p;
    }
    return y;
}

对于树的后继和前驱的查找,运行时间为O(h)

插入和删除

插入和删除操作会引起由二叉搜索树表示的动态集合的变化。所以一定要修改数据结构来反映这个变化,但修改要保持二叉搜索树性质的成立

插入

向一个二叉搜索树中插入一个x结点,只需要不断比较x.key与当前结点z.key的大小,若小于,则肯定是向z的左子树插入,否则向z的右子树插入,循环比较,直到遇到当前结点的左/右子树为空为止。这时候已经找到了要插入的结点的父结点的位置,最后判断是左子树还是右子树即可

Node * TreeInsert(Node *root, Node *z)
{
    Node *y = nullptr;
    Node *x = root;

    // 节点非空,看是左子树还是右子树插入
    while (x != nullptr) {
        y = x;
        if (z->key < x->key)
        {
            x = x->left;
        }
        else
        {
            x = x->right;
        }
    }

    z->p = y;
    if (y == nullptr) // 建立第一个节点
    {
        root = z;
    }
    else if (z->key < y->key) // 小于父结点即位左子树
    {
        y->left = z;
    }
    else // 大于即为右子树
    {
        y->right = z;
    }

    return root;
}
3.jpeg

比如:上图插入结点,关键字为13,可以对照代码和图形理解

删除

从一棵二叉搜索树T中删除一个结点z,存在3种情况:

1、如果z没有孩子结点,那么只是简单地将它删除,并修改父结点,用nil作为孩子来替换z
2、如果z只有一个孩子,那么将孩子提升到树中z的位置,并修改z的父结点,用z的孩子来替换z
3、如果z有两个孩子,那么找z的后继y(一定在z的右子树中),并让y占据树中z的位置。z的原来右子树部分成为y的新的右子树,并且z的左子树成为y的新的左子树

对照图来理解上面几种情况

  • 如果z没有左孩子(如下图),那么用其右孩子来替换z,这个右孩子可以是nil,也可以不是nil。当右孩子是nil的时候,此时就是z没有孩子结点的情形。当右孩子非nil时,这就是z仅有一个孩子的情况,该孩子是其右孩子
屏幕快照 2018-12-12 上午9.16.42.png
  • 如果z仅有一个孩子且为左孩子(如下图),那么用其左孩子替换z
屏幕快照 2018-12-12 上午9.16.50.png
  • z既有左孩子也有右孩子。如果y是z的右孩子(如下图),那么用y替换z,并仅留下y的右孩子
屏幕快照 2018-12-12 上午9.17.03.png
  • 如果y位于z的右子树中但并不是z的右孩子(如下图),这种情况下,先用y的右孩子替换y,然后再用y替换z
屏幕快照 2018-12-12 上午9.17.19.png

在二叉搜索树内移动子树,它是用另一棵子树替换一棵子树并成为其双亲的孩子结点

移动子树

void Transplant(Node **T, Node *u, Node *v) // 用结点v替换结点u
{
    if (u->p ==  nullptr) // 父结点不存在,那么u为根结点
    {
        *T = v;
    }
    else if (u == u->p->left) // u为左孩子,那么将v作为左孩子
    {
        u->p->left = v;
    }
    else // u为右孩子,那么将v作为右孩子
    {
        u->p->right = v;
    }

    if (v != nullptr) // v不为nil,那么更新父结点
    {
        v->p = u->p;
    }
}

删除结点

Node * TreeDelete(Node *T, int k)
{
    Node *z = TreeSearch(T, k); // 查找结点z
    Node *y = nullptr;

    if (z->left == nullptr) // 结点z没有左孩子
    {
        Transplant(&T, z, z->right);
    }
    else if (z->right == nullptr) // 结点z没有右孩子
    {
        Transplant(&T, z, z->left);
    }
    else // 结点z有两个孩子
    {
        y = TreeMinimum(z->right); // 找右子树的最小结点

        if (y->p != z) { // y的父结点不是z
            Transplant(&T, y, y->right);
            y->right = z->right;
            y->right->p = y;
        }

        Transplant(&T, z, y);
        y->left = z->left;
        y->left->p = y;
    }
    return T;
}

简单使用

// 创建搜索二叉树
Node * TreeEstablish(int *a, int length)
{
    Node *root = nullptr;
    for (int i=0 ; i < length; i++) {
        Node *node = (Node *)malloc(sizeof(Node));
        node->key = a[i];
        node->p = nullptr;
        node->left = nullptr;
        node->right = nullptr;
        root = TreeInsert(root, node);
    }
    return root;
}

int main(int argc, const char * argv[]) {
    int a[] = {2,3,4,6,15,7,18,17,20,13,9};
    int length = sizeof(a)/sizeof(a[0]);

    Node *T;
    T = TreeEstablish(a, length); // 建立二叉搜索树
    InorderTreeWalk(T);  // 中序遍历打印
    cout<<"6 successor is "<<TreeSuccessor(T, 6)->key<<endl; // 后继查找
    cout<<"18 predecessor is "<<TreePredecessor(T, 18)->key<<endl; // 前驱查找
    T = TreeDelete(T, 2); // 删除结点
    TreeInorderPrint(T); // 删除结点之后的二叉搜索树
    return 0;
}

参考

《算法导论》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,561评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,218评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,162评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,470评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,550评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,806评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,951评论 3 407
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,712评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,166评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,510评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,643评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,306评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,930评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,745评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,983评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,351评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,509评论 2 348

推荐阅读更多精彩内容