分类树设计(邻接表、路径枚举、嵌套集、闭包表、混合方案)

2025博客之星年度评选已开启 10w+人浏览 2.4k人参与

分类树设计(邻接表、路径枚举、嵌套集、闭包表、混合方案)

  通过这篇文章你将了解到分类树设计时要考虑的问题,常见的设计方案,如邻接表、路径枚举、嵌套集、闭包表、混合方案等,包括每个方案的存储设计、代码示例、优缺点分析等,以及这些设计方案的演化过程。


—— 2025 年 9 月 7 日 乙巳年七月十六 白露
扫码关注微信公众号   了解最新最全文章

qrcode

前言:

  分类树是某些系统中的核心模块,尤其像淘宝/京东等电商系统。其是整个系统的组织架构、导航体系和基础设施。对用户来讲,其便于快速浏览或检索商品,降低了决策成本;同时,系统可通过分析用户历史数据来构建用户画像,从而使用智能推荐来提高用户满意度。对平台来讲,其便于商品的管理,更易于进行营销活动。本文将从设计分类树时要考虑的问题、常见的设计方案及其演进过程等方面来讨论如何设计分类树。

1 设计时要考虑的问题

  设计分类树时可能需要考虑以下几个问题:

  • 层级:这是分类树最重要的一个特点,层级或树的深度会直接影响系统的性能、可扩展性和可维护性。虽然在通常业务场景下不会超过 3 级或 4 级,但站在设计者的角度,我们总是希望尽最大可能满足系统的非功能性需求,所以一般情况下会设计为无限极。
  • 存储:分类树的存储结构如何设计,才能满足系统的性能、可扩展性和可维护性,同时亦可降低系统复杂度。
  • 维护:如增加、删除、修改和移动等操作,考虑到分类树维护频率较低,故对其性能要求也较低。
  • 查询:分类树被使用最多的操作,如查询整棵树、子节点、父节点和同级节点等,同时对查询性能可能要求较高。

2 常见设计方案

  常见的分类树设计方案及其特点和适用场景如下:

category-design

  接下来通过详细介绍上述各种设计方案,并以下图所示分类树结构为例,来描述分类树设计的演进过程。

category-tree

3 邻接表

  邻接表特点是每个节点都记录其父节点的ID,是最简单直观的设计,是最早期最常见的分类树设计方案。

3.1 存储设计

  分类表 category ,建议为 parent_id 字段建立索引,表结构为:

字段名字段类型字段描述备注
idBIGINT主键自增
parent_idBIGINT父节点 ID0 表示根节点 默认值 0
nameVARCHAR名称
sort_orderINT排序同层内排序 默认值 0
levelINT层级

  示例数据为:

idparent_idnamesort_orderlevel
10电子产品01
21手机02
31电脑12
42华为03
52苹果13
63笔记本03
73台式机13

3.2 常用操作

3.2.1 查询直接子节点
# 查询指定节点的直接子节点 简单高效
select * from category where parent_id = 2;
3.2.2 查询所有子节点

  若 mysql 版本为 8.0+ 则可使用递归公共表达式 CTE:

with recursive sub_tree as (
	-- 初始查询(即指定节点)
  union all
  -- 递归查询(即指定节点的子节点)
)
select * from sub_tree;
# 如查询指定节点 '手机' 的所有子节点
with recursive sub_tree as (
	select * from categtory where id = 2
  union all
  select c.* from category c inner join sub_tree st on c.parent_id = st.id
)
select * from sub_tree where id != 2;

  注:CTE 表达式在大数据量情况下会有性能瓶颈,可通过对 parent_id 字段建立索引来提高查询性能,但随着数据量的增长,依然会有性能瓶颈。

  若 mysql 版本为 5.7 及以下或你使用的数据库不支持 CTE,则需要在应用层递归。如下:

// 先获取所有数据 然后在内存中递归处理 伪代码如下:

