树的存储和常用操作

树是我们日常开发中经常会用到的一种数据类型,常用的应用场景有:无限级分类、任务列表等层次数据。为了便于理解,我们首先假设需要存储如下图所示的树结构:

图1

对这样一颗树常用的树操作有:

  • 树节点查询:
  • 查询某节点下的所有子节点(子树)
  • 查询某节点的所有父级(路径查询)
  • 添加节点
  • 移动节点

下面介绍两种我个人常用的树存储模型以及对树常用操作的实现。

  1. 邻接表模型

我们在使用关联数据库存储树结构的时候,通常使用的方法是一条数据存储一个节点,在节点中添加一个parentId字段存储当前节点的父节点ID来实现树的存储,这种方法我们称为邻接表模型。使用Mysql存储这颗树的数据表结构可能类似以下结构:

ID name parentId
1 root 0
2 A 1
3 B 1
4 C 1
5 A1 2
6 A2 2
7 C1 4
8 C2 4
9 A2.1 6

下面,我们通过树的常用操作分析下这种存储模型的性能。

2.1 查询某节点下的所有子节点(子树查询)

假设我们现在需要查询节点A的子树,根据图1所示我们知道节点A的直接子节点有A1、A2,而A2又有子节点A2.1。而在邻接表模型中我们是通过parentId来管理树的层次接口,那么我们要获取A节点的子树就需要:

  1. 根据A节点的ID 2获取到A节点的直接子节点A1、A2。
  2. 遍历A节点的直接子节点获取所有孙级节点。
  3. 遍历A节点的所有孙级节点获取更下层节点,以次类推直到获取到整颗子树。

通过这个查询流程我们发现要获取A节点的子树实际上需要进行很多次查询性能是非常低的。当然,我们可以优化扩展邻接表模型以优化这类查询的性能。比如增加一个parentIds字段存储一个节点的所有父节点路径,例如A2.1的parentIds为”,0,2,6,”这时候我们可以通过如下SQL查询到A节点的子树:select * from true where parentIds like "%,2,%"。但是这种方法依然存在缺陷,首先,like查询的性能在大表下会有一定的影响,其次是在插入新节点时需要预先获取其所有父节点ID再写入parentIds字段中。

2.2 查询某节点的所有父级节点(路径查询)

假设我们现在需要查询节点A2.1的路径,根据图1所示我们知道A2.1的路径是A2.1 > A2 > A > root。类似2.1的查询方法我们需要:

  1. 通过A2.1的parentId查询到A2.1的直接父级A2
  2. 通过A2的parentId查询到A2.1的爷级节点A
  3. 通过A的parentId查询到A2.1的祖级节点root

同样,要实现这样的查询性能也是非常低的。当然,我们也可以像2.1那样优化在节点中添加一个childrenId字段存储节点的所有子节点ID来进行查询优化,但是很显然的问题如果这是一个颗很大的树,那么root节点的children字段将会非常大!噢No!

2.3 添加新节点

假设现在需要为A2.1添加一个子A2.1.1,很简单只需要插入一条数据将parentId设置为9就可以了。

2.4 移动节点

移动节点也非常简单快速,假设我们需要将A2.1节点移动到节点C下只需要更新A2.1节点的parentId字段为4就可以了。

  1. 先根遍历树模型

先根遍历树模型是另一种常用的树存储模型,实现方法是从根节点左侧开始分别为每个节点进行编号直到树的最底层,再从最底成节点右侧反向编号直到回到根节点。如下图所示:

图2

这样每个节点都获得了一个左值和右值,使用Mysql存储的表结构类似下表:

ID name leftValue rightValue
1 root 1 18
2 A 2 9
3 B 10 11
4 C 12 17
5 A1 3 4
6 A2 5 8
7 C1 13 14
8 C2 15 16
9 A2.1 6 7

同样,基于先根遍历树模型我们看下如何进行树的常用操作。

3.1 查询某节点下的所有子节点(子树查询)

同样假设需要查询节点A的子树,通过观察图2中节点的左右值可以发现A节点的所有子节点左值肯定都大于A节点的左值,而右值肯定都小于A节点的右值!根据这条规则我们使用一条SQL就可以查询到A节点的子树,SQL如下:

select * from tree
    where leftValue > a.leftValue
        and rightValue < a.rightValue
    order by leftValue asc;

可见使用这种存储模型查询子树的性能远远高于邻接表模型。

3.2 查询某节点的所有父级节点(路径查询)

同样假设需要查询节点A2.1的路径,通过观察图2中节点A2.1的所有父节点左右值可以发现,A2.1的所有父节点左值肯定都小于A2.1的左值,而右值肯定都大于节点A2.1的右值!根据这条规则我们同样可以使用一条SQL查询到A2.1的所有父节点,SQL如下:

select * from tree
    where leftValue < a21.leftValue
        and rightValue > a21.rightValue
    order by leftValue acs;

可见使用这种存储模型查询节点路径的性能也是远远高于邻接表模型。

3.3 添加新节点

通过前面两种查询操作可以看到先根遍历树模型在查询数据方面性能非常卓越。可惜人无完人,方法也是!这种模型在进行数据插入方面可就不能直接一条SQL搞定了。

还是以向A2.1添加一个A2.1.1节点为例。因为这种模型下树初始化后节点的左右值是连续的,所以如果想要添加一个节点就必须先给新节点腾出一个空位来。根据左右值的编码规则我们可以推算出新节点A2.1.1的左值应该是7、右值应该是8。如果要腾出这样一个位置的话,就必须将A2.1、A2、A、Root节点的右值加2,将B、C、C1、C2的左值和右值都加2。SQL如下:

update tree
    set rightValue = rightValue+2
    where leftValue <= a21.leftValue
        and rightValue >= a21.rightValue;
update true
    set leftValue = leftValue + 2,rightValue = rightValue + 2
    where leftValue > a21.rightValue;

这种就为新节点A2.1.1空出了一个位置,接下来再使用insert语句将A2.1.1插入即可。由于需要更新A2.1所以右值节点和父节点的所以大一颗比较大的数下插入一个新节点性能消耗还是比较大的。

3.4 移动节点

使用先根遍历数模型移动节点位置和插入新节点的方法基本相同,也是需要先在新位置为节点空出一个位置,然后再将节点的左右值修改为新位置的左右值。

总结

两种树的存储模型以及它们分别实现树的常用操作方法就介绍完成了,可以发现这两种模型各有优缺点。

邻接表模型插入新节点、移动节点位置性能非常好,但是要进行复杂的树查询性能就很糟糕了。
先根遍历树模型在查询方面性能突出,但是在插入新节点和移动节点时虽然SQL也只有两三条但是在一个大表下考虑到数据量以及索引重建性能还是会有问题。
所以,世界上没有最好的语言更没有最好的方法!只有根据自己的业务特点选择最合适存储模型才是最佳实践,如果对树的操作更多是查询应该使用先根遍历树模型;如果对树的写操作比较多则应该使用邻接表模型。当然也可以将两种模型同时使用,插入的时候使用邻接表模型然后在后台重建左右值结构,查询的时候使用先根遍历树模型。结合现代的新技术还可以分析树的规模,如果树的规模肯定不会很大则还可以使用redis存储JSON对象的方式来进行存储。总之一句话:首先分析业务场景,再根据业务场景选择最合适的方法!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 1 序 2016年6月25日夜,帝都,天下着大雨,拖着行李箱和同学在校门口照了最后一张合照,搬离寝室打车去了提前租...
    RichardJieChen阅读 10,610评论 0 12
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 135,404评论 19 139
  • 分布式系统面临的第一个问题就是数据分布,即将数据均匀地分布到多个存储节点。另外,为了保证可靠性和可用性,需要将数据...
    olostin阅读 10,138评论 2 26
  • 树的概述 树是一种非常常用的数据结构,树与前面介绍的线性表,栈,队列等线性结构不同,树是一种非线性结构 1.树的定...
    Jack921阅读 9,934评论 1 31
  • 在大家通常的印象里,旅游景点一般都规模宏大,小一点的,起码也是一家店铺,一间餐厅或者一尊雕塑。而美国这座小镇最大的...
    旅行者镜头阅读 2,325评论 0 0