你好,树
清晨就收到这个,既然是行文中出现的,不妨发上来,这样会有一种感觉,自己还是个搞技术的:]
前言
本来是想起个类似“xxxx树详解”之类的名字,觉得可能详解不了,所以还是小清新一点,篇幅一定不要很长。今天用屌丝的语言来行文,再次回复自由,屌丝的生活让我热情满血:p
当初学数据结构的时候(嗯,如果你是抱着看生态文章的态度进来,那只能说你被我坑了),树就让我恐惧,当我知道树也是一种图的时候,我几乎已经不想吃饭。
现在,这篇文章,不可能出现关于树性质的一些证明、推导和详细释义,只从应用分析的角度来看看树,今天看看B+树和红黑树。其中红黑树的伪代码本人手敲,是为了方便写注释,详细请参见普林斯顿大学的算法或算法导论等经典。
B+树
这里的B+树将和B树不加区分,下图只画出逻辑结构,如果索引层的兄弟节点之间有互相指向的指针,你可以认为是B树,比如innoDB的索引组织。
这幅图传达几个信息:
1.这是一棵4阶未满的B+树;
2.蓝色是非叶节点的索引记录,不保存实际数据记录。索引节点一般从磁盘拉到内存后,会缓存在内存中,根节点一般比缓存,第二层看情况而定;
3.红色代表指针或者说是地址.
叶子层,叶子之间的相互指针,你想到了什么,对,就是:
select * from table where key > 5 and key < 100
.
范围查询,逻辑上是不是很爽!这也是B+树和B-树的一个不同之一(B是Balanced,“-”也不是减号)。
而且你看到了数据在页节点内部是有序的,你还要什么?
order by key asc/desc
4.绿色节点是叶子节点。
在这幅图的情况下,一次select * from table where key = 5
的操作,有缓存地情况下大致会经历三次内存查找(找到根、第二层索引、叶子),用于在索引层找到正确的指向5的叶子节点。
无缓存的情况下会经历三次磁盘查找根->第二层->叶子块
,用于将5所在的叶子块的数据读出。
内存查找中,没有磁盘I/O的顾虑,索引数据又是有序的,你想怎么查那还不够high?
磁盘查找,会经历寻道(柱面)、盘片旋转(到对应扇区)、电子化读的操作。
磁盘和内存速度差多少?极端情况下,一次磁盘操作,可以进行几万到几十万次内存访问。
这一点也就是为什么分布式、大并发系统的瓶颈大多最终会集中在数据持久化层。
5.block-oriented的文件系统,一般都有块儿这个概念,hadoop也不例外,block是hadoop的fs的原语,也是很多文件系统的原语。
一般叶子最小是一个块,块内记录逻辑有序且物理连续(磁盘上连续),但块间不连续,HDFS也是,从文件系统来讲,块位置的随机化是一种全局磁盘空间高效利用的表现。
但数据库的块总想着特殊点儿,总想着连续,这是有原因的,谁不想穿过索引,直接从第一块开始连续扫到最后(磁盘转啊转,这是磁盘的最爱),可惜很难,只能在块粒度上做文章,大了浪费空间,小了不连续几率更高,而且容易外部碎片(360大师帮你整理的那个磁盘优化)。
facebook经典的逻辑预读取,来自OLAP和逻辑全表备份的需求,但是表太大,几十亿甚至百亿条数据,反复的插、改、标记删除,longtime aged的一棵B+,会导致叶子块的物理连续性完全崩坏,和逻辑有序越来越远。全表一次,最最极端的情况下,有多少个叶子块,会有多少次磁盘寻道+旋转。有兴趣的同学请自行百度。
你说线性预读取?开玩笑,一个块才多大。命中率上不去的缓存比直接读磁盘还可怕!
B+树实际意义在哪里?为啥几乎所有的关系型数据库都有这种索引B-TREE
,原因就是:
磁盘爱连续不爱随机,机械磁盘比SSD便宜,SSD还容易坏!你有钱另当别论,而且很多公司做数据挖掘和商业智能的,不在乎那点钱.
put them in SSD as more as you can!
平衡树-红黑树和AVL树
2-3树演化而来,如果对红黑树的代码比较晕,建议从AVL树和递归实现入手。
AVL树和红黑树都是平衡树,而且都是二叉搜索树(Binary Search Tree,俗称BST),平衡就是要压低树高,让平均查询路径更短,看看下图就知道为什么需要平衡:
上图展示了二叉搜索树在非随机情况下退化为一个顺序链表,二叉搜索树的查找退化为\(O(N)\).
红黑树比AVL树的插入(put、write)性能好,虽然不如AVL树那般严格平衡。
红黑树的高效秘密只有一个:
任何简单查询路径的长度不会大过最短路径的两倍。
为什么?
因为最短路径全是黑,红又不能连续出现,叶子又是黑,所以最长路径红黑交替着来,你算算。
平衡操作-旋转
AVL树和红黑树最让开始接触的人崩溃的就是rotation,好多人,好多博客、甚至好多书(非经典),左旋、右旋、左右双旋、右左双旋不分。
要分清,得有下面的认识:
什么是case(型)?
left-left(LL)、left-right(LR)、right-left(RL)、right-right(RR),这些是case.
什么是op(操作)?
left-rotation(左旋)、right-rotation(右旋),这些是操作。
- LL型明显右边缺了,得平衡,怎么办,右边缺补右边,右单旋;
- RR是个镜像,所以左单旋。
- LR型你怎么单旋都平衡不了,所以考虑从孩子入手,孩子右旋,然后自己再左旋。
- RL是个镜像,孩子左旋,自己右旋。
AVL树,处理RR型,左单旋(left-rotation):
AVL树,处理LR型,左-右双旋(left-right-rotation)
红黑树的性质
1.节点有颜色(一个布尔量),红或者黑;
2.根节点必为黑;
3.叶节点(空NIL)必为黑;
4.红节点的孩子必为黑;
5.对每个节点,从其到叶子的简单路径(一直往下),包含的黑节点相同.第4、5条性质说明了任何简单查询路径的长度不会大过最短路径的两倍。
红黑树在旋转上和AVL没有区别(但有的实现中将颜色变换包含进去,比如普林斯顿 algorithm 4),先看红黑的左单旋代码,这里上nginx的:
//左单旋
static ngx_inline void
ngx_rbtree_left_rotate(ngx_rbtree_node_t **root, ngx_rbtree_node_t *sentinel,
ngx_rbtree_node_t *node)
{
ngx_rbtree_node_t *temp;
//暂存右孩子
temp = node->right;
//自己要占据右孩子的左孩子的位置,所以先把人家的左孩子拎过来,拎到右边,为什么?红黑树也是搜索树,有保序要求(左小右大)
node->right = temp->left;
//让原来右孩子的左孩子(你孙子)认个亲,毕竟你要把你孙子拎走
if (temp->left != sentinel) {
temp->left->parent = node;
}
//好了,拎了孙子,右孩子升级了成咱爹的儿了,和咱同辈?还有后续呢...
temp->parent = node->parent;
//一切手续都齐备了,现在自己的爹来认新儿子了
if (node == *root) {
*root = temp;
} else if (node == node->parent->left) {
node->parent->left = temp;
} else {
node->parent->right = temp;
}
//果然,爹不仅不要咱了,咱还成了爹的孙子
temp->left = node;
//成孙子了,那爹也换得了,换成咱平衡前的右孩子了...
node->parent = temp;
}
看这样的代码,需要以图为相,参考上面的AVL树的旋转示意图。
多画画,看图写码,直接看代码能想象出一个树形的,是大师。
红黑树的插入
先看看伪代码:
/**
* 红黑树插入
*
* @param t 红黑树
* @param z 待插入节点
*/
RB-INSERT(t,z) {
y = T.nil
x = T.root
//和二叉搜索树一样,找到插入位置
while(x != T.nil) {
y = x
if(z.key < x.key) {
x = x.left
}else{
x = x.right
}
}
z.p = y
if(y == T.nil) {
T.root = z
}else if(y.right == z) {
z.left = T.nil
}else{
z.right = T.nil
}
//新插入的节点着色为红色,这一点会贯穿始终,也叫invariant.
//如果你经常看算法、jdk、java语言规范,对这个词应该不陌生,暂且叫不变式吧
//比如java中的volatile关键字一条不变式就是保证其修饰的变量跨线程的内存可见性和一致性
z.color = RED
//调整平衡及着色,保证红黑树的性质
RB-INSERT-FIXUP(T,z)
}
插入没什么好说的,和二叉搜索树类似,最后多了着色、哨兵处理,最重要的是这个函数RB-INSERT-FIXUP(T,z)
,下面给出图,不然根本不会看懂伪代码:
注:图来自算法导论D版,以后会换掉此图
情况1:z的叔叔节点y为红色,z上升,其父亲和叔叔变色;
情况2:z是棵右子树,上升,左旋,同时保证红黑树性质,进行对应变色
情况3:z在左旋后成了左孩子,因为一直要保证z是红色,所以其父变黑,爷爷变红,出现不平衡,所以右旋平衡,最终形成图(d),满足红黑树的性质。
RB-INSERT-FIXUP(T,z)函数伪代码,结合着图看效果更佳:
/**
* 红黑树调整,保证其性质
*
* @param t 红黑树
* @param z 待插入节点
*/
RB-INSERT-FIXUP(T,z) {
//红黑树性质,红节点的孩子不能是红节点
//所以调整到节点z的父亲是黑就ok
while(z.p.color == RED) {
//z的父节点是棵左子树
if(z.p == z.p.p.left) {
//考察z的叔叔节点,这里是y
y = z.p.p.right
//叔叔是红色
if(y.color == RED) {
//z变上升到其爷爷节点,并保证其为红色
//同时z原来的父亲和叔叔变为黑色
z.p.color = BLACK
y.color = BLACK
z.p.p.color = RED
z = z.p.p
}else if(z == z.p.right) {
//z是右子树,上升一层
z = z.p
/* 这里虽然不是先对孩子右旋再自己左旋,
而是自己先左旋,孩子右旋,同样可满足平衡 */
//对z所代表的子树进行左线
LEFT-ROTATE(T,z)
//左旋后的z成了左孩子,所以要将其新父亲染黑
z.p.color = BLACK
//保证红黑树的性质,其爷爷必为红色
z.p.p.color = RED
RIGHT-ROTATE(T,z.p.p)
}
}else{
//镜像操作,也就是z的父亲是棵右子树
}
//红黑树性质,根节点必为黑色
T.root.color = BLACK
}
}
红黑树的删除
本篇已经很长了,留个坑下次填上。
// todo: this is a 坑.
平衡树的应用价值
1.不像B、B+、B*树,平衡树基本都是针对内存的数据结构,目的也很简单,压低树高,保持平衡,减少平均查找长度,同时继承二叉搜索树对数级的操作效率(有序可二分)。
2.nginx采用红黑树(rbtree)管理定时器(timer)和缓存,具体可参考其源码。
3.linux内核中也实现了一个rbtree,用于virtual memory area(VMA)
的映射管理,就是包含在vm_area_struct
,比如将一个vm_area_struct
插入红黑树:
static inline void vma_rb_insert(struct vm_area_struct *vma,
struct rb_root *root)
{
/* All rb_subtree_gap values must be consistent prior to insertion */
validate_mm_rb(root, NULL);
rb_insert_augmented(&vma->vm_rb, root, &vma_gap_callbacks);
}
而具体怎么和rbtree联系的?就是vm_area_struct
:
/*
* This struct defines a memory VMM memory area. There is one of these
* per VM-area/task. A VM area is any part of the process virtual memory
* space that has a special rule for the page-fault handlers (ie a shared
* library, the executable area etc).
*/
struct vm_area_struct {
/* The first cache line has the info for VMA tree walking. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next, *vm_prev;
struct rb_node vm_rb;
/*
* Largest free memory gap in bytes to the left of this VMA.
* Either between this VMA and vma->vm_prev, or between one of the
* VMAs below us in the VMA rbtree and its ->vm_prev. This helps
* get_unmapped_area find a free area of the right size.
*/
unsigned long rb_subtree_gap;
/* Second cache line starts here. */
struct mm_struct *vm_mm; /* The address space we belong to. */
pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, see mm.h. */
/*
* For areas with an address space and backing store,
* linkage into the address_space->i_mmap interval tree, or
* linkage of vma in the address_space->i_mmap_nonlinear list.
*/
union {
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} linear;
struct list_head nonlinear;
} shared;
/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_chain; /* Serialized by mmap_sem &
* page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
/* reserved for Red Hat */
unsigned long rh_reserved1;
unsigned long rh_reserved2;
unsigned long rh_reserved3;
unsigned long rh_reserved4;
};
总结
树是数据结构中的明珠(自发赞美),在数据处理中,是几乎除了基本的数组、链表、队列、栈之外应用最广泛的数据结构了,本篇只窥其一斑。
所谓大并发、高性能、高可用、一致性等等被人挂在嘴上不放(你不提人家觉得你不懂技术似的)的名词,几乎都是数据结构、线程模型、算法、通讯和软件工程等基础却犀利的东西支撑着的。
当然一个成功的系统或framework,离不开软件工程和一流的项目管理,以及程序员自身的编码素质,这个不懂,也就不妄言了。
希望对您有益。