数据结构:评论盖楼设计,支持无限层级

本文介绍了如何使用左右值法优化树形结构数据的查询和操作,如增删节点、查找祖先和后代节点。这种方法通过两次查询即可获取所需数据,提升查询效率。同时,文章给出了在评论、用户裂变等场景下的应用,并提供了数据库表结构和示例代码。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第一部分:问题抛出

常见的设计,采用标准的树形结构,每个结点记录父ID(pid)。利用pid查询子集时,一次只能查询出一层,查询多层时,逻辑代码将会非常繁琐,而且无法一次查询出子集的数量等等,另外多次查询效率不高。

推荐一种数据结构,大部分情况两次查询,可找出所有需要的数据,减少查询次数,提升性能,本质也是树。可应用于评论用户裂变地域分类等,可以支持无限层级

第二部分:原理介绍
基本结构

在这里插入图片描述
每个节点有三个属性,节点名称、左属性、右属性。其中左属性右属性为关键属性。

public class Node {
    private String name;
    private int lft;
    private int rght;
}
规律
  1. 祖先节点的左 < 后代节点的左;祖先节点的右 > 后代节点的右
  • 节点(I)左右分别是6、7,其父节点(E)的左右分别是5、8;
  • 节点(C)左右分别是12、17,其子节点(G)左右分别是13、14,子节点(H)是15、16;
  • 节点(B)左右为2、11,其后代节点(D、E、F、I)中最小的左是3,最大的右是10;
  • 节点(A)左右为1、18,其后代节点(B、C、D、E、F、G、H、I)中最小的左是2,最大的右是17;
  1. 同一节点右左之差,除以2并向下取整,即为后代节点数量
  • 节点(I)的后代节点数量 = ( 7 - 6 ) / 2 = 0;
  • 节点(E)的后代节点数量 = ( 8 - 5 ) / 2 = 1;
  • 节点(B)的后代节点数量 = ( 11 - 2 ) / 2 = 4;
  • 节点(A)的后代节点数量 = ( 18 - 1 ) / 2 = 8;
找后代节点
  • 如找节点(E)的后代节点,条件:左 > 5 AND 右 < 8,结果是节点(I);
  • 如找节点(B)的后代节点,条件:左 > 2 AND 右 < 11;结果是结点(D、E、F、I);
  • 如找节点(A)的后代节点,条件:左 > 1 AND 右 < 18;结果是结点(B、C、D、E、F、G、H、I);
    如果查询的结果也想包含自己,则上述条件换成 >= 和 <=
找祖先节点
  • 如找节点(B)的祖先节点,条件:左 < 2 AND 右 > 11,结果是节点(A);
  • 如找节点(E)的祖先节点,条件:左 < 5 AND 右 > 8;结果是(A、B);
  • 如找节点(I)的祖先节点,条件:左 < 6 AND 右 > 7;结果是(A、B、E);
    如果查询的结果也想包含自己,则上述条件换成 <= 和 >=
增加节点(单个节点)

假设在节点(E)下增加子节点(J),注意只能加到最后(可以理解为,他最晚出生,他是最小的弟弟,其他的都是哥哥)。

  1. 为新增节点(J)设置左右属性,左 = 父节点的右;右 = 左 + 1;
  2. 修改祖先节点的右属性,右 = 右 + 2 WHERE 左 < J(左) && 右 >= J(左),这里涉及到节点(A、B、E);
  3. 修改右侧节点的左右属性,SET 左 = 左 + 2, 右 = 右 + 2 WHERE 左 >= J(右),这里涉及节点(F、G、H、C);
    变化如下:
    在这里插入图片描述

插入已完成,可以尝试着把示例图完整画一遍。注意,除第一个节点(A)的左右手动设置外,其余的节点的都是动态算出来的。示例中左是从1开始,可以使用任何一个自然数,如:-1、3678、7758、10000等等;

删除节点(单个节点)

如上例中,删除结点(D)

  • 步骤如下:
  1. 修改祖先节点的右属性,SET 右 = 右 - 2 WHERE 左 < D(左) AND 右 > D(右),这里涉及到结点(A、B);
  2. 修改右侧节点左右属性,SET 左 = 左 - 2, 右 = 右 - 2 WHERE 左 > D(右),这里涉及节点(E、I、J、F、G、H、C);
    变化如下:
    在这里插入图片描述
