无限深度章节树如何一遍建成?路径编码+单调栈

无限深度章节树如何一遍建成?路径编码+单调栈

问题背景

在文档解析场景中,我们拿到的是一份扁平的文本流------段落、表格、图片、章节标题混杂在一起,顺序排列。但用户最终需要的是一棵结构清晰的章节树:第一章下有1.1、1.2节,1.1节下可能还有更细的小节。

核心挑战是:如何在遍历一遍文本流的过程中,把这棵树建出来?

最直观的想法是递归:遇到章节标题,递归处理其子内容。但文档可能有几十层深度,递归的调用栈不可控。另一种想法是存储冗余字段:在每个节点存 level 字段,表示它的层级深度。但层级变了,所有子节点的 level 都要更新,维护成本高。

我们最终采用的方案是:路径编码 + 单调栈。两个算法配合,一遍遍历完成建树,查询效率 O(1),层级信息可推导无需存储。


数据结构设计

节点实体

java 复制代码
/**
 * 章节节点实体
 * 存储:id, documentId, parentId, title, path
 * 不存储:level(由 path 推导)
 */
public class ChapterNode {
    
    /** 主键 ID */
    private Long id;
    
    /** 关联文档 ID */
    private String documentId;
    
    /** 父节点 ID(0 表示根节点) */
    private Long parentId;
    
    /** 章节标题 */
    private String title;
    
    /** 可排序路径(如 "/1/2/3") */
    private String path;
    
    /** 内容哈希(用于增量更新检测) */
    private String contentHash;
    
    /** 状态:pending/processing/done */
    private String status;
    
    /** 章节来源类型 */
    private String chapterSource;
    
    /** 删除标记:0-未删除,1-已删除 */
    private Integer deleted;
    
    // createTime, updateTime 等时间字段省略...
}

关键设计决策

字段 存储策略 原因
level 不存储 path.split("/").length - 1 推导
path 存储 层级信息载体,支持排序和前缀查询
parentId 存储 快速定位父节点,双保险

算法一:路径编码

设计思路

路径格式:父路径 + "/" + 序号

arduino 复制代码
"/"           → 根节点
"/1"          → 根节点下的第1个章节
"/2"          → 根节点下的第2个章节
"/1/1"        → "/1" 的第1个子章节
"/1/2"        → "/1" 的第2个子章节
"/1/1/1"      → "/1/1" 的第1个子章节

路径生成代码

java 复制代码
/**
 * 创建章节节点
 * 
 * @param documentId    文档 ID
 * @param header        章节标题项(来自解析器)
 * @param ancestorStack 祖先栈(单调栈)
 * @param nodeIndex     节点序号计数器(全局递增)
 * @return 章节节点实体
 */
private ChapterNode createChapterNode(String documentId,
                                      SectionHeaderItem header,
                                      Deque<ChapterNode> ancestorStack,
                                      int[] nodeIndex) {
    
    // === 1. 生成章节路径 ===
    // 格式:父路径 + "/" + 序号
    // 例如:"/" + "/" + 1 = "/1"
    //       "/1" + "/" + 2 = "/1/2"
    String path = null;
    if (ancestorStack.peek() != null) {
        path = ancestorStack.peek().getPath() + "/" + nodeIndex[0]++;
    }
    
    // === 2. 获取父节点 ID ===
    Long parentId = null;
    if (ancestorStack.peek() != null) {
        parentId = ancestorStack.peek().getId();
    }
    
    // === 3. 使用 Builder 创建节点 ===
    return ChapterNode.builder()
            .documentId(documentId)
            .parentId(parentId)
            .title(header.getText())
            .path(path)
            .status("PENDING")
            .deleted(0)
            .build();
}

为什么这样设计

1. 唯一标识

每个节点有唯一路径,即使标题相同,路径也不同。

2. 层级可推导

java 复制代码
/**
 * 从 path 推导层级深度
 * 
 * @param path 章节路径(格式:/1/2/3)
 * @return 层级深度(从 0 开始)
 * 
 * 示例:
 * - "/" → 0(根节点)
 * - "/1" → 1
 * - "/1/1" → 2
 * - "/1/1/1" → 3
 */
public static int calculateLevel(String path) {
    if (path == null || path.isEmpty() || path.equals("/")) {
        return 0;
    }
    // 统计 "/" 分隔后的段数,减去开头的空段
    return path.split("/").length - 1;
}

3. 排序友好

路径字典序 = 文档阅读顺序。查询直接 ORDER BY path

4. 子树查询高效

sql 复制代码
-- 查询某章节的所有子孙
SELECT * FROM chapter_node 
WHERE document_id = ? AND path LIKE '/1/%' AND deleted = 0;

