一种简单的无限深度树结构数据库设计方案

最近在开发jSqlBox过程中,研究树形结构的操作,突然发现一种简单的树结构数据库存储方案,在网上找了一下,没有找到雷同的(也可能是花的时间不够),现介绍如下:

目前常见的树形结构数据库存储方案有以下四种,但是都存在一定问题:

1)Adjacency List::记录父节点。优点是简单,缺点是访问子树需要遍历,发出许多条SQL,对数据库压力大。

2)Path Enumerations:用一个字符串记录整个路径。优点是查询方便,缺点是插入新记录时要手工更改此节点以下所有路径,很容易出错。

3)Closure Table:专门一张表维护Path,缺点是占用空间大,操作不直观。

4)Nested Sets:记录左值和右值,缺点是复杂难操作。

以上方法都存在一个共同缺点:操作不直观,不能直接看到树结构,不利于开发和调试。

本文介绍的第一种方法我暂称它为“简单粗暴多列存储法”或称为"朱氏深度树V1.0法"(如已有人发明过,删掉头两个字就好了),因为下面还有改进版。它与Path-Enumerations模式有点类似,但区别是用很多的数据库列来存储一个占位符(1或空值),如下图(https://github.com/drinkjava2/Multiple-Columns-Tree/blob/master/treemapping.jpg) 左边的树结构,映射在数据库里的结构见右图表格:

各种SQL操作如下:

```

1.获取(或删除)指定节点下所有子节点,已知节点的行号为"X",列名"cY":

select *(or delete) from tb where

line>=X and line<(select min(line) from tb where line>X and  (cY=1 or c(Y-1)=1 or c(Y-2)=1 ... or c1=1))

例如获取D节点及其所有子节点:

select * from tb where line>=7 and line< (select min(line) from tb where line>7 and (c2=1 or c1=1))

删除D节点及其所有子节点:

delete from tb where line>=7 and line< (select min(line) from tb where line>7 and (c2=1 or c1=1))

仅获取D节点的次级所有子节点:

select * from tb where line>=7 and c3=1 and line< (select min(line) from tb where line>7 and (c2=1 or c1=1))

2.查询指定节点的根节点, 已知节点的行号为"X",列名"cY":

select * from tb where line=(select max(line) from tb where line<=X and c1=1)

例如查I节点的根节点:

select * from tb where line=(select max(line) from tb where line<=12 and c1=1)

3.查询指定节点的上一级父节点, 已知节点的行号为"X",列名"cY":

select * from tb where line=(select max(line) from tb where line

例如查L节点的上一级父节点:

select * from tb where line=(select max(line) from tb where line<11 and c3=1)

3.查询指定节点的所有父节点, 已知节点的行号为"X",列名"cY":

select * from tb where line=(select max(line) from tb where line

union select * from tb where line=(select max(line) from tb where line

...

union select * from tb where line=(select max(line) from tb where line

例如查I节点的所有父节点:

select * from tb where line=(select max(line) from tb where line<12 and c2=1)

union  select * from tb where line=(select max(line) from tb where line<12 and c1=1)

4.插入新节点:

视需求而定,例如在J和K之间插入一个新节点T:

update tb set line=line+1 where line>=10;

insert into tb (line,id,c4) values (10,'T',1)

这是与Path Enumerations模式最大的区别,插入非常方便,只需要利用SQL将后面的所有行号加1即可,无须花很大精力维护path字串,

不容易出错。

另外如果表非常大,为了避免update tb set line=line+1 造成全表更新,影响性能,可以考虑增加

一个GroupID字段,同一个根节点下的所有节点共用一个GroupID,所有操作均在groupID组内进行,例如插入新节点改为:

update tb set line=line+1 where groupid=2 and line>=8;

insert into tb (groupid,line,c4) values (2, 8,'T')

因为一个groupid下的操作不会影响到其它groupid,对于复杂的增删改操作甚至可以在内存中完成操作后,一次性删除整个group的内容

并重新插入一个新group即可。

```

总结:

以上介绍的这种方法优点有:

1)直观易懂,方便调试,是所有树结构数据库方案中唯一所见即所得,能够直接看到树的形状的方案,空值的采用使得树形结构一目了然。

2)SQL查询、删除、插入非常方便,没有用到Like语法。

3)只需要一张表

4) 兼容所有数据库

5)占位符即为实际要显示的内容应出现的地方,方便编写Grid之类的表格显示控件

缺点有

1)不是无限深度树,数据库最大允许列数有限制,通常最多为1000,这导致了树的深度不能超过1000,而且考虑到列数过多对性能也有影响, 使用时建议定一个比较小的深度限制例如100。