物理删除节点(带子节点的节点)

利用递归,先删除子节点,再删除自己。(真实的使用场景中,建议使用逻辑删除,不做物理删除
伪代码如下:

public void del(node) {
	List childs = node.childs() // 包含自己,按左属性降序;
	if(childs != empty) {
	    for;;
	        del(childs[i]);
	    return;
	}
	// 物理删除结点
}
更换节点(单个节点)

先做删除,再做添加,如上图把节点J挂到H下,
伪代码如下:

	del(J);
	add(J, H);
}

上面是基础的增删操作,下面介绍几种复杂的情况,主要还是利用循环或递归,执行删除、添加操作;

更换节点(带子节点)

如把E节点,挂在C下,推荐的做法是先删除节点,再添加;伪代码如下:

	List childs = E.childs(); // 包含自己,注意排序为:[E, I, J]
	
	for (int i = childs.length - 1; i >= 0; i--) { // 注意这里是倒序删除,del(J)、del(I)、del(E)
		del(childs.get(i));
  	}
  
  	// 注意这里是正序添加
  	add(E, C);
  	add(I, E);
  	add(J, E);
添加节点(插入指定位置)

如想把新节点K挂在C下,并且在G和H之间。上面得知,新加的节点,只能加到最后,这种情况的处理,伪代码如下:

	add(K, C);
	del(H);
	add(H, C);
第三部分:扩展

上述介绍了基本原理,在实际使用中,扩展几个属性,使用起来会更方便。以对书的评论为例,建立三张表,书籍表、评论表、评论关系表。这里给出个关系BEAN。

public class Rlat {
    private long id;// 这里仅仅是关系表的主键,没有任何业务意义,与pid没有任何关系
    private long rid;// 根级ID(主体ID),这里关联书籍表ID
    private long pid;// 父级ID(业务ID),这里关联评论表ID
    private long bid;// 业务ID,这里关联评论表ID
    private int lft;// 左
    private int rght;// 右
    private int level; // 层级,当只要N级子集时,方便查询;添加结点是维护该属性;
    private int son; // 儿子的数量,需要时直接带出,无需额外查询,修改子节点时,维护该属性;
    private int child; // 后代的数量,需要时直接带出,无需额外查询,修改子节点时,维护该属性;
    private int seq; // 排序(同级有效)
    private long created;// 创建时间
    private long updated;// 修改时间
    private int del_flg; // 逻辑表示,0-正常;1-删除
    // get/set 略
}

书籍表数据如下:

ID书名
1飞狐外传
2雪山飞狐
3连城诀
4天龙八部
5射雕英雄传

评论表数据如下:

ID评论内容
1飞狐外传不错
2飞狐外传真不错
3飞狐外传真的不错
4天龙八部真好
5天龙八部太好了

评论关系表数据如下:

ID根级ID父级ID业务ID层级儿子数量后代数量
1书籍表ID1--------18013
2书籍表ID1评论表ID1评论表ID127112
3书籍表ID1评论表ID1评论表ID236211
4书籍表ID1评论表ID2评论表ID345300
5书籍表ID4--------16022
6书籍表ID4评论表ID4评论表ID423100
7书籍表ID4评论表ID5评论表ID545100

其中,关系表中ID(1、5)为根数据,即根据书籍表中书籍书籍的数据,每本书应该有且只有一条根数据。新增评论时,应该先判断根数据是否存在,没有则创建。根数据的作用:

  • 方便查询;
  • 保证数据结构的完整;
第四部分:示例

基于上面书籍评论的场景,写了个例子,实现基本的增删查

