无限深度章节树如何一遍建成?路径编码+单调栈
问题背景
在文档解析场景中,我们拿到的是一份扁平的文本流------段落、表格、图片、章节标题混杂在一起,顺序排列。但用户最终需要的是一棵结构清晰的章节树:第一章下有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) | 无 | 低 |
总结
这套算法组合的核心价值:
- 一遍遍历建树:O(n) 时间,每个节点只进出栈一次
- O(1) 获取父节点:栈顶永远是对的,无需回溯
- 无冗余存储:层级推导计算,不占字段
- 任意层级支持:不用预知最大深度,动态调整
- 查询友好:路径支持前缀匹配,子树查询一条 SQL
设计选择本质上是权衡。我们选择用路径字符串承载层级信息,换取查询效率和维护简便。这套方案在文档解析场景表现稳定,十层深的章节树也能高效处理。