public List<Category> buildTree(long parentId) {
  	// 获取所有节点记录
  	List<Category> list = categoryMapper.selectList(new LambdaQueryWrapper<Category>());
  	List<Category> tree = new ArrayList<>();
  	for (Category parent : getFirstLevelChildren(parentId, list)) {
    		tree.add(buildChildTree(parent, list));
  	}

  	return tree;
}

// 获取指定节点的一级子节点
public List<Category> getFirstLevelChildren(long parentId, List<Category> list) {
  	return list.stream().filter(item -> Objects.equals(parentId, item.getParentId()))
    		.collect(Collectors.toList());
}

// 构建指定节点的子树
public Category buildChildTree(Category parent, List<Category> list) {
  	List<Category> children = new ArrayList<>();
  	for (Category child : list) {
    		if (Objects.equals(parent.getId(), child.getParentId())) {
      			children.add(buildChildTree(child, list));
    		}
  	}

  	parent.setChildren(CollectionUtils.isEmpty(children) ? null : children);
  	return parent;
}
3.2.3 查询节点路径

  查询节点路径有两种方案可选,一是递归访问数据,二是递归处理数据(类似于 查询所有子节点 操作中的第二种方案)。其中第一种方案伪代码如下:

List<Category> path = new ArrayList<>();

// 获取指定节点
Category current = categoryMapper.selectById(categoryId);

// 递归获取父节点
while (current != null && current.getParentId() != 0) {
  	current = categoryMapper.selectById(current.getParentId());
  	if (current != null) {
    		path.add(0, current);
  	}
}

3.3 优缺点分析

3.3.1 优点
  • 设计简单、容易理解;
  • 增加、删除、修改和移动操作简单;
  • 占用存储空间小;
3.3.2 缺点
  • 查询子树需要递归或多次查询,性能差;
  • 查询路径需要递归或多次查询,性能差;
  • 高并发或大数据量情况下数据库压力大;
3.3.3 适用场景
  • 层级固定或较浅(如 1 ~ 3 层);
  • 写多读少;
  • 小型项目,数据量 < 1万;

4 路径枚举

  路径枚举是指每个节点都存储根节点到当前节点的完整路径,通常为 path 字段。解决了邻接表的递归查询问题,是邻接表的第一次重要改进。

4.1 存储设计

  分类表 category ,建议为 path 字段建立索引,表结构为:

字段名字段类型字段描述备注
idBIGINT主键自增
parent_idBIGINT父节点 ID0 表示根节点 默认值 0
nameVARCHAR名称
pathVARCHAR路径/ 拼接
sort_orderINT排序同层内排序 默认值 0
levelINT层级

  示例数据为:

idparent_idnamepathsort_orderlevel
10电子产品101
21手机1/202
31电脑1/312
42华为1/2/403
52苹果1/2/513
63笔记本1/3/603
73台式机1/3/713

4.2 常用操作

4.2.1 查询所有子节点
# 查询所有子节点 直接 LIKE 即可 如查询指定节点 '手机' 的所有子节点
select * from category where path like '1/2/%';
4.2.2 查询节点路径
# 获取到 path 字段后在应用层分割即可
select path from category where id = 2;
4.2.3 移动节点
# 移动节点需要更新所有子节点的路径 如将 '5 苹果' 节点从 '2 手机' 节点移动到 '3 笔记本' 节点下

-- 获取要移动的所有节点
select * from category where path like '1/2/5%';

-- 然后将获取到的所有记录的 path 值修改为 1/3/5 开头即可

4.3 优缺点分析

4.3.1 优点
  • 查询子树或路径操作简单速度快;
  • 避免了递归查询;
  • 相比邻接表读性能大幅提升;
4.3.2 缺点
  • 路径字段会占用较大存储空间;
  • 路径字段的长度限制会限制树的深度;
  • 移动节点时需要更新所有子节点的路径字段;