-- 查询某章节的直接子节点
SELECT * FROM chapter_node 
WHERE document_id = ? AND parent_id = ? AND deleted = 0;

算法二:单调栈

设计思路

遍历文本流时,章节标题可能随时跳层级。单调栈的设计目标:栈中始终保存从根到当前章节的完整祖先链

核心实现

java 复制代码
/**
 * 更新祖先栈(单调栈维护)
 * 
 * 规则:
 * 1. 栈顶层级 < 新章节层级 → 不弹出,新章节是栈顶的子章节
 * 2. 栈顶层级 ≥ 新章节层级 → 弹出,新章节是栈顶的兄弟或叔叔
 * 3. 栈大小 = 1 → 停止,保留根节点
 * 
 * @param stack      祖先栈
 * @param newChapter 新章节节点
 */
private void updateAncestorStack(Deque<ChapterNode> stack, 
                                  ChapterNode newChapter) {
    
    // 1. 计算新章节的层级
    int currentLevel = calculateLevel(newChapter.getPath());
    
    // 2. 循环弹出层级 ≥ 新章节的节点
    while (stack.size() > 1) {  // 保留根节点
        ChapterNode top = stack.peek();
        int topLevel = calculateLevel(top.getPath());
        
        if (topLevel >= currentLevel) {
            // 弹出:新章节是栈顶的兄弟或更低层级
            stack.pop();
        } else {
            // 找到正确的父节点
            break;
        }
    }
    
    // 3. 新章节入栈
    stack.push(newChapter);
}

完整遍历流程

java 复制代码
/**
 * 从 DoclingDocument 持久化章节树
 * 
 * @param documentId      文档 ID
 * @param doclingDocument Docling 解析结果
 * @param chapterSource   章节来源
 */
public void persistFromDocling(String documentId,
                                DoclingDocument doclingDocument,
                                String chapterSource) {
    
    // === 1. 删除旧数据(幂等) ===
    chapterNodeService.deleteByDocumentId(documentId);
    
    // === 2. 创建根节点 ===
    ChapterNode root = createRootNode(documentId, chapterSource);
    chapterNodeService.insert(root);
    
    // === 3. 初始化遍历变量 ===
    Deque<ChapterNode> ancestorStack = new LinkedList<>();
    ancestorStack.push(root);  // 根节点入栈
    
    ChapterNode currentChapter = null;      // 当前章节(用于累积文本)
    StringBuilder currentText = new StringBuilder();  // 文本累积器
    int[] nodeIndex = {1};                  // 全局节点计数器
    int[] blockOrder = {1};                 // 内容块顺序
    
    // === 4. 遍历文本流 ===
    for (BaseTextItem item : doclingDocument.getTexts()) {
        
        // 跳过非 BODY 层
        if (item.getContentLayer() != ContentLayer.BODY) {
            continue;
        }
        
        // 处理章节标题
        if (item instanceof SectionHeaderItem header) {
            
            // 保存上一个章节的文本内容块
            if (currentChapter != null && !currentText.isEmpty()) {
                saveTextBlock(currentChapter.getId(), currentText.toString(), 
                              blockOrder[0]++);
            }
            
            // 创建新章节节点
            currentChapter = createChapterNode(documentId, header, 
                                                ancestorStack, nodeIndex);
            chapterNodeService.insert(currentChapter);
            
            // 更新单调栈
            updateAncestorStack(ancestorStack, currentChapter);
            
            // 重置文本累积器
            currentText = new StringBuilder();
            
        } else {
            // 累积普通文本(段落、表格等)
            String text = item.getText();
            if (text != null && !text.isEmpty()) {
                if (currentText.length() > 0) {
                    currentText.append("\n");
                }
                currentText.append(text);
            }
        }
    }
    
    // === 5. 保存最后一个章节的内容块 ===
    if (currentChapter != null && currentText.length() > 0) {
        saveTextBlock(currentChapter.getId(), currentText.toString(), 
                      blockOrder[0]);
    }
}

运行示例

文档结构:

scss 复制代码
根节点 (level 0)
├── 第一章 (level 1)
│   ├── 1.1 节 (level 2)
│   └── 1.2 节 (level 2)
└── 第二章 (level 1)

栈的变化过程:

ini 复制代码
初始:[根"/"]

遇到"第一章" level=1:
  栈顶 level=0 < 1 → 不弹出
  入栈 → [根"/", 第一章"/1"]

遇到"1.1节" level=2:
  栈顶 level=1 < 2 → 不弹出
  入栈 → [根"/", 第一章"/1", 1.1节"/1/1"]

遇到"1.2节" level=2:
  栈顶 level=2 ≥ 2 → 弹出 1.1节
  栈顶 level=1 < 2 → 停止
  入栈 → [根"/", 第一章"/1", 1.2节"/1/2"]

