转载地址 http://noalgo.info/603.html
参考地址 http://songlee24.github.io/2015/01/13/binary-search-tree/
二叉查找树(binary search tree,又叫二叉搜索树或者二叉排序树)是一种非常重要的数据结构,许多高级树结构都是二叉查找树的变种,例如AVL树、红黑树等,理解二叉查找树对于后续树结构的学习有很好的作用。同时利用二叉查找树可以进行排序,称为二叉排序,也是很重要的一种思想。
本文主要参考算法导论,详细介绍二叉查找树的原理及具体的C++代码实现。
定义
查找树是一种数据结构,它支持多种动态集合操作,包括search, minimum, maximum, predecessor, successor, insert以及delete。它既可以用作字典,也可以用作优先队列。二叉查找树是按二叉树结构来组织的,每个结点除了key域和卫星数据外,还包含left,right和parent,分别指向其左右儿子和父亲节点。如果某个儿子节点或父亲节点不存在,则相应域中的值为NULL。
它由一个递归定义表示:它或者是一棵空树;或者是具有下列性质的二叉树:
- 若左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 左、右子树也分别为二叉查找树;
即二叉查找树中关键字的存储方式满足以下二叉查找树性质:
- 如果y是x左子树中的一个结点,则key[y]<=key[x]。
- 如果y是x右子树中的一个结点,则key[x]<=key[y]。
二叉查找树上基本操作的执行时间和树的高度成正比。对一棵n个结点的完全二叉树来说,这些操作的最坏情况运行时间为Θ(lg n),而如果树是含有那个结点的线性链,这些操作的最坏运行时间是Θ(n)。一棵随机构造的二叉查找树的期望高度为O(lg n),但实际中并不能总是保证二叉查找树是随机构造的,有些二叉查找树的变形能保证各种基本操作的最坏情况性能,比如红黑树的高度为O(lg n),而B树对维护随机访问的二级存储器上的数据库特别有效。
节点数据结构
二叉查找树使用二叉树实现,其节点包括关键字及三个指针域,为了使用时方便,还提供了带默认参数的构造函数。
以下是其C++代码实现,其中使用struct定义,是为了使成员默认拥有public属性。
//树节点 struct Node { int key; //关键字 Node *left, *right, *parent;//左儿子,右儿子,父亲 Node (int k = 0, Node *l = NULL, Node *r = NULL, Node *p = NULL) :key(k), left(l), right(r), parent(p) {} };
遍历二叉查找树
二叉查找树的遍历与普通二叉树遍历一样,可以使用递归算法按照前序、中序、后序等不同顺序进行遍历。如果使用中序遍历,根据二叉搜索树的特定,遍历的顺序会按照节点关键字的大小关系从小到大依次进行。
以下是其中序遍历的递归代码,前后序遍历可以类似进行。
//中序遍历二叉树 void inorder(Node *x) { if (x == NULL) return; inorder(x->left); //左子树 printf("%d ", x->key); //根节点 inorder(x->right); //右子树 }
查询二叉查找树
二叉查找树最常见的操作是查找树中的某个关键字,处了普通的search之外,其还能支持minimum、maximun、successor、predecessor等查询,对于高度为h的树,它们都可以在O(h)时间内完成。
查找
给定指向树根的指针和关键字k,要在树中查找该关键字是否存在,如果存在,返回其指针,否为返回NULL。
该过程可以从树的根节点开始进行查找,并沿着树下降。对碰到的每个节点x,就比较k和key[x](表示x的关键字)。如果这两个关键字相同,则查找结束。如果k<key[x],则继续查找x的左子树,因为由二叉查找树性质可知,k不可能在x的右子树中。对称地,如果k>key[x],则继续在x的右子树中查找。
比如在如下的二叉查找树中,为了在树中查找关键字13,要沿着从根开始的路径15->6->7->13进行查找。
15 / \ 6 18 / \ / \ 3 7 17 20 / \ \ 2 4 13 / 9
下面是其实现代码。
//递归查找元素,找到返回关键字的结点指针,没找到返回NULL Node* searchRecursive(Node *x, int k) { if (x == NULL || x->key == k) return x; if (k < x->key)//查找左子树 return searchRecursive(x->left, k); else //查找右子树 return searchRecursive(x->right, k); }
也可以使用非递归的算法,其执行效率会更高。
//非递归查找元素,找到返回关键字的结点指针,没找到返回NULL Node* search(Node *x, int k) { while (x != NULL && k != x->key) { if (k < x->key) x = x->left; else x = x->right; } return x; }
最大关键字元素和最小关键字元素
要查找二叉查找树中具有最小关键字的元素,只要从根节点开始,沿着各节点的left指针查找下去,直到遇到NULL为止。比如上面的二叉查找树中的最小关键字为2,位于从根开始最左下的位置。
//查找最小关键字 Node* searchMin(Node *x) { //空树时返回NULL if (x == NULL) return NULL; //一直往左儿子找,直到没有左儿子 while (x->left != NULL) x = x->left; return x; }
查找二叉查找树中最大元素是对称的。上面的二叉查找树中的最大关键字为20,位于从根开始最右下的位置。
//查找最大关键字 Node* searchMax(Node *x) { //空树时返回NULL if (x == NULL) return NULL; //一直往右儿子找,直到没有右儿子 while (x->right != NULL) x = x->right; return x; }
前驱和后继
给定一个二叉查找树中的节点,要找出在中序遍历下它的后继。如果所有的关键字都不相同,则某一节点x的后继即具有大于key[x]中的关键字中最小者的那个节点。在二叉查找树中,不需要对关键字进行任何比较即可找到该后继。如果该节点x的右子树非空,则x的后继即为右子树的最左节点;否则,其后继y是x的最低祖先节点,且y的左儿子而是x的祖先。比如在上面的二叉查找树中,15的后继为17,因为17是15的右子树的最小关键字。而13的关键字为15,因为13没有右子树,而15是其最近的一个祖先,且15的左儿子6也是其祖先。
//查找某个结点的后继 Node* searchSuccessor(Node *x) { //空树 if (x == NULL) return NULL; //有右子树,右子树中最小的那个 if (x->right != NULL) return searchMin(x->right); //无右子树,则为最低的祖先,其左儿子也是祖先 Node *y = x->parent; //x向上搜索,y为x的父亲 while (y != NULL && x == y->right) x = y, y = y->parent; return y; }
查找给定节点的前驱思想和上面的相反,这里不再赘述。
//查找某个结点的前驱 Node *searchPredecessor(Node *x) { //空树 if (x == NULL) return NULL; //有左子树、左子树中最大的那个 if (x->left != NULL) return searchMax(x->left); //无左子树,则为最低的祖先,其右儿子也是祖先 Node *y = x->parent; //x向上搜索,y为x的父亲 while (y != NULL && x == y->left) x = y, y = y->parent; return y; }
插入和删除
插入和删除操作会引起二叉查找树表示的动态集合的变化,要反映出这种变化,就要修改数据结构,同时要保持二叉查找树的性质。
插入
给定一棵二叉查找树和一个新的关键字,要将该关键字插入到树中。首先根据关键字创建一个新的二叉查找树节点,然后从根节点出发查找待插入的位置,最后再指定的位置进行插入。查找时指针p从根节点向下搜索,每次根据当前节点关键字与新的关键字的大小关系决定向左或者向右走,而q则始终记录p的父亲节点。当p到达叶子后面的空节点时,q则为最后的叶子节点,此时把新节点插入即可。
比如,在左边二叉查找树中插入13时,先沿着12->18->15找到插入的位置,再把13插入到15的左边。
12 12 / \ / \ 5 18 5 18 / \ / \ / \ / \ 2 9 15 19 2 9 15 19 \ / \ 17 13 17
具体代码如下:
//往二叉查找树中插入结点 //插入可能要改变根结点,所以传递二级指针 void insertNode(Node **root, int key) { //根据插入的值创建新节点 Node *z = new Node(key, NULL, NULL, NULL); //向下搜素插入的位置,x为当前节点,y为其父亲 Node *x = *root, *y = NULL; while (x != NULL) { y = x; if (key == x->key) return; //已经存在 else if(key < x->key) x = x->left;//往左儿子走 else x = x->right; //往右儿子走 } //把z插入到y的下方,修正z和y之间的链接 z->parent = y; if (y == NULL) *root = z; else if (key < y->key) y->left = z; else y->right = z; }
删除
给定一棵二叉查找树和一个关键字,要将该关键字的节点删除。首先需要找到该关键字所在的节点的指针p,然后具体的删除过程可以分为几种情况:
- p没有子女,直接删除p。
- p有一个子女,直接删除p。
- p有两个子女,删除p的后继q(q至多只有一个子女)。
确定了要删除的节点q之后,就要修正q的父亲和子女的链接关系,然后把q的关键字内容替换掉原先p的关键字内容,最后把q删除掉。
例如,在下面的二叉查找树中,13没有儿子,直接删除。
15 15 / \ / \ 5 16 5 16 / \ \ / \ \ 3 12 20 3 12 20 / \ / \ / / \ 10 13 18 23 10 18 23 / / 6 6 \ \ 7 7
在下面的二叉查找树中,16只有一个儿子,直接删除,然后修正16的父亲15和儿子20的链接关系。
15 15 / \ / \ 5 16 5 20 / \ \ / \ / \ 3 12 20 3 12 18 23 / \ / \ / \ 10 13 18 23 10 13 / / 6 6 \ \ 7 7
在下面的二叉查找树中,5有两个儿子,知道5的后继6,修正6的父亲10和儿子7的链接关系,并把6复制5的位置,然后删除节点6。
15 15 / \ / \ 5 16 6 16 / \ \ / \ \ 3 12 20 3 12 20 / \ / \ / \ / \ 10 13 18 23 10 13 18 23 / / 6 7 \ 7
下面是具体的代码,代码中把不同的情况综合在一起处理,需要注意一些细节问题。
//根据关键字删除某个结点 //删除可能把根结点删掉从而改变根结点,所以传递二级指针 void deleteNode(Node **root, int key) { //查询待删除的节点z,并判断是否为空 Node *z = search(*root, key); if (z == NULL) return; //查找真正删除的节点y及其儿子x Node *y = (z->left == NULL || z->right == NULL) ? z : searchSuccessor(z); Node *x = y->left != NULL ? y->left : y->right; //修正删除y后,修正y的儿子x和y的父亲之间的链接 if (x != NULL) x->parent = y->parent; if (y->parent == NULL) *root = x; //y父亲为空,说明删了根节点,修正根节点 else if (y == y->parent->left) y->parent->left = x;//y为左儿子,修正左儿子 else y->parent->right = x; //y为右儿子,修正右儿子 //把真正删除的节点数据拷贝到原来待删除的节点,再删除节点 if (y != z) z->key = y->key; delete(y); }
简单测试
最后是一个简单的测试程序,以最开始的二叉查找树为例,代码如下:
#include <cstdio> int main() { Node *bst = NULL; int a[] = {15, 6, 18, 3, 7, 17, 20, 2, 4, 13, 9}; //插入节点 for (int i = 0; i < 11; i++) insertNode(&bst, a[i]); inorder(bst); printf("\n"); printf("%d %d\n", searchSuccessor(bst)->key, searchPredecessor(bst)->key); //删除节点 for (int i = 0; i < 9; i++) deleteNode(&bst, a[i]); inorder(bst); printf("\n"); return 0; }