JDK:jdk-14.0.1
DB:MySQL8.0.21
CREATE TABLE `books` (
	`id` INT(10) NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(20) NULL DEFAULT '' COLLATE 'utf8mb4_general_ci',
	`created` BIGINT(19) NULL DEFAULT '0' COMMENT '创建时间',
	`updated` BIGINT(19) NULL DEFAULT '0' COMMENT '修改时间',
	`del_flg` TINYINT(1) NULL DEFAULT '0' COMMENT '逻辑标识,0-正常;1-删除;',
	PRIMARY KEY (`id`) USING BTREE
)
COMMENT='书籍表'
COLLATE='utf8mb4_general_ci'
ENGINE=InnoDB
;
CREATE TABLE `comments` (
	`id` INT(10) NOT NULL AUTO_INCREMENT,
	`bid` INT(10) NOT NULL DEFAULT '0' COMMENT '书籍ID',
	`content` VARCHAR(200) NULL DEFAULT '' COMMENT '评论内容' COLLATE 'utf8mb4_general_ci',
	`created` BIGINT(19) NULL DEFAULT '0' COMMENT '创建时间',
	`updated` BIGINT(19) NULL DEFAULT '0' COMMENT '修改时间',
	`del_flg` TINYINT(1) NULL DEFAULT '0' COMMENT '逻辑标识,0-正常;1-删除;',
	PRIMARY KEY (`id`) USING BTREE
)
COMMENT='评论表'
COLLATE='utf8mb4_general_ci'
ENGINE=InnoDB
;
CREATE TABLE `rlats` (
	`id` INT(10) NOT NULL AUTO_INCREMENT,
	`rid` INT(10) NOT NULL DEFAULT '0',
	`pid` INT(10) NOT NULL DEFAULT '0',
	`bid` INT(10) NOT NULL DEFAULT '0',
	`lft` INT(10) NOT NULL DEFAULT '0',
	`rght` INT(10) NOT NULL DEFAULT '0',
	`son` INT(10) NOT NULL DEFAULT '0',
	`child` INT(10) NOT NULL DEFAULT '0',
	`level` INT(10) NOT NULL DEFAULT '0',
	`seq` INT(10) NOT NULL DEFAULT '0',
	`created` BIGINT(19) NULL DEFAULT '0' COMMENT '创建时间',
	`updated` BIGINT(19) NULL DEFAULT '0' COMMENT '修改时间',
	`del_flg` TINYINT(1) NULL DEFAULT '0' COMMENT '逻辑标识,0-正常;1-删除;',
	PRIMARY KEY (`id`) USING BTREE
)
COMMENT='评论表'
COLLATE='utf8mb4_general_ci'
ENGINE=InnoDB
;

核心方法:

    public void save(long bookId, long pid, String content) throws Exception {

        var now = System.currentTimeMillis();

        var dao = new Dao();

        var helper = DBHelper.build(URL, USER, PASSWORD);

        var bid = dao.insertComment(helper, bookId, content);

        var root = dao.getRlatRootByRid(helper, bookId);

        if (root == null) {
            root = dao.getRalt(helper, dao.insertRlatForRoot(helper, bookId));
        }

        var parent = dao.getRlatByBid(helper, pid);

        if (parent == null) {
            parent = root;
        }

        var rlat = new Rlat();
        rlat.setRid(bookId);

        if (pid == 0) {
            rlat.setPid(bid);
        } else {
            rlat.setPid(pid);
        }

        var lft = parent.getRght();
        var rght = lft + 1;

        rlat.setBid(bid);
        rlat.setLft(lft);
        rlat.setRght(rght);
        rlat.setSon(0);
        rlat.setChild(0);
        rlat.setLevel(parent.getLevel() + 1);
        rlat.setCreated(now);
        rlat.setUpdated(now);
        rlat.setDel_flg((byte) 0);

        dao.insertRlat(helper, rlat);

        dao.updateRlatForAncestor(helper, bookId, lft);
        dao.updateRlatForParent(helper, bookId, lft, rght);
        dao.updateRlatForRght(helper, bookId, rght);
    }
    public void del(long id) throws Exception {

        var dao = new Dao();

        var helper = DBHelper.build(URL, USER, PASSWORD);

        dao.deleteComment(helper, id);

        var rlat = dao.getRlatByBid(helper, id);

        dao.updateRlatAncestorForDelNode(helper, rlat.getRid(), rlat.getLft(), rlat.getRght());
        dao.updateRlatParentForDelNode(helper, rlat.getRid(), rlat.getLft(), rlat.getRght(), rlat.getLevel() - 1);
        dao.updateRlatRghtForDelNode(helper, rlat.getRid(), rlat.getRght());

        dao.deleteRalt(helper, rlat.getId());
    }

源码地址:https://github.com/xiaojianhx/demo-tree

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值