2)SQL语句比较长,很多时候会出现c9=1 or c8=1  or c7=1 ... or c1=1这种n阶乘式的查询条件

3)树的节点整体移动操作比较麻烦,需要将整个子树平移或上下称动,当节点须要经常移动时,不建议采用这种方案。对于一些只增减,不常移动节点的应用如论坛贴子和评论倒比较合适。

4)列非常多时,空间占用有点大。

##以下为改进版,是建立在前述基础上,一种更简单的无限深度树方案

上面第一种方法比较笨拙,如果不用多列而是只用一个列来存储一个深度等级数值,则可以不受数据库列数限制,从而进化为无限深度树,虽然不再具有所见即所得的效果,但是在性能和简单性上要远远超过上述“简单粗暴多列存储法”,暂时给它取名"朱氏深度树V2.0法"(呵呵如果没人发明过的话),介绍如下:

如下图 (https://github.com/drinkjava2/Multiple-Columns-Tree/blob/master/treemappingv2.png) 左边的树结构,映射在数据库里的结构见右图表格,注意每个表格的最后一行必须有一个END标记,level设为0:

```

1.获取指定节点下所有子节点,已知节点的行号为X,level为Y, groupID为Z

select * from tb2 where groupID=Z and

line>=X and line<(select min(line) from tb where line>X and level<=Y and groupID=Z)

例如获取D节点及其所有子节点:

select * from tb2 where groupID=1 and

line>=7 and line< (select min(line) from tb2 where groupid=1 and line>7 and level<=2)

删除和获取相似,只要将sql中select * 换成delete即可。

仅获取D节点的次级所有子节点:(查询条件加一个level=Y+1即可):

select * from tb2 where groupID=1 and

line>=7 and level=3 and line< (select min(line) from tb2 where groupid=1 and line>7 and level<=2)

2.查询任意节点的根节点, 已知节点的groupid为Z

select * from tb2 where groupID=Z and line=1 (或level=1)

3.查询指定节点的上一级父节点, 已知节点的行号为X,level为Y, groupID为Z

select * from tb2 where groupID=Z and

line=(select max(line) from tb2 where groupID=Z and line

例如查L节点的上一级父节点:

select * from tb2 where groupID=1

and line=(select max(line) from tb2 where groupID=1 and line<11 and level=3)

4.查询指定节点的所有父节点, 已知节点的line=X,level=Y:

select * from tb2 where groupID=Z and

line=(select max(line) from tb2 where groupID=Z and line

union select * from tb2 where groupID=Z and

line=(select max(line) from tb2 where groupID=Z and line

...

union select * from tb2 where groupID=Z and

line=(select max(line) from tb2 where groupID=Z and line

例如查I节点的所有父节点:

select * from tb2 where groupID=1 and

line=(select max(line) from tb2 where groupID=1 and line<12 and level=2)

union  select * from tb2 where groupID=1 and

line=(select max(line) from tb2 where groupID=1 and line<12 and level=1)

5.插入新节点:例如在J和K之间插入一个新节点T:

update tb2 set line=line+1 where  groupID=1 and line>=10;

insert into tb (groupid,line,id,level) values (1,10,'T',4);

```

总结:

此方法优点有:

1) 是无限深度树

2) 虽然不象第一种方案那样具有所见即所得的效果,但是依然具有直观易懂,方便调试的特点。

3) 能充分利用SQL,查询、删除、插入非常方便,SQL比第一种方案简单多了,也没有用到like模糊查询语法。

4) 只需要一张表。

5) 兼容所有数据库。

6) 占用空间小

缺点有:

1)树的节点整体移动操作有点麻烦, 适用于一些只增减,不常移动节点的场合如论坛贴子和评论等。当确实需要进行复杂的移动节点操作时,一种方案是在内存中进行整个树的操作并完成排序,操作完成后删除整个旧group再整体将新group一次性批量插入数据库。

2017年1月22日补充1:

节点的移动操作有点麻烦,只是相对于查询/删除/插入来说,并不是说难上天了。例如在MySQL下移动整个B节点树到H节点下,并位于J和K之间的操作如下:

updatetb2settempno=line*1000000wheregroupid=1;set@nextNodeLine=(selectmin(line)fromtb2wheregroupid=1andline>2andlevel<=2);updatetb2settempno=9*1000000+line,level=level+2wheregroupID=1andline>=2andline<@nextNodeLine;set@mycnt=0;updatetb2setline=(@mycnt:=@mycnt+1)wheregroupid=1orderbytempno;

上例需要在表中新增一个名为tempno的整数类型列, 这是个懒人算法,虽然简单明了,但是对整棵树进行了重新排序,所以效率并不高。 在需要频繁移动节点的场合下,用Adjacency List方案可能更合适一些。

