数据结构中有很多树的结构,其中包括二叉树、二叉搜索树、2-3 树、红黑树等等。本文中对数据结构中常见的几种树的概念和用途进行了汇总,不求严格精准,但求简单易懂。
1. 二叉树
二叉树是数据结构中一种重要的数据结构,也是树表家族最为基础的结构。
二叉树的定义:二叉树的每个结点至多只有二棵子树 (不存在度大于 2 的结点),二叉树的子树有左右之分,次序不能颠倒。二叉树的第 i 层至多有 2i-1 个结点;深度为 k 的二叉树至多有 2k-1 个结点;对任何一棵二叉树 T,如果其终端结点数为 n0,度为 2 的结点数为 n2,则 n0=n2+1。
二叉树的示例:
满二叉树和完全二叉树:
满二叉树:除最后一层无任何子节点外,每一层上的所有结点都有两个子结点。也可以这样理解,除叶子结点外的所有结点均有两个子结点。节点数达到最大值,所有叶子结点必须在同一层上。
满二叉树的性质:
1) 一颗树深度为 h,最大层数为 k,深度与最大层数相同,k=h;
1) 叶子数为 2h;
2) 第 k 层的结点数是:2k-1;
3) 总结点数是:2k-1,且总节点数一定是奇数。
完全二叉树:若设二叉树的深度为 h,除第 h 层外,其它各层 (1~(h-1) 层) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。
注:完全二叉树是效率很高的数据结构,堆是一种完全二叉树或者近似完全二叉树,所以效率极高,像十分常用的排序算法、Dijkstra 算法、Prim 算法等都要用堆才能优化,二叉排序树的效率也要借助平衡性来提高,而平衡性基于完全二叉树。
二叉树的性质:
1) 在非空二叉树中,第 i 层的结点总数不超过 2i-1, i>=1;
2) 深度为 h 的二叉树最多有 2h-1 个结点 (h>=1),最少有 h 个结点;
3) 对于任意一棵二叉树,如果其叶结点数为 N0,而度数为 2 的结点总数为 N2,则 N0=N2+1;
4) 具有 n 个结点的完全二叉树的深度为 log2(n+1);
5) 有 N 个结点的完全二叉树各结点如果用顺序方式存储,则结点之间有如下关系:
若 I 为结点编号则 如果 I>1,则其父结点的编号为 I/2;
如果 2I<=N,则其左儿子(即左子树的根结点)的编号为 2I;若 2I>N,则无左儿子;
如果 2I+1<=N,则其右儿子的结点编号为 2I+1;若 2I+1>N,则无右儿子。
6) 给定 N 个节点,能构成 h(N) 种不同的二叉树,其中 h(N) 为卡特兰数的第 N 项,h(n)=C(2*n, n)/(n+1)。
7) 设有 i 个枝点,I 为所有枝点的道路长度总和,J 为叶的道路长度总和 J=I+2i。
2. 二叉查找树
二叉查找树定义:又称为是二叉排序树(Binary Sort Tree)或二叉搜索树。二叉排序树或者是一棵空树,或者是具有下列性质的二叉树:
1) 若左子树不空,则左子树上所有结点的值均小于它的根结点的值;
2) 若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值;
3) 左、右子树也分别为二叉排序树;
4) 没有键值相等的节点。
二叉查找树的性质:对二叉查找树进行中序遍历,即可得到有序的数列。
二叉查找树的时间复杂度:它和二分查找一样,插入和查找的时间复杂度均为 O(logn),但是在最坏的情况下仍然会有 O(n) 的时间复杂度。原因在于插入和删除元素的时候,树没有保持平衡(比如,我们查找上图(b)中的 “93”,我们需要进行 n 次查找操作)。我们追求的是在最坏的情况下仍然有较好的时间复杂度,这就是平衡查找树设计的初衷。**
**二叉查找树的高度决定了二叉查找树的查找效率。**
二叉查找树的插入过程如下:
1) 若当前的二叉查找树为空,则插入的元素为根节点;
2) 若插入的元素值小于根节点值,则将元素插入到左子树中;
3) 若插入的元素值不小于根节点值,则将元素插入到右子树中。
二叉查找树的删除,分三种情况进行处理:
1) p 为叶子节点,直接删除该节点,再修改其父节点的指针(注意分是根节点和不是根节点),如图 a;
2) p 为单支节点(即只有左子树或右子树)。让 p 的子树与 p 的父亲节点相连,删除 p 即可(注意分是根节点和不是根节点),如图 b;
3) p 的左子树和右子树均不空。找到 p 的后继 y,因为 y 一定没有左子树,所以可以删除 y,并让 y 的父亲节点成为 y 的右子树的父亲节点,并用 y 的值代替 p 的值;或者方法二是找到 p 的前驱 x,x 一定没有右子树,所以可以删除 x,并让 x 的父亲节点成为 y 的左子树的父亲节点。如图 c。
二叉树相关实现源码:
插入操作:
struct node { int val; pnode lchild; pnode rchild; }; pnode BT = NULL; //递归方法插入节点 pnode insert(pnode root, int x) { pnode p = (pnode)malloc(LEN); p->val = x; p->lchild = NULL; p->rchild = NULL; if(root == NULL){ root = p; } else if(x < root->val){ root->lchild = insert(root->lchild, x); } else{ root->rchild = insert(root->rchild, x); } return root; } //非递归方法插入节点 void insert_BST(pnode q, int x) { pnode p = (pnode)malloc(LEN); p->val = x; p->lchild = NULL; p->rchild = NULL; if(q == NULL){ BT = p; return ; } while(q->lchild != p && q->rchild != p){ if(x < q->val){ if(q->lchild){ q = q->lchild; } else{ q->lchild = p; } } else{ if(q->rchild){ q = q->rchild; } else{ q->rchild = p; } } } return; }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960 | struct node{ int val; pnode lchild; pnode rchild;}; pnode BT = NULL; // 递归方法插入节点 pnode insert(pnode root, int x){ pnode p = (pnode)malloc(LEN); p->val = x; p->lchild = NULL; p->rchild = NULL; if(root == NULL){ root = p; } else if(x < root->val){ root->lchild = insert(root->lchild, x); } else{ root->rchild = insert(root->rchild, x); } return root;} // 非递归方法插入节点 void insert_BST(pnode q, int x){ pnode p = (pnode)malloc(LEN); p->val = x; p->lchild = NULL; p->rchild = NULL; if(q == NULL){ BT = p; return ; } while(q->lchild != p && q->rchild != p){ if(x < q->val){ if(q->lchild){ q = q->lchild; } else{ q->lchild = p; } } else{ if(q->rchild){ q = q->rchild; } else{ q->rchild = p; } } } return;} |
删除操作:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677 | bool delete_BST(pnode p, int x) // 返回一个标志,表示是否找到被删元素 { bool find = false; pnode q; p = BT; while(p && !find){ // 寻找被删元素 if(x == p->val){ // 找到被删元素 find = true; } else if(x < p->val){ // 沿左子树找 q = p; p = p->lchild; } else{ // 沿右子树找 q = p; p = p->rchild; } } if(p == NULL){ // 没找到 cout « “没有找到” « x « endl; } if(p->lchild == NULL && p->rchild == NULL){ //p 为叶子节点 if(p == BT){ //p 为根节点 BT = NULL; } else if(q->lchild == p){ q->lchild = NULL; } else{ q->rchild = NULL; } free(p); // 释放节点 p } else if(p->lchild == NULL | p->rchild == NULL){ //p 为单支子树 if(p == BT){ //p 为根节点 if(p->lchild == NULL){ BT = p->rchild; } else{ BT = p->lchild; } } else{ if(q->lchild == p && p->lchild){ //p 是 q 的左子树且 p 有左子树 q->lchild = p->lchild; // 将 p 的左子树链接到 q 的左指针上 } else if(q->lchild == p && p->rchild){ q->lchild = p->rchild; } else if(q->rchild == p && p->lchild){ q->rchild = p->lchild; } else{ q->rchild = p->rchild; } } free(p); } else{ //p 的左右子树均不为空 pnode t = p; pnode s = p->lchild; // 从 p 的左子节点开始 while(s->rchild){ // 找到 p 的前驱,即 p 左子树中值最大的节点 t = s; s = s->rchild; } p->val = s->val; // 把节点 s 的值赋给 p if(t == p){ p->lchild = s->lchild; } else{ t->rchild = s->lchild; } free(s); } return find;} |
查找操作:
12345678910111213141516171819 | pnode search_BST(pnode p, int x){ bool solve = false; while(p && !solve){ if(x == p->val){ solve = true; } else if(x < p->val){ p = p->lchild; } else{ p = p->rchild; } } if(p == NULL){ cout « “没有找到” « x « endl; } return p;} |
3. 平衡二叉树
我们知道,对于一般的二叉搜索树(Binary Search Tree),其期望高度(即为一棵平衡树时)为 log2n,其各操作的时间复杂度 O(log2n) 同时也由此而决定。但是,在某些极端的情况下(如在插入的序列是有序的时),二叉搜索树将退化成近似链或链,此时,其操作的时间复杂度将退化成线性的,即 O(n)。我们可以通过随机化建立二叉搜索树来尽量的避免这种情况,但是在进行了多次的操作之后,由于在删除时,我们总是选择将待删除节点的后继代替它本身,这样就会造成总是右边的节点数目减少,以至于树向左偏沉。这同时也会造成树的平衡性受到破坏,提高它的操作的时间复杂度。于是就有了我们下边介绍的平衡二叉树。
平衡二叉树定义:平衡二叉树(Balanced Binary Tree)又被称为 AVL 树(有别于 AVL 算法),且具有以下性质:它是一 棵空树或它的左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。平衡二叉树的常用算法有红黑树、AVL 树等。在平衡二叉搜索树中,我们可以看到,其高度一般都良好地维持在 O(log2n),大大降低了操作的时间复杂度。
最小二叉平衡树的节点的公式如下:
F(n)=F(n-1)+F(n-2)+1
这个类似于一个递归的数列,可以参考 Fibonacci 数列,1 是根节点,F(n-1) 是左子树的节点数量,F(n-2) 是右子树的节点数量。
3.1 平衡查找树之 AVL 树
有关 AVL 树的具体实现,可以参考 C 小加的博客《一步一步写平衡二叉树(AVL)》。
AVL 树定义:AVL 树是最先发明的自平衡二叉查找树。AVL 树得名于它的发明者 G.M. Adelson-Velsky 和 E.M. Landis,他们在 1962 年的论文 “An algorithm for the organization of information” 中发表了它。在 AVL 中任何节点的两个儿子子树的高度最大差别为 1,所以它也被称为高度平衡树,n 个结点的 AVL 树最大深度约 1.44log2n。查找、插入和删除在平均和最坏情况下都是 O(logn)。增加和删除可能需要通过一次或多次树旋转来重新平衡这个树。这个方案很好的解决了二叉查找树退化成链表的问题,把插入,查找,删除的时间复杂度最好情况和最坏情况都维持在 O(logN)。但是频繁旋转会使插入和删除牺牲掉 O(logN) 左右的时间,不过相对二叉查找树来说,时间上稳定了很多。
AVL 树的自平衡操作——旋转:
AVL 树最关键的也是最难的一步操作就是旋转。旋转主要是为了实现 AVL 树在实施了插入和删除操作以后,树重新回到平衡的方法。下面我们重点研究一下 AVL 树的旋转。
对于一个平衡的节点,由于任意节点最多有两个儿子,因此高度不平衡时,此节点的两颗子树的高度差 2. 容易看出,这种不平衡出现在下面四种情况:
1) 6 节点的左子树 3 节点高度比右子树 7 节点大 2,左子树 3 节点的左子树 1 节点高度大于右子树 4 节点,这种情况成为左左。
2) 6 节点的左子树 2 节点高度比右子树 7 节点大 2,左子树 2 节点的左子树 1 节点高度小于右子树 4 节点,这种情况成为左右。
3) 2 节点的左子树 1 节点高度比右子树 5 节点小 2,右子树 5 节点的左子树 3 节点高度大于右子树 6 节点,这种情况成为右左。
4) 2 节点的左子树 1 节点高度比右子树 4 节点小 2,右子树 4 节点的左子树 3 节点高度小于右子树 6 节点,这种情况成为右右。
从图 2 中可以可以看出,1 和 4 两种情况是对称的,这两种情况的旋转算法是一致的,只需要经过一次旋转就可以达到目标,我们称之为单旋转。2 和 3 两种情况也是对称的,这两种情况的旋转算法也是一致的,需要进行两次旋转,我们称之为双旋转。
单旋转
单旋转是针对于左左和右右这两种情况的解决方案,这两种情况是对称的,只要解决了左左这种情况,右右就很好办了。图 3 是左左情况的解决方案,节点 k2 不满足平衡特性,因为它的左子树 k1 比右子树 Z 深 2 层,而且 k1 子树中,更深的一层的是 k1 的左子树 X 子树,所以属于左左情况。
为使树恢复平衡,我们把 k2 变成这棵树的根节点,因为 k2 大于 k1,把 k2 置于 k1 的右子树上,而原本在 k1 右子树的 Y 大于 k1,小于 k2,就把 Y 置于 k2 的左子树上,这样既满足了二叉查找树的性质,又满足了平衡二叉树的性质。
这样的操作只需要一部分指针改变,结果我们得到另外一颗二叉查找树,它是一棵 AVL 树,因为 X 向上一移动了一层,Y 还停留在原来的层面上,Z 向下移动了一层。整棵树的新高度和之前没有在左子树上插入的高度相同,插入操作使得 X 高度长高了。因此,由于这颗子树高度没有变化,所以通往根节点的路径就不需要继续旋转了。
双旋转
对于左右和右左这两种情况,单旋转不能使它达到一个平衡状态,要经过两次旋转。双旋转是针对于这两种情况的解决方案,同样的,这样两种情况也是对称的,只要解决了左右这种情况,右左就很好办了。图 4 是左右情况的解决方案,节点 k3 不满足平衡特性,因为它的左子树 k1 比右子树 Z 深 2 层,而且 k1 子树中,更深的一层的是 k1 的右子树 k2 子树,所以属于左右情况。
为使树恢复平衡,我们需要进行两步,第一步,把 k1 作为根,进行一次右右旋转,旋转之后就变成了左左情况,所以第二步再进行一次左左旋转,最后得到了一棵以 k2 为根的平衡二叉树。
AVL 树实现源码: