写了一个红黑树算法,有一点心得跟大家交流一下。
目录
一、缘起
二、过程
三、红黑树的理解
3.1 红黑计算
3.2 叶子节点NIL
3.3 删除产生“双黑”是什么意思
3.4 为什么删除只考虑有一个子结点的情况
四、编程经验
4.1 底层抽象
4.2 辅助代码
五、算法
上周一摸鱼闲聊,想起来我的代码库里有个核心算法是别人写的,是树的平衡,具体什么算法不清楚。我一直有想法把这个代码替换掉,替换掉后我的代码库就全部都是我自己写的了,于是就让别人看了看,别人说这是平衡二叉树,不是红黑树,红黑树才是综合性能最好的。
当初为什么这个算法给别人写了呢?因为当时是在项目中,时间比较紧,没时间去写一个心里没谱的复杂算法,于是就用一个简单折中办法:每次插入时计算深度,深度超过理论平衡二叉树深度的两倍就完全排序重建树。
旁边一个兄弟说他有兴趣挑战一下这个算法,于是他花了大约两周完成了这个算法(由于在大数据量测试中不断发生问题,所以大约两周后才最终测试通过)。后来就一直用他写的这个算法,我也没在意用的是平衡二叉树还是红黑树,它们的性能差异不是很重要。
有人说各种类库都有现成算法,用的还都是红黑树,为什么我的要自己写呢?这真不是为了练手,而是因为我们用的是共享内存,不能基于指针,而我实在找不到通过包装来利用stl算法的方法,所以不得不自己写。
当然了,我并不认为“重复发明轮子”是不必要的行为,这是另一个话题,不展开讲了。
下了决心要写红黑树,就上网搜罗了各种文章来学习,理解了一下各种概念,发现红黑树的几个定义大家说的当然都一样,阐述却不是很一样,各人角度各有不同,我的理解也不太一样,所以我会把我的理解方式写出来跟大家探讨。
写插入算法挺简单,随便哪个文章照着写就对了。很容易就测试通过了。由于树的基础功能是以前就写好的,所以只花了半天就把插入算法写好了。
写删除算法就麻烦了,由于写的时候对算法的理解还不够透彻,不断测试不断错,不断修改,最后发现是因为之前对别人文章的理解错了(而不是别人的文章错了)。写删除算法花了整整两天(程序员的两天,每天不只是八小时)。
红节点不计数,黑节点算深度,也就可以理解为,红节点就是相比平衡二叉树多出来的节点。
因为红节点不允许连续,这就限制了红节点的数量,大致不能超过黑节点数太多(不做论文的话不用太精确)。
新增节点首先设置为红色,遇到黑色父节点不用平衡,遇到红色父节点再做平衡。这就能减少一部分平衡操作(具体减少多少不好说),所以比平衡二叉树综合性能高一些。
因为规定从根到叶子节点(NIL节点)的黑节点数都相同,所以扣除红节点,剩下的树看起来是类似平衡树的。
红黑树所有叶子节点都是NIL,就是把左右子树为空的空指针当作叶子,因为最终每条分支都一定会以NIL分支结束,而且NIL规定为黑色,所以这个所谓的“叶子”不影响红黑计算。
根节点规定为黑色,也不影响红黑计算。
很多文章描述删除时使用“双黑节点”这个概念,这个概念其实就是“这个节点的黑高少一”。
这种情形由删除了一个黑色数据节点形成,因为删除了一个,当然黑高减少1。
“双黑变单黑”这个说法我就不是很能理解。我觉得理解成“黑高少一”就可以了。
解决黑高少一的办法不外乎两个:
1,从兄弟节点那边挪一个红色节点过来变成黑色,黑高恢复
2,把兄弟节点那边变一个红色出来,兄弟节点也黑高减一,两边平衡,父节点变成了黑高少一节点,向上递归处理即可
后面我会详细解释删除算法。
这里说的子节点指的是有数据的子节点,不是NIL叶子节点。
因为删除中间节点可以通过用左子树最大值或右子树最小值交换的方法把问题转换为只有一个子结点的情况。
编写算法一定要做底层抽象,这样算法才能复用。因为我一直在共享内存上写东西,所以对STL直接使用指针这个事挺抓狂。指针作为基础类型,是不可以重定义的。
因为我这里数据都是通过索引访问的,所以我用这种方式来访问实际数据:
struct TREE_NODE
{
static TREE_NODE& at(T_SIZE n);
T_SIZE _me()const;
};
静态方法at(T_SIZE n)获取n处的数据,至于如何获取,可以很简单,也可以很复杂。
_me()返回自己的位置。对于指针,n就是自己,自己就是n,但是这里n不是指针,所以就有了反向查找n的需要。
编写算法没有辅助代码根本不行。编写过程中我写了图形化显示树的代码和检测树结构的代码,以及测试验证的代码。
测试验证的代码必须是自动化一次完成的,遇到错误自动停止。为了跟踪错误,要有很多日志输出,但是大数据量测试会产生过多输出,所以我用了一个小技巧:
先用关闭调试执行,遇到错误再打开调试重新执行:
int main()
{
G_IS_DEBUG = false;//关闭调试输出
int count = 1000;//每次测试的数据量
for (int i = 0; i < 100; ++i)
{
if (!test(i, count))//以不同的随机数种子生成数据测试
{
G_IS_DEBUG = true;//遇到错误,打开调试,重新执行出错的参数
test(i, count);
thelog << "失败 i= " << i << endi;
return 1;
}
thelog << TimeToString_log(time(NULL)) << " " << i << endi;
}
return 0;
}
稍候。
(这里是结束)