4.3.3 适用场景
  • 读多写少或移动操作少;
  • 子树或路径查询频率高;
  • 树深度有限(建议 <= 10层);
  • 中型项目,数据量约 1万 ~ 10万;

5 嵌套集

  依据树的前序遍历,通过使用节点的左右值来表示节点在树中的位置关系,是一种数学化的设计,避免了递归查询。

5.1 核心原理

  其核心原理是通过前序遍历来给每个节点分配/计算左值 left 和右值 right,注意,左右值是节点在树中的顺序或位置值,和节点ID没关系。

category-tree-left-right

5.2 存储设计

  分类表 category ,建议为 leftrightleft & right 建立索引,表结构为:

字段名字段类型字段描述备注
idBIGINT主键自增
nameVARCHAR名称
leftINT左值
rightINT右值
sort_orderINT排序同层内排序 默认值 0
levelINT层级

  示例数据为:

idnameleftrightsort_orderlevel
1电子产品11401
2手机2702
3电脑81312
4华为3403
5苹果5613
6笔记本91003
7台式机111213

5.3 常用操作

5.3.1 查询所有子节点
# 如查询 '手机' 节点的所有子节点
select c2.* from category c1, category c2
where c1.id = 2
	and c2.left > c1.left
	and c2.right < c1.right;
5.3.2 查询节点路径
# 如查询 '华为' 节点的路径
select c1.* from category c1, category c2
where c2.id = 4
	and c1.left < c2.left
	and c1.right > c2.right;
5.3.3 查询所有叶子节点
select * from category where right = left + 1;
5.3.4 统计某节点的叶子节点数量
# 如统计 '手机' 节点的叶子节点数量
select (right - left - 1) / 2 as childTotal
from category where id = 2;
5.3.5 增加节点
// 增加节点需要更新大量左右值(因为要给新插入节点的左右值腾出空间 即右移插入位置右侧的所有节点的左右值)

// 如在节点 '手机' 下插入新节点 '诺基亚' 伪代码如下:

// 1、获取待插入节点的父节点
Category parent = categoryMapper.selectById(parentId);
int newLeft = parent.getRight();   // 父节点的右值即为新节点的左值

// 2、将所有左值 >= parent.getRight() 的节点的左值 +2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
                     .setSql("left = left + 2")
                     .ge(Category::getLeft, newLeft));

// 3、将所有右值 >= parent.getRight() 的节点的右值 +2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
                     .setSql("right = right + 2")
                     .ge(Category::getRight, newLeft));

// 4、插入新节点
newCategory.setLeft(newLeft);
newCategory.setRight(newLeft + 1);
categortMapper.insert(newCategory);

增加后效果如下图所示;

category-tree-add

5.3.6 删除节点
# 删除节点后要更新左右值

-- 如删除 '诺基亚' 节点(假设其ID为 8)
delete from category where id = 8

-- 将所有左值 > 7 的节点的左值 -2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
                     .setSql("left = left - 2")
                     .gt(Category::getLeft, 7));

-- 将所有右值 > 8 的节点的右值 -2
categoryMapper.update(null, new LambdaUpdateWrapper<Category>()
                     .setSql("right = right - 2")
                     .gt(Category::getRight, 8));
5.3.7 移动节点

  移动节点是嵌套集方案中最复杂的操作,需要重新计算整个子树的左右值,故不推荐频繁移动节点。通常做法为:

  • 1、记录要移动的所有子树(即要移动的所有节点);
  • 2、删除要移动的所有子树(即删除要移动的所有节点);
  • 3、在新位置插入节点(删除再插入是为了更新左右值);

5.4 优缺点分析

5.4.1 优点
  • 查询子树和路径极快;
  • 统计节点数量效率高;
  • 查询性能优于路径枚举;
