前言
之前一直在研究avl树的迭代算法。我参考了C++标准库map的实现,发现他们在树节点上使用了parent指针+一个状态标志位的形式,去实现动态迭代。
但是我不想用parent指针,因为觉得会增加调整指针的时间还有浪费存储空间。于是,在我的不屑努力下,终于,找到了一种基于堆栈实现的双向迭代算法。
所谓双向,是指可以从avl树的任何一个节点开始,随意的前移和后退,而不是只能朝着一个方向遍历。
本来是想尝试找一个计算机的普刊作为论文发上去的,但是考虑到人家期刊的论文大多是一些前言研究,所以就觉得自己没这个资格吧,而且还有昂贵的版面费问题以及职称和学历问题。所以,我想通过网络,直接把我研究出的算法公布出来。
由于之前已经写了论文,所以这里我就把我论文照搬上去了。有些公式和图片显示不了,文章链接我放到了知乎上:https://zhuanlan.zhihu.com/p/699338355。
源码以及demo程序的链接:https://gitee.com/luqi_866813670/MyLib
算法描述部分详细的描述了算法的整个过程。
摘要:以平衡二叉树为代表的一系列数据结构提供了优秀的查找性能和有序迭代数据的能力。由于其主要提供的是快速查找和插入删除数据的能力,因此迭代算法的相关研究较少。如今的C++标准库在如map等STL容器的实现上,采用增加额外的调整树结构的开销用以维护一组迭代指针,从而提供动态双向迭代的能力。而静态迭代算法一般以单向迭代为主,在迭代过程中不考虑回退节点的情况。设计一种不需要增加额外调整开销的双向迭代算法是一件值得挑战的事情。
关键词: 平衡二叉树; 双向迭代算法; C++标准库; 数据结构; STL容器
中图分类号:TP311.12
1 引言
在C++标准库中,以红黑树为基础的map、set等容器中,均使用了parent指针(指向父节点的指针)以及一个迭代状态标志位来实现动态迭代功能,这种做法的优点是提供了简易的动态双向迭代功能,也就是可以随意地前进和后退,而且对迭代状态的维护比较简单。但是在每次插入节点以及删除节点时,有可能会对路径上的节点进行左旋和右旋,一旦发生旋转操作,就需要对parent指针进行维护,并且由于每个节点都需要存储parent指针,当数据量较大时,会产生额外的空间。本文以AVL树为基础,使用基于双向堆栈实现的一种静态双向迭代算法。算法不需要维护节点的parent指针。
2 算法描述
首先定义AVL树的节点结构。AVL树节点的结构由:left(指向左子节点的指针)、right(指向右子节点的指针)、height(当前节点高度)、bf(平衡因子)组成。其中height用于在实现静态双向迭代时计算堆栈长度后退或前进的距离,而在插入、删除等算法中无需进行维护。由于AVL树的高度平衡性(左右子树高度差不超过1)。经过计算,在最坏情况下40层左右的树高能存储约1万亿个节点,因此在物理结构上,可以将bf以及height字段定义成1字节。相较于4或8字节大小的指针,空间占用是非常低的。
2.1初始化的算法
以下是初始化算法的步骤描述,用于在首次迭代时设置:
1) 定义需要使用的变量:一个长度为40的用于存放树节点的指针数组stack,模拟双向堆栈,存放前进或后退方向的节点。2个长度变量,分别是stack_next_len以及stack_prev_len,分别初始化为0和39(stack的长度-1)。以下将stack_next_len统称为前向索引,将stack_prev_len统称为后向索引。前者用于存放前进方向的节点指针,后者用于存放后退方向的节点指针。当前的高度cur_height,用于保存当前所处节点的高度,并用于更新在后续迭代过程中节点的高度。状态量status,表示当前迭代的边界状态。
2) 根据用户所选的迭代起始位置(最小值节点或最大值节点),初始化stack。
2.1) 如果从最小值节点开始迭代,那么从树根节点开始,下潜到最左侧节点,并依次将下潜过程中遍历的所有节点从前向索引往后(从0到39的方向)依次存入stack。
2.2) 如果从最大值节点开始迭代,那么从树根节点开始,下潜到最右侧节点,并依次将下潜过程中遍历的所有节点从后向索引往前(从39到0的方向)依次存入stack。
3) 根据下潜的方向,修改status,如果是左侧下潜,那么状态为1(表示当前的位置是处于比树的最左侧节点还要小的一个假象相邻节点),如果是右侧下潜,那么状态为2(表示当前的位置是处于比树的最右节点还要大的一个假象相邻节点)。若树为空,则将status设置为3。
2.2 前向迭代的算法
在完成了初始化之后,根据用户传入的迭代方向,调整双向堆栈的内容和迭代器的状态。
以下是执行next(前向)动作的迭代算法:
1) 如果迭代器的status = 0,说明当前的迭代状态正常,没有处于边界条件。接下来判断迭代器所指向节点是否存在右子节点。
1.1) 如果存在,那么将当前节点存入双向堆栈的后向索引处,索引递减1(表现为向双向堆栈的尾部堆入一个节点)。随后将节点设置为其右子节点R,并执行一个循环。该循环的作用表现为:从R节点左向下潜到其最左侧节点LM,并依次更新下潜过程中的节点高度,将它们存入到双向堆栈的前向索引处,然后递增1(表现为向双向堆栈的首部堆入一个节点)。完成后返回LM所指数据。
1.2) 如果不存在,且前向索引的值不为0。那么取出前向索引所处的节点N。设当前节点的高度减去N节点的高度再减去1的值为m,然后将后向索引的值加上m(表现双向堆栈的尾部出栈m个节点)。然后更新迭代器高度为N的高度。完成后返回N所指数据。
1.3) 如果上述都不满足,那么说明节点达到了比二叉树的最右节点还要大的位置(设为limR)。设置status=2,并返回空指针。
2) 如果当前status = 1,那么说明当前的位置处于二叉树最左节点还要小的位置(设为limL),此时进行next操作,那么所指向的节点必定是二叉树的最左节点L。设置迭代器status = 0,表示状态正常,然后返回L所指数据。
3) 如果当前status = 2,那么说明在到达limR之后还在尝试next操作,这种操作是无效的。只需返回空指针。
4) 如果status = 3,直接返回空指针
2.3 后向迭代的算法
后向迭代的算法与前向迭代大致相同,只需要将对应的方向和索引进行交换即可。
以下是执行previous(后向)动作的迭代算法:
1) 如果迭代器的status = 0,说明当前的迭代状态正常,没有处于边界条件。接下来判断迭代器所指向节点是否存在左子节点。
1.1) 如果存在,那么将当前节点存入双向堆栈的前向索引处,索引递减1(表现为向双向堆栈的首部堆入一个节点)。随后将节点设置为其左子节点L,并执行一个循环。该循环的作用表现为:从L节点右向下潜到其最右侧节点RM,并依次更新下潜过程中的节点高度,将它们存入到双向堆栈的后向索引处,然后递减1(表现为向双向堆栈的尾部堆入一个节点)。完成后返回RM所指数据。
1.2) 如果不存在,且后向索引的值不为39(栈的最大高度-1)。那么取出后向索引所处的节点N。设当前节点的高度减去N节点的高度再减去1的值为m,然后将前向索引的值减去m(表现双向堆栈的首部出栈m个节点)。然后更新迭代器高度为N的高度。完成后返回N所指数据。
1.3) 如果上述都不满足,那么说明节点到达了limL(见上文)。设置status=1,并返回空指针。
2) 如果当前status = 2,那么说明当前的位置在limR(见上文),此时进行previous操作,那么所指向的节点必定是二叉树的最右节点R。设置迭代器status = 0,表示状态正常,然后返回R所指数据。
3) 如果当前status = 1,那么说明在到达limL之后还在尝试previous操作,这种操作是无效的。只需返回空指针。
4) 如果status = 3,直接返回空指针
3 算法原理分析
基于堆栈的双向迭代算法,实现难点在于如何保证在双向任意次数迭代的情况下而不会产生节点的丢失以及失序的问题,解决这个问题的核心在于,如何根据前后迭代状态合理地更新两个堆栈的高度。
根据二叉树迭代过程中的下潜方向,我们分别用两个堆栈保存左向下潜和右向下潜迭代过程的所有节点,并且在无法通过下潜获取符合迭代方向的节点时,我们取出自身堆栈顶部的节点。这个节点代表需要回退的位置。如图1所示,假设当前节点是C,那么根据二叉树性质可知,B节点一定位于C节点之后迭代,且A节点一定位于C节点之前迭代。若此时执行previous操作,而节点B位于节点A和节点C之间,那么B节点应该失效。如果当前所处的节点是D,在执行next操作后,应该回退到节点R,并且在R与D之间的节点A和B都应该失效。而根据这一个过程,可以得知,若发生回退,则一定只可能且只有同一个下潜方向的节点需要被消除。而不同下潜方向的节点被保存在了不同堆栈,所以只需更新与自身相对的另一个堆栈的高度即可。且这两个堆栈的高度总和不会超过树高-1的长度,因此可以采用双向堆栈进一步节省空间。
图1 AVL树实例
4 算法性能
本文所述的是双向迭代算法的一种静态实现,由于在迭代过程中不考虑节点的删除和插入操作,因此迭代过程是静态的。(动态迭代的扩展见下文)
参考相关标准库的实现得知,使用parent指针作为迭代实现所占用的内存与使用双向堆栈作为迭代算法所占用的内存要大(指树本身的存储内存)。假设指针类型所使用的内存是8字节,那么在省去parent指针的情况下,在1000个节点的avl树上,就可以节省接近8kb的内存,这个大小是相当可观的。并且由于不需要对parent指针做维护,在考虑到缓存命中率的情况下,可以得出使用定长堆栈进行的操作会比直接取parent指针会更快。
若将AVL树的删除实现改为使用自顶向下的删除[1]算法,在省略parent指针的情况下,性能提升将非常明显。
4 算法扩展
基于上述的算法流程,可以延伸出其他算法的迭代器版本(不考虑多线程)。
首先是查找算法,在需要获取某一个节点并对其附近的节点进行迭代时,只需要把查找路径上的节点,按照下潜的方向依次存入双向堆栈即可。
其次是插入算法,在插入某一个节点之后,只需自顶向下,再次使用迭代器版本的查找算法即可重新构建查找路径。迭代器的插入算法时间复杂度如下:
最后是删除算法,如果删除的节点是迭代器所指的节点,那么在执行删除时,只需要将节点高度从小到大,进行合并,然后使用自底向上的删除算法,对树结构进行重构,并且将于删除的节点值最接近的节点更新到迭代器所指节点,然后在回退至根节点的过程中,如果发生旋转操作,只需要根据旋转的类型,调整双向堆栈的结构即可。迭代器的删除算法时间复杂度如下:
5 多线程可行性
以下将对多个线程使用同一个avl树对象进行迭代的情况做讨论。
经过理论分析可以得到,在多线程环境下,使用parent指针与使用双向堆栈的迭代算法在进行静态迭代时都不会失效,而使用parent指针的迭代算法,在迭代过程中如果需要进行插入操作,那么只需要加上互斥语句即可。而使用parent指针的迭代算法在删除操作的情况下,有非常低的概率会其他线程的迭代器失效,这种情况是所删除的节点正好被其他迭代器访问。
使用双向堆栈迭代的迭代算法,在进行删除操作以及插入操作时,都会使其他线程的迭代器失效,原因是树结构的改变不会实时更新到其他线程的堆栈上,并且各个迭代器的堆栈状态是不一样的,这样的同步代价就很高。解决方法是,在任意一个线程使用插入或删除操作之后,通知其余正在使用迭代器的进程,从avl树的根节点开始,将迭代器的当前所指数据指针作为查找依据,进行一个模糊查找,查找到最接近的那一个节点,从而完成双向堆栈的重构。使用这种方法时,在删除操作上同样存在与使用parent指针时一样的问题。如果使用的是指针类型来做查找依据,那么在查找时需确保备份了自身数据指针所指的数据,避免出现悬挂指针。这两种操作在有效的情况下,同步的时间复杂度如下:
其中是第i个线程的节点数量。
还可以使用读写锁的方式进一步简化时间复杂度,简化后的时间复杂度如下:
其中O(1)表示获取读写锁的时间,后部表示节点数量最大的线程查找一次某个节点的时间。
6 结论
平衡二叉树在多种应用场景中均有广泛的应用,基于堆栈的迭代算法很早就存在,但是缺乏双向迭代的灵活性。本文提出的方法在一定程度上提升了空间利用率和性能。列举一个使用双向迭代的情景:假如使用电话号码作为key,将人员信息保存到avl树中,那么可以通过双向迭代的方式,找出与给定的电话号码相近的一组电话号。更多应用场景有待读者发现。本文所述的方法不可在多线程环境中使用迭代器进行增删。由于使用堆来维护迭代状态,因此多线程操作不可避免的会破坏堆的状态,而当使用不同的迭代器进行增删操作时,所使用的堆也不是同一个,因此不能用于多线程环境下的增删操作。
参考文献:
[1] GNU Compiler Collection. (2022). GCC online documentation. 11.2.0. GNU Project. [EB/OL]. https://gcc.gnu.org/onlinedocs/gcc-11.2.0/.
[2] Mark A W. 数据结构与算法分析[M]. 北京: 机械工业出版社, 2004. 35-107.
[3] 唐自立. 一种新的删除AVL树的节点的算法[J]. 计算机应用于软件, 2005, 22(04):4-109.
-
[3] 唐自立. 一种新的删除AVL树的节点的算法[J]. 计算机应用于软件, 2005, 22(04):4-109 ↑