遇到"第二章" level=1:
  栈顶 level=2 ≥ 1 → 弹出 1.2节
  栈顶 level=1 ≥ 1 → 弹出 第一章
  栈顶 level=0 < 1 → 停止
  入栈 → [根"/", 第二章"/2"]

关键点 :栈顶永远是正确的父节点,peek() 即可,O(1) 复杂度。


算法三:层级推导

实现代码

java 复制代码
/**
 * 计算章节层级
 * 独立工具方法,可在任意位置调用
 */
public static int calculateLevel(String path) {
    if (path == null || path.isEmpty() || path.equals("/")) {
        return 0;
    }
    return path.split("/").length - 1;
}

/**
 * 在聚合对象中按需计算层级
 */
public class ChapterAggregate {
    private ChapterNode node;
    
    /**
     * 动态获取层级(不存储)
     */
    public Integer getLevel() {
        if (node != null && node.getPath() != null) {
            return calculateLevel(node.getPath());
        }
        return null;
    }
}

为什么不存储 level 字段

问题 说明
数据冗余 level 已编码在 path,存两份浪费
更新传播 移动节点需改 path + level,子节点也要改
一致性风险 path 和 level 可能不一致,多一个出错点

不存储的好处:

好处 说明
单一数据源 层级只存在于 path,无不一致可能
移动简单 只改 path 和 parentId,其他推导
计算成本低 split 在短字符串上极快

简要数据库表结构

sql 复制代码
CREATE TABLE chapter_node (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    document_id     VARCHAR(64) NOT NULL COMMENT '文档ID',
    parent_id       BIGINT DEFAULT 0 COMMENT '父节点ID,0表示根节点',
    title           VARCHAR(512) COMMENT '章节标题',
    path            VARCHAR(255) NOT NULL COMMENT '可排序路径,如/1/2/3',
    content_hash    VARCHAR(64) COMMENT '内容SHA256哈希',
    status          VARCHAR(32) DEFAULT 'PENDING' COMMENT '状态',
    chapter_source  VARCHAR(64) COMMENT '章节来源类型',
    deleted         TINYINT DEFAULT 0 COMMENT '删除标记',
    create_time     DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time     DATETIME ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_document_id (document_id),
    INDEX idx_parent_id (parent_id),
    INDEX idx_path (path)
) COMMENT '章节节点表';

索引设计

索引 用途
idx_document_id 按文档查询章节树
idx_parent_id 查询直接子节点
idx_path 前缀查询子树、排序

复杂度分析

时间复杂度

操作 复杂度 说明
建树 O(n) 一遍遍历,每个节点常数时间
获取父节点 O(1) 栈顶 peek()
计算层级 O(k) k 是路径深度,通常 < 10
子树查询 O(1)* LIKE 查询,索引命中

空间复杂度

数据结构 复杂度 说明
单调栈 O(h) h 是最大层级深度,与节点数无关
路径存储 O(h) 每节点存一个路径字符串

方案对比

方案 建树 查询父节点 存储 维护
递归 O(n) 不可控 需回溯
存 level O(n) O(1) level 字段 高(更新传播)
路径编码+单调栈 O(n) 可控 O(1)

总结

这套算法组合的核心价值:

  1. 一遍遍历建树:O(n) 时间,每个节点只进出栈一次
  2. O(1) 获取父节点:栈顶永远是对的,无需回溯
  3. 无冗余存储:层级推导计算,不占字段
  4. 任意层级支持:不用预知最大深度,动态调整
  5. 查询友好:路径支持前缀匹配,子树查询一条 SQL

设计选择本质上是权衡。我们选择用路径字符串承载层级信息,换取查询效率和维护简便。这套方案在文档解析场景表现稳定,十层深的章节树也能高效处理。

相关推荐
大东_tom1 小时前
Python 与 PyTorch 工程实践——从脚本到训练框架
后端
Joose都有人用1 小时前
我把一个「九成正常、一成作恶」的风控难题做稳了,聊聊踩过的坑
后端
她说彩礼65万3 小时前
Asp.net core返回类型总结
后端·asp.net
用户6757049885023 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第二章:发布之sshPublisher)
后端·jenkins
用户6757049885024 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第一章:部署)
后端·jenkins
一路向北North4 小时前
Spring Security OAuth2.0(20):完善环境配置
java·后端·spring
从零开始的代码生活_4 小时前
C++ string 详解:常用接口、字符串算法与深拷贝实现
开发语言·c++·后端·学习·算法
65岁退休Coder5 小时前
LangChain v1.3.4 笔记 - 01 模型的连接/输出、消息、提示词模板
后端
笃行3505 小时前
数据库优化深度科普向|外连接消除底层原理,国产数据库优化器设计思路拆解
后端