5.4.2 缺点
  • 增加、删除和移动节点操作极其复杂;
  • 维护时需要更新大量节点的左右值,增加一个节点,可能需要更新几千个节点的左右值,且在高并发场景下容易产生死锁;
  • 无法快速查询直接子节点,当然可以通过冗余 parent_id 字段来实现,但在维护时需要额外计算父节点的左右值。如何取舍,全在你;
5.4.3 适用场景
  • 只读或极少修改;
  • 查询性能要求极高;
  • 数据量不是特别大(< 5万);

6 闭包表

  用额外一张表单独维护了所有节点之间的 祖先-后代 关系(包括自身),采用空间换时间的策略解决了前面所有方案的痛点,是目前大型系统最推荐的方案。

6.1 存储设计

  分类表 category ,建议为 parent_id 字段建立索引,表结构为:

字段名字段类型字段描述备注
idBIGINT主键自增
parent_idBIGINT父节点 ID0 表示根节点 默认值 0
nameVARCHAR名称
sort_orderINT排序同层内排序 默认值 0
levelINT层级

  示例数据为:

idparent_idnamesort_orderlevel
10电子产品01
21手机02
31电脑12
42华为03
52苹果13
63笔记本03
73台式机13

  闭包关系表 category_colsure,建议为 descendantdepth 字段建立索引,表结构为:

字段名字段类型字段描述备注
idBIGINT主键自增
ancestorBIGINT祖先
descendantBIGINT后代
depthINT深度层级差

  如果有 n 个节点,其闭包表的理论记录数约为:n + n * (n - 1) / 2 ,但实际上为:n * (h + 1) ,h 为平均深度。假设有 10万 条节点,平均深度为 5层,则其闭包关系约为 60万 条记录。

  示例数据为:

idancestordescendantdepth
1110
2121
3131
4142
5152
6162
7172
8220
9241
10251
11330
12361
13371
14440
15550
16660
17770

6.2 常用操作

6.2.1 查询所有子节点
# 如查询 '手机' 节点的所有子节点
select c.* from category c
inner join category_closure cc on c.id = cc.descendant
where cc.ancestor = 2 and cc.depth > 0;
6.2.2 查询直接子节点
# 如查询 '手机' 节点的直接子节点
select c.* from category c
inner join category_closure cc on c.id = cc.descendant
where cc.ancestor = 2 and cc.depth = 1;

# 当然也可以直接使用邻接表字段 parent_id 实现
select * from category where parent_id = 2;
6.2.3 查询节点路径
# 如查询 '华为' 节点的路径(若添加 cc.depth > 0 条件则不包括自己 反之亦然)
select c.* from category c
inner join category_closure cc on c.id = cc.ancestor
where cc.descendant = 4 and cc.depth > 0;
6.2.4 查询子树深度
# 如查询 '手机' 节点子树的最大深度
select max(depth) as maxDepth
from category_closure
where ancestor = 2;
6.2.5 统计子节点数量
# 如统计 '手机' 节点的子节点的数量
select count(*) - 1 as childTotal
from category_closure
where ancestor = 2;
6.2.6 增加节点
# 如增加 '诺基亚' 节点 id 为 8 步骤及伪代码如下:

-- 1、插入分类基础信息 略

-- 2、插入自己到自己的关系
insert into category_closure (ancestor, descendant, depth) values (8, 8, 0);

-- 3、复制父节点的所有祖先关系 sql 示意如下(当然也可在应用层实现)
insert into category_closure (ancestor, descendant, depth)
  select ancestor, 8, depth + 1
  from category_closure
  where descendant = 2;
6.2.7 删除节点

  删除单个节点:

# 如删除 '苹果' 节点
-- 1、检查要删除节点是否存在子节点 即统计子节点数量(若存在则不能删除)
select count(*) - 1 as childTotal
from category_closure
where ancestor = 5;

-- 2、删除该节点的所有关系 即 where descendant = id
delete from category_closure
where descendant = 5;

-- 3、删除基础分类信息
delete from category where id = 5;

  删除子树:

