前面讨论的几种查找方法中,二分查找效率最高,但其要求表中记录按照关键字有序,且只能在顺序表上实现,从而需要在插入和删除操作时移动很多的元素。如果不希望表中记录按关键字有序,而又希望得到较高的插入和删除效率,可以考虑使用几种特殊的二叉树或树作为表的组织形式。本篇阅读时间大约为15min。
1二叉查找树
基本概念
二叉查找树(Binary Search Tree, BST)又称二叉排序树,它是满足如下性质的二叉树:
若它的左子树非空,则左子树上所有记录的值均小于根记录的值;
若它的右子树非空,则右子树上所有记录的值均大于根记录的值;
左、右子树又各是一棵二叉查找树。
假如有一个序列{62,88,58,47,35,73,51,99,37,93},那么构造出来的二叉查找树如下图所示:
二叉查找树是递归定义的,其一般理解是:二叉查找树中任一节点,其值为k,只要该节点有左孩子,则左孩子的值必小于k,只要有右孩子,则右孩子的值必大于k。二叉查找树的一个重要的性质是:中序遍历该树得到的序列是一个递增有序的序列。
代码实现
有关二叉查找树的新增和删除节点如何实现,可以阅读我的《每天5分钟用C#学习数据结构之二叉树》一节,该文使用C#实现了二叉查找树。
需要注意的是:对于二叉查找树最糟糕的情况是插入一个有序序列,使得具有N个元素的集合生成了高度为N的单枝二叉树,从而使其退化了一个单链表,其查找效率也会会由O(logn)变为O(n)。
2平衡二叉树
刚刚提到在二叉查找树中,如果插入元素的顺序接近有序,那么二叉查找树将退化为链表,从而导致二叉查找树的查找效率大大降低。前苏联两位科学家G.M. Adelson-Velskii和E.M. Landis在1962年的一篇论文中提出了一种自平衡二叉查找树。这种二叉查找树在插入和删除操作中,可以通过一系列的旋转操作来保持平衡,从而保证了二叉查找树的查找效率。最终这种二叉查找树被命名为AVL-Tree,也被称为平衡二叉树。
基本概念
平衡二叉树定义(AVL):它或者是一颗空树,或者具有以下性质的二叉树:它的左子树和右子树的深度之差的绝对值不超过1,且它的左子树和右子树都是一颗平衡二叉树。
平衡因子(BF):结点的左子树的深度减去右子树的深度,那么显然-1<=bf<=1;
平衡二叉树上所有结点的平衡因子只可能是-1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
基本操作
假设我们已经有棵平衡二叉树,现在让我们来看看插入节点后,原来节点失去平衡后,平衡二叉树会进行不同类型(RR、LL、LR以及RL)的旋转来保持平衡。
SortedDictionary类
另一种与平衡二叉树类似的是红黑树,红黑树和AVL树的区别在于它使用颜色来标识节点的高度,它所追求的是局部平衡而不是AVL树中的非常严格的平衡。在.NET BCL中的System.Collections.Generic命名空间下,SortedDictionary类就是使用红黑树实现的。红黑树和AVL树的原理非常接近,但是复杂度却远胜于AVL树,这里也就不做讨论。博客园里也已经有了不少关于红黑树的比较好的介绍的文章,有兴趣的可以去阅读阅读。
在代码中,我们可以模拟100000个数字进行添加:
Random random =newRandom();intarray_count =100000;List intList =newList();for(inti =0; i <= array_count; i++){intran = random.Next(); intList.Add(ran);}SortedDictionary dic_int =newSortedDictionary();foreach(variteminintList){if(dic_int.ContainsKey(item) ==false) { dic_int.Add(item, item); }}
当然,还可以与SortedList(SortedList内部是Array,而SortedDictionary内部是红黑树)进行一下对比,这里使用了老赵的CodeTimer类:
(1)新增操作对比:
由于SortedList用Array数组保存,每次进行插入操作时,首先用二分查找法找到相应的位置,得到位置以后,SortedList会把该位置以后的值依次往后移一个位置,空出当前位,再把值插入,这个过程中用到了Array.Copy方法,而调用该方法是比较损耗性能的,具体代码如下:
privatevoid Insert(int index, TKey key, TValue value){ ......if(index
SortedDictionary在添加操作时,只会根据红黑树的特性,旋转节点,保持平衡,并没有对Array.Copy的调用。下面我们就用数据测试一下:循环一个int型、容量为10w的随机数组,分别用SortedList和SortedDictionary添加,看看效率如何:
staticvoidSortedAddInTest(){Random random =newRandom();intarray_count =100000;List intList =newList();for(inti =0; i <= array_count; i++) {intran = random.Next(); intList.Add(ran); }SortedList sortedlist_int =newSortedList();SortedDictionary dic_int =newSortedDictionary();CodeTimer.Time("sortedList_Add_int",1, () => {foreach(variteminintList) {if(sortedlist_int.ContainsKey(item) ==false)sortedlist_int.Add(item,"test"+ item.ToString()); } });CodeTimer.Time("sortedDictionary_Add_int",1, () => {foreach(variteminintList) {if(dic_int.ContainsKey(item) ==false)dic_int.Add(item,"test"+ item.ToString()); } });}
运行结果如下图所示:
从上图可以看出:在大量添加操作的情况下,SortedDictionary性能(无论是从时间消耗、CPU计算、还是GC垃圾回收次数)优于SortedList。
(2)查找操作对比:
两者的查询操作中,时间复杂度都为O(logn),且源码中也没有额外的操作造成性能损失,那么他们在查询操作中性能如何?继续上面一个例子进行测试。
staticvoidSortedQueryInTest(){Random random =newRandom();intarray_count =100000;List intList =newList();for(inti =0; i <= array_count; i++) {intran = random.Next(); intList.Add(ran); }SortedList sortedlist_int =newSortedList();SortedDictionary dic_int =newSortedDictionary();foreach(variteminintList) {if(sortedlist_int.ContainsKey(item) ==false)sortedlist_int.Add(item,"test"+ item.ToString()); }foreach(variteminintList) {if(dic_int.ContainsKey(item) ==false)dic_int.Add(item,"test"+ item.ToString()); }CodeTimer.Time("sortedList_Search_int",1, () => {foreach(variteminintList) { sortedlist_int.ContainsKey(item); } });CodeTimer.Time("sortedDictionary_Search_int",1, () => {foreach(variteminintList) { dic_int.ContainsKey(item); } });}
运行结果如下图所示:
从上图可以看出:两者在循环10w次的情况下,查询操作SortedList大概为SortedDictionary的一半,这是由于SortedList已经在插入操作时已经将其转化为了一个有序的数组,从而在查询时可以直接使用二分查找提高效率。SortedDictionary则是一个二叉排序树,查询效率理论上也是O(logn),但其较有序数组的二分查找效率还是差了一点点。
(3)删除操作对比:
从添加操作例子可以看出,由于SortedList内部使用Array数组进行存储数据,而数组本身的局限性使得SortedList大部分的添加操作都要调用Array.Copy方法,从而导致了性能的损失,这种情况同样存在于删除操作中。
SortedList每次删除操作都会将删除位置后的值往前挪动一位,以填补删除位置的空白,这个过程刚好跟添加操作反过来,同样也需要调用Array.Copy方法,相关代码如下:
publicvoid RemoveAt(int index){ ......if(index
而SortedDictionary使用红黑树结构存储元素,红黑树本身是一棵二叉查找树,它的删除和二叉查找树的删除类似。首先要找到真正的删除点,当被删除结点n存在左右孩子时,真正的删除点应该是n的中序遍历的前驱,关于这一点请参考二叉查找树的删除。如下图所示,当删除结点20时,实际被删除的结点应该为18,结点20的数据变为18。
这里,我们仍然选择上面的例子来进行一个简单的对比测试,仍然是10w个元素的数据量:
staticvoidSortedDeleteInTest(){Random random =newRandom();intarray_count =100000;List intList =newList();for(inti =0; i <= array_count; i++) {intran = random.Next(); intList.Add(ran); }SortedList sortedlist_int =newSortedList();SortedDictionary dic_int =newSortedDictionary();foreach(variteminintList) {if(sortedlist_int.ContainsKey(item) ==false)sortedlist_int.Add(item,"test"+ item.ToString()); }foreach(variteminintList) {if(dic_int.ContainsKey(item) ==false)dic_int.Add(item,"test"+ item.ToString()); }CodeTimer.Time("sortedList_Delete_String",1, () => {foreach(variteminintList) { sortedlist_int.Remove(item); } });CodeTimer.Time("sortedDictionary_Delete_String",1, () => {foreach(variteminintList) { dic_int.Remove(item); } });}
运行结果如下图所示:
从上图也可以看出:在10w次的删除操作中,SortedDictionary的处理速度和性能消耗较SortedList好的不是一丁半点。
(4)对比总结:
① SortedList用数组存储数据,所以对GC比较友好一点,而且对于相对比较有序的输入源而言,操作较少(eg:List intList = Enumerable.Range(0, array_count).ToList())。
② SortedDictionary用节点链存储数据,所以对GC而言,相对比较复杂。所以当可以预见到集合中的元素比较少的时候或者数据本身相对比较有序时,应该倾向于使用SortedList。
3小结
本篇介绍了查找算法中查找树算法部分的两个算法:二叉查找树和平衡二叉树,然后还对比了.NET BCL中的两个类SortedDictionary和SortedList。
下一篇,我们继续看看另外的查找方法:哈希表和Hashtable。
4参考资料
程杰,《大话数据结构》
陈广,《数据结构(C#语言描述)》
段恩泽,《数据结构(C#语言版)》
许两会,《.NET集合类的研究-有序集合》