2017年1月22日补充2:

如果需要频繁移动节点的场合,又想保留方案2高效查询的优点,还有一种方案就是再添加一个父节点pid字段和两个辅助字段tempno和 temporder用于排序,(暂时称其为“深度树V3.0法"), 这样相当于V2.0法和Adjacency List模式的合并了,优点是每次移动节点,只需要更改PID即可,不需要复杂的算法,一次可以任意移动、增加、删除多个节点,最后统一调用以下算法简单 地进行一下重排序即可,下面这个示例完整演示了一个Adjacency List模式到V2.0模式的转换,这相当于一个重新给树建查询索引的过程:

createtabletb3(idvarchar(10),commentsvarchar(55),pidvarchar(10),lineinteger,levelinteger,tempnobigint,temporderinteger)insertintotreetest(id,comments,Pid)values('A','found a bug',null);insertintotreetest(id,comments,Pid)values('B','is a worm?','A');insertintotreetest(id,comments,Pid)values('E','no','B');insertintotreetest(id,comments,Pid)values('F','is a bug','B');insertintotreetest(id,comments,Pid)values('C','oh, a bug','A');insertintotreetest(id,comments,Pid)values('G','need solve it','C');insertintotreetest(id,comments,Pid)values('D','careful it bites','A');insertintotreetest(id,comments,Pid)values('H','it does not bite','D');insertintotreetest(id,comments,Pid)values('J','found the reason','H');insertintotreetest(id,comments,Pid)values('K','solved','H');insertintotreetest(id,comments,Pid)values('L','uploaded','H');insertintotreetest(id,comments,Pid)values('I','well done!','D');set@mycnt=0;updatetb3setline=0,level=0, tempno=0, temporder=(@mycnt:=@mycnt+1)orderbyid;updatetb3setlevel=1, line=1wherepidisnull;updatetb3settempno=line*10000000whereline>0;updatetb3 a, tb3 bseta.level=2, a.tempno=b.tempno+a.temporderwherea.level=0anda.pid=b.idandb.level=1;set@mycnt=0;updatetb3setline=(@mycnt:=@mycnt+1)wherelevel>0orderbytempno;updatetb3settempno=line*10000000whereline>0;updatetb3 a, tb3 bseta.level=3, a.tempno=b.tempno+a.temporderwherea.level=0anda.pid=b.idandb.level=2;set@mycnt=0;updatetb3setline=(@mycnt:=@mycnt+1)wherelevel>0orderbytempno;updatetb3settempno=line*10000000whereline>0;updatetb3 a, tb3 bseta.level=4, a.tempno=b.tempno+a.temporderwherea.level=0anda.pid=b.idandb.level=3;set@mycnt=0;updatetb3setline=(@mycnt:=@mycnt+1)wherelevel>0orderbytempno;

以上算法利用了SQL的功能,将原来可能需要非常多SQL递归查询的过程转变成了有限次数(=树最大深度)的SQL操作,为了突出算法,以上示例 假设只有一个根节点,删除了groupid和endtag,实际使用中要完善一下这个细节, order by id也可改成以其它字段排序。因时间关系我就不给出V2.0模式到Adjacency List模式逆推的算法了(也即pid为空,根据V2.0表格倒过来给pid赋值的过程),不过这个算法倒不重要,因为通常v3.0表中每一行会一直保存 着一个pid)。

总结一下:

Adjacency List模式:移/增/删节点方便,查询不方便

深度树V2.0法:查询方便,增/删节点方便,但存在效率问题,移动节点不方便

深度树V3.0法:移/增/删节点方便,查询方便,缺点是每次移/增/删节点后要重建line和level值以供查询用。它是结合了前面两种模式的合并体,并可以根据侧重,随时在这两种模式(修改模式和查询模式)间切换。v3.0法相当于给Adjacency List模式设计了一个查询索引。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,493评论 18 399
  • 50个常用的sql语句Student(S#,Sname,Sage,Ssex) 学生表Course(C#,Cname...
    哈哈海阅读 1,220评论 0 7
  • “自从迷上了康德 我虚度的人生 定义出了非凡的意义” 朋友圈发了这段话 被骂了一整晚。
    留子尧阅读 128评论 0 4
  • 自由写作2017年一月 身边一岁多的儿子正睡的香,每天把他哄睡后躺在床上又睡不着的这一两个钟头是一天中周围环境安静...
    晓米eR阅读 430评论 0 2
  • “早在多年前,我们就给出了承诺,要给姐妹们一个梦想中的温暖的家,这里没有各种秀,也没有多么高逼格的策划,只有卸下疲...
    陈晓依阅读 1,085评论 0 0