-- 1、先获取所有要删除的节点 id 记为 ids
select descendant from category_closure where ancestor = 2;

-- 2、删除所有关系
delete from category_closure
where descendant in(ids);

-- 3、删除基础分类信息
delete from category where id in (ids);
6.2.8 移动节点
# 如移动 '苹果' 节点到 '电脑' 节点下

-- 1、删除 '苹果' 节点的祖先关系(保留自己到自己的关系)
-- 即删除 (1, 5, 2) (2, 5, 1) 保留 (5, 5, 0)
delete from category_closure
where descendant in (
		select descendant from (
    		select descendant from category_closure where ancestor = 5
    ) as temp
)
and ancestor in (
		select ancestor from (
    		select ancestor from category_closure 
      	where descendant = 5 and ancestor != descendant
    ) as temp2
);

-- 2、插入新的祖先关系
-- 即插入 (1, 5, 2) (3, 5, 1)
-- 即以新父节点的后代作为新关系的祖先 以要移动节点的后代作为新关系的后代
insert into category_closure (ancestor, descendant, depth)
select cc1.ancestor, cc2.descendant, cc1.depth + cc2.depth + 1
from category_closure cc1
cross join category_closure cc2
where cc1.descendant = 3   -- 新父节点
		and cc2.ancestor = 5;   -- 要移动的节点

6.3 优缺点分析

6.3.1 优点
  • 查询操作性能高,时间复杂度为 O(1)O(h)
  • 支持任意深度的树;
  • 对增加、删除、修改和移动操作友好;
6.3.2 缺点
  • 需要额外的存储空间;
  • 增加和移动节点操作较复杂;
  • 需要维护两张表的数据一致性;
6.3.3 适用场景
  • 读写混合;
  • 大型系统;

7 邻接表 + 路径枚举

  即结合了 parent_id 字段与 path 字段所带来的优势。其适用场景为:

  • 实现简单,性价比高;
  • 树层级较浅 <= 6层;
  • 中小型系统,数据量约 1万 ~ 10万 节点;

8 闭包表 + 邻接表 + 路径枚举

  在闭包表的基础上整合了邻接表的 parent_id 与路径枚举的 path 的优势。其适用场景为:

  • 邻接表 parent_id 用于查询直接子节点;
  • 路径枚举 path 用于查询路径或简单 LIKE 查询;
  • 闭包表用于复杂查询;
  • 三者互补,查询灵活;

qrcode

扫码关注微信公众号   了解最新最全文章
内容概要:本文系统阐述了智能物流路径规划的技术体系与实践应用,涵盖其发展背景、核心问题建模、关键算法、多目标与动态环境处理、系统架构及典型应用场景。文章以车辆路径问题(VRP)及其变体为核心数学模型,介绍了从Dijkstra、A*等单智能体算法到多车VRP的元启发式求解方法(如遗传算法、蚁群算法、大规模邻域搜索),并深入探讨了多目标优化(成本、时间、碳排放)与动态环境(实时订单、交通变化)下的自适应规划策略。结合城市配送、干线运输、场内物流等案例,展示了路径规划在提升效率、降低成本方面的实际价值,并分析了当前面临的复杂性、不确定性等挑战,展望了AI融合、数字孪生、车路协同等未来趋势。; 适合人群:具备一定物流、运筹学或计算机基础,从事智能交通、物流调度、算法研发等相关工作的技术人员与管理人员,工作年限1-5年为宜。; 使用场景及目标:①理解智能物流路径规划的整体技术架构与核心算法原理;②掌握VRP建模方法与多目标、动态环境下路径优化的实现策略;③为物流系统设计、算法选型与系统优化提供理论依据与实践参考; 阅读建议:建议结合文中案例与数学模型,重点理解算法选择与实际业务场景的匹配逻辑,关注动态规划与多目标优化的工程实现难点,可配合仿真工具或开源求解器进行实践验证。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

红衣女妖仙

行行好,给点吃的吧!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值