简介:这是一份基于 Python 的 APTED 算法实现资源,面向算法研究与树结构处理开发者,用于高效计算两棵有序树的编辑距离并给出节点映射关系,相较传统 RTED 方案精度与速度更为领先。资源共 20 个文件,以 py 源码为主(14 个),另有 json 配置、说明文档与许可证等,压缩包约 40KB,模块划分清晰,便于集成与二次开发。已有 535 人学习下载。资源实现了括号表示法解析输入,如 {A{B{X}{Y}{F}}{C}} 这样的串可直接生成树结构;输出同时包含最小编辑距离与对应节点映射,覆盖删除、插入及替换操作,适合在语法树比对、程序代码相似度检测、XML/JSON 结构差异分析等场景中调用。代码简洁且测试用例完整,可作为算法学习参考或直接嵌入现有项目使用。
1. 树编辑距离是什么,APTED 算法解决的是哪一类比较问题
把「两棵树有多像」变成一个可计算的数值,这件事的业务价值比字面上看起来大得多。做编译器前端的人要对比两份 AST 找出语法改动;做爬虫和网页结构抽取的人要判断两个 HTML 页面是否同源;做代码克隆检测的人要在仓库里找「换了变量名、换了常量值」的同构代码段。树编辑距离(Tree Edit Distance, TED)就是这套需求的统一抽象:允许对节点做删除、插入、重命名三种操作,每种操作付出对应成本,最小总成本就是两棵树的距离。APTED(A Practical Tree Edit Distance)算法是 TED 家族里在准确率不损失的前提下把性能压到实用的代表,PyPI 上的 apted 包直接可用,核心逻辑纯 Python 实现,不需要编译。本文会按「原理 → 安装 → 定制 → 批量落地」的顺序,把这条链路完整走一遍。
2. APTED 算法原理:路径分解与动态规划剪枝怎么做
2.1 三种操作与成本模型:先定义清楚再谈优化
树编辑距离的问题定义很干净:给定两棵有根有序树 T1、T2,允许对节点做删除、插入、重命名三种操作,每种操作附带一个非负成本,两棵树的距离就是把 T1 变成 T2 所需的最小操作成本总和。删除一个节点时,它的子节点会整体上移接替位置;插入是删除的逆操作;重命名只修改节点标签,不改变树的结构。
成本模型直接决定距离的业务含义。最常见的设定是三种操作各记 1,相当于「最少需要几步能改完」;但如果你的场景里「把函数名从 add 改成 sum」和「把整个函数体删掉重写」不该是同一个代价,就得把重命名成本调高,或者让重命名成本与标签内容的编辑距离挂钩。下面给出几种典型配置的对比。
表 1 树编辑距离成本模型示例
| 场景 | delete/insert 成本 | rename 成本 | 说明 |
|---|---|---|---|
| 通用结构相似度 | 1 | 1 | 经典 TED 默认 |
| AST 变更度量 | 2 | 1 | 增删节点的代价被放大 |
| 代码克隆检测 | 1 | 标签不同时 5 | 强烈惩罚节点类型变化 |
| XML 结构 diff | 0.5 | 1 | 允许少量冗余节点参与距离 |
在 apted 库里,默认成本就是「三种操作各 1」。实际项目里我一般不会直接用默认值,而是先看标签的语义粒度:如果两个节点标签一个是FunctionDef、一个是AsyncFunctionDef,重命名成本设 1 会把这当一次普通改动,设 0.5 则更符合它们是相近语法构造的事实。成本函数不是算法的附属品,它是业务模型的一部分。
2.2 传统 DP 慢在哪:所有子树对都被算了一遍
经典 Zhang-Shasha 算法把问题拆成「删除前根子树」和「删除根节点」两类子问题,用两张动态规划表分别存树距离和森林距离。设 T1 有 n1 个节点、T2 有 n2 个节点,两张表的规模都是 O(n1·n2),每个单元还要扫描左根路径上的祖先做归约,所以总复杂度到 O(n²·depth²),最坏 O(n⁴)。后续改进算法把上限压到 O(n³),但常数因子依然偏大。
瓶颈在哪里?动态规划表里真正决定最终答案的单元往往只占一小部分。Zhang-Shasha 会把所有子树对的 DP 单元都填一遍,其中大量单元与最终最小编辑路径无关,这就是冗余计算的来源。下面用一个简化的 DP 结构来展示四层循环的形态,方便和 APTED 的剪枝思路对比:
# 仅用于展示经典 DP 的嵌套结构 for i in range(1, m + 1): for j in range(1, n + 1): # 反复扫描左根路径上的祖先子树 for p in left_path_ancestors(i): for q in left_path_ancestors(j): dist[p][q] = min( dist[p][q - 1] + ins_cost, dist[p - 1][q] + del_cost, dist[p - 1][q - 1] + ren_cost, )这段代码里内两层循环反复扫描大量与当前子问题无关的祖先节点,等价于对每对子树都重新计算距离。树一旦变大,四层循环的乘数效应会让耗时呈立方级甚至更高上涨。
2.3 APTED 的路径分解:只算真正需要的子问题
APTED 把「整棵树的编辑距离」拆成若干条「路径」上的动态规划,路径指一条从根到叶子的链。具体做法是:每次选一条路径,把路径上的节点连同挂着的子树看作一个整体,只计算这条路径与另一棵树对应路径之间的对齐,并直接复用已经算完的子树结果。这样需要真正展开计算的子问题数量比 Zhang-Shasha 少几个数量级。下表是两种算法在不同节点规模下的相对耗时趋势示意,用来理解量级差异:
表 2 不同节点规模下相对计算时间趋势(示意,非实测)
| 节点数 | Zhang-Shasha 相对耗时 | APTED 相对耗时 |
|---|---|---|
| 100 | 1.0x | 0.4x |
| 300 | 1.0x | 0.2x |
| 500 | 1.0x | 0.08x |
| 1000 | 1.0x | 0.03x |
具体倍数取决于树的分叉形状和标签重复度。树越「瘦长」(深度大、分叉少),APTED 的优势越明显;如果树是接近满二叉树那种「矮胖」形态,两者差距会缩小,但 APTED 一般也不会更慢。空间方面,APTED 只需 O(n²) 的存储,配合延迟释放临时表,峰值内存还能进一步压低。
理解这一层就够了:你不用自己实现路径分解,但你知道库里APTED类每次调用大致在做什么——按策略拆路径、在两条路径之间跑局部 DP、合并已算完的子树结果,而不是在整个树结构上盲目扫描。
3. Python 环境里装好 apted,用文本树跑通第一段代码
3.1 环境准备:venv 隔离、pip 安装与离线下载
不同平台的 Python 安装路径差别很大,但虚拟环境这一步是一致的。我用python3 -m venv建独立环境,避免 apted 依赖污染系统 Python;Windows 上如果没有python3命令,就用python试试,或者先去官网下载安装 Python 并勾选 Add to PATH。
mkdir ted-demo && cd ted-demo python3 -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install --upgrade pip pip install apted安装完成后验证模块能否导入:
python -c "from apted import APTED, Config; from apted.helpers import Tree; print('apted ok')"如果需要在无外网环境离线安装,可以用pip download apted -d ./vendor先把 wheel 包下载到本地,之后在目标机器执行pip install ./vendor/apted-*.whl。apted 是纯 Python 包,没有 C 扩展,所以 wheel 不需要在本机编译,下载下来的包跨平台可用。习惯用 VS Code 写 Python 的话,在项目目录按 Ctrl+Shift+P 执行 “Python: Select Interpreter”,选择.venv里的解释器即可。
3.2 用文本串表示树并计算编辑距离
apted 对树有一种紧凑的文本表示法:花括号{}表示一棵子树,第一个花括号里的字符串是根节点标签,后面以空格分隔的花括号依次是子节点。例如{a{b}{c}}表示根节点 a 有 b、c 两个子节点。下面把这种用法拆开:
from apted.helpers import Tree t1 = Tree.from_text('{a{b{c}}{d}}') t2 = Tree.from_text('{a{b{c}}{e}}') print(t1) print(t2)打印结果会把{a{b{c}}{d}}的完整嵌套结构展示出来,方便确认解析无误。之后用 APTED 类计算距离:
from apted import APTED apted = APTED(t1, t2) dist = apted.compute_edit_distance() print(dist)执行链路是:Tree.from_text做语法解析生成Tree对象,APTED构造器接收两棵树,compute_edit_distance()内部走路径分解流程,最后返回浮点距离。这里 t1 和 t2 只有 d 和 e 两处标签不同,默认成本下距离等于 1,对应一次重命名。
提示:文本格式的标签不能包含空格和花括号,否则解析结果与预期不符。如果节点标签来自真实文本内容,比如 HTML 属性值,就用
Tree(label)对象构造方式,不要走from_text。
表 3 文本树表示法对照
| 文本 | 根节点 | 直接子节点 |
|---|---|---|
{a} | a | 无 |
{a{b}} | a | b |
{a{b}{c}} | a | b, c |
{a{b{c}}} | a | b(b 有一个孩子 c) |
3.3 compute_edit_mapping:拿到最小编辑路径的节点对应关系
有时候光知道「距离是 3」不够,你更想知道哪几个节点被删、哪几个节点被改成什么。compute_edit_mapping()返回的就是这个对应关系:每一项是(u, v)元组,u 来自 T1、v 来自 T2,表示 u 被重命名为 v;v 是 None 表示 u 被删除;u 是 None 表示 v 被插入。
mapping = apted.compute_edit_mapping() for u, v in mapping: if u is None: print(f'INSERT: {v}') elif v is None: print(f'DELETE: {u}') else: print(f'MAP: {u} -> {v}')在我的经验里,compute_edit_mapping()比只算距离多花大约 20%~30% 的时间,因为它要在 DP 表上多走一次回溯。如果业务场景只要批量筛相似度,不需要具体改动点,就只调compute_edit_distance(),不要为了省事每次都算映射,树规模上千后这个差异十分明显。
到这里,最小可用的代码路径已经完整。下一章进入定制,这才是 apted 真正嵌入业务系统的关键。
4. 自定义成本与节点类型:把 APTED 用到 AST 对比
4.1 为什么不能直接把 AST 节点转成字符串
用文本格式给小型语法树算距离很简单,真实 AST 的节点标签复杂得多。同样是Name节点,id 不同语义不同;同样是Constant,值的类型差别也要处理。如果直接把 AST 节点转成字符串,例如"Name(id=x)",x 和 y 就成了两个完全不同的标签,重命名成本等于 1,这往往不是我们要的业务语义。做代码克隆检测时,局部变量的重命名不应该被记成一次结构性改动。
正确做法是:在构造Tree之前先把 AST 节点归一化成规范化标签,比如变量名统一成VAR,常量统一成CONST,只保留节点类型本身。这样a + b * c和x + y * z的 AST 会转成完全相同的树结构,编辑距离自然降到 0。
4.2 从 ast 模块生成 apted 树的标准写法
下面这段代码把 Python 标准库ast模块解析出的节点树转成 apted 的Tree对象。我习惯用对象方式而不是拼文本,因为可以在生成过程中直接做节点归一化:
import ast from apted import APTED from apted.helpers import Tree def py_ast_to_tree(node): """把 ast 节点递归转换成 apted 的 Tree 对象。""" label = type(node).__name__ t = Tree(label) for child in ast.iter_child_nodes(node): t.children.append(py_ast_to_tree(child)) return t src1 = ast.parse("a + b * c") src2 = ast.parse("x + y * z") t1 = py_ast_to_tree(src1) t2 = py_ast_to_tree(src2) apted = APTED(t1, t2) print(apted.compute_edit_distance())ast.iter_child_nodes按位置顺序枚举子节点,ast.parse返回Module根节点。这段代码只保留节点类型名作为标签,所有叶子上的变量名、常量值都丢了,因此两个表达式算出来距离是 0。如果希望变量名差异也反映到距离里,可以在递归时针对Name、Constant这类节点把额外信息拼进标签,例如Tree(f"{type(node).__name__}:{node.id}")。
4.3 重写 Config 的 delete、insert、rename 控制业务语义
默认成本不满足业务需求时,继承Config类并重写三个方法。下面这个例子把「删除函数定义」的成本调成普通节点的 3 倍,把「同类型节点重命名」的成本调低到 0.2:
from apted import APTED, Config class AstConfig(Config): def delete(self, node): # 删除函数定义的代价是普通节点的 3 倍 return 3.0 if node.tag == 'FunctionDef' else 1.0 def insert(self, node): # 插入和删除保持对称,避免算法偏向某个方向 return self.delete(node) def rename(self, node1, node2): # 节点类型相同,成本低;类型不同,成本高 if node1.tag == node2.tag: return 0.2 if node1 != node2 else 0.0 return 2.0 config = AstConfig() apted = APTED(t1, t2, config=config) print(apted.compute_edit_distance())这里的node.tag是 apted 的Tree对象暴露的标签属性。rename在标签相同时返回 0(同一节点)或 0.2(同类型但有差异),标签不同时返回 2,这样算法更倾向于「重命名为同类型节点」而不是「删除再插入」。把Config对象作为第三个参数传给APTED构造函数即可。表 4 总结了三个方法被调用的时机:
表 4 Config 三个方法的调用时机
| 方法 | 被调用的场景 | 对结果的影响 |
|---|---|---|
| rename(u, v) | 尝试把 T1 的 u 映射到 T2 的 v | 决定标签差异的代价 |
| delete(u) | 尝试把 u 从 T1 中删除 | 决定删除一个节点的代价 |
| insert(v) | 尝试把 v 插入 T2 | 决定插入一个节点的代价 |
成本可以是浮点数。调参时最需要关注的是 insert 和 delete 的对称性:如果 delete 比 insert 贵很多,算法会倾向于少删节点,最后得到偏大的距离;反过来则会多插入节点。稳定做法是让 insert 和 delete 用同一个成本函数,只在 rename 上做文章。
5. 批量树对比的落地技巧:子树剪枝与多进程并行
单次 APTED 好跑,工程上真正的难点是批量对比。比如你有 500 个候选代码文件,想找出与目标文件最相似的几个,做法是先把每棵树转成 apted 可计算的格式,再两两算距离。这里有两个问题必须先解决:树太大会拖慢单次计算,以及大量计算该不该并行。
5.1 对 AST 做子树剪枝,先压节点规模
AST 里有很多节点不影响结构相似度——连续的小叶子表达式、纯符号节点、空语句。在生成 apted 树之前做一次修剪,把连续的叶子节点序列合并成一个节点,能显著降低节点总数。下面是一个简单的修剪函数:
def prune_tree(t: Tree) -> Tree: if not t.children: return Tree(t.tag) merged = [] leaf_buf = [] for child in t.children: if not child.children: # 叶子节点暂时攒起来,连续叶子合并成一个 LITERAL 节点 leaf_buf.append(child.tag) continue if leaf_buf: merged.append(Tree('LEAF:' + ','.join(leaf_buf))) leaf_buf = [] merged.append(prune_tree(child)) if leaf_buf: merged.append(Tree('LEAF:' + ','.join(leaf_buf))) node = Tree(t.tag) node.children = merged return node这个函数把连续叶子合并成带LEAF:前缀的节点,配合按标签判断成本的 Config 可以正常参与距离计算。在真实的 HTML 解析树上,这种剪枝可以把节点数压到原来的五分之二左右,APTED 的运行时间压缩幅度远大于这个比例,因为算法瓶颈主要在路径展开数量上。
5.2 多进程并行跑距离矩阵,避免重复解析文本
单次 APTED 是纯 CPU 计算,非常适合进程池。注意每个 worker 进程都要解析树文本,所以如果树规模大,可以把已构建的Tree对象直接作为参数传入,而不是让每个 worker 重复from_text。文本格式在树不大时更容易调试,写法如下:
from concurrent.futures import ProcessPoolExecutor from apted import APTED from apted.helpers import Tree def ted_pair(pair: tuple[str, str]) -> float: t1_text, t2_text = pair return APTED( Tree.from_text(t1_text), Tree.from_text(t2_text), ).compute_edit_distance() pairs = list(zip(texts_a, texts_b)) with ProcessPoolExecutor(max_workers=4) as pool: distances = list(pool.map(ted_pair, pairs))max_workers一般设成物理核心数,不要直接超卖到逻辑线程数,因为 APTED 的临时内存占用按 O(n²) 增长,进程开多了容易把内存打爆。另一个实用技巧:如果候选集里很多树与目标树完全相同,先把候选树文本做哈希,重复的直接复用上一次距离结果。这两个技巧组合起来,500 对树的批量对比能从分钟级降到秒级;候选树超过一万对时,我建议先把每棵树导出路径签名表做粗筛,粗筛通过的再走完整 APTED,整个流水线的吞吐能再翻一倍。
本文还有配套的精品资源,点击获取