第二章 差分算法基础:二进制差异的本质

做差分升级,说白了就是要回答一个问题:两个二进制文件之间,到底差在哪?

我刚开始接触这个领域时,以为差分就是简单的「逐字节比较」。后来才发现,事情远没那么简单。你想想看,一个固件镜像动辄几兆甚至几十兆,如果只是逐字节对比,那跟全量升级有什么区别?

这一章,我们就来拆解二进制差异的本质,以及支撑差分算法的两个核心理论:编辑距离和最长公共子序列。

2.1 二进制差异的本质

两个二进制文件,在计算机眼里就是两串字节序列。差异的本质,就是从一个序列变成另一个序列,需要做哪些操作。

举个例子:

旧文件: 0x01 0x02 0x03 0x04 0x05
新文件: 0x01 0x02 0xFF 0x04 0x05

肉眼一看,第三个字节从 0x03 变成了 0xFF。但实际工程中,差异远不止这么简单:

  • 插入:新文件多了几个字节
  • 删除:旧文件少了几个字节
  • 替换:字节内容变了
  • 移动:一大段数据换了位置

核心观点:差分算法的目标,就是用最少的操作来描述新旧文件之间的差异。这个「最少操作次数」,就是后面要讲的编辑距离。

我在项目中遇到过一种情况:两个固件版本,功能上只改了一行代码,但编译出来的二进制差异却很大。为什么?因为编译器优化、地址对齐、链接顺序都会导致大量「无关差异」。所以,二进制差异 ≠ 源码差异,这一点必须刻在脑子里。

2.2 编辑距离算法

编辑距离(Edit Distance),也叫 Levenshtein 距离。它衡量的是:把一个字符串变成另一个字符串,最少需要多少次编辑操作。

操作只有三种:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符

嗯,这里要注意:移动操作在基本编辑距离里不算,它会被拆成删除+插入。

2.2.1 算法原理

编辑距离用动态规划求解。我们定义一个二维数组 dp[i][j],表示旧序列前 i 个字符变成新序列前 j 个字符的最小编辑距离。

递推公式:

if old[i] == new[j]:
    dp[i][j] = dp[i-1][j-1]
else:
    dp[i][j] = min(
        dp[i-1][j] + 1,    # 删除
        dp[i][j-1] + 1,    # 插入
        dp[i-1][j-1] + 1   # 替换
    )

边界条件:

  • dp[0][j] = j(从空串插入 j 次)
  • dp[i][0] = i(从 i 个字符删除 i 次)

2.2.2 一个具体例子

假设旧序列是 "ABC",新序列是 "ABD"。

A B D
0 1 2 3
A 1 0 1 2
B 2 1 0 1
C 3 2 1 1

最终 dp[3][3] = 1,只需要一次替换(C→D)。

个人习惯:我在做嵌入式差分时,很少直接用编辑距离算整个固件。因为固件太大了,O(n*m) 的空间复杂度扛不住。但编辑距离的思想是所有差分算法的基础,必须吃透。

2.3 最长公共子序列

最长公共子序列(LCS),跟编辑距离是「表兄弟」。它找的是:两个序列中,按顺序出现的最长公共部分。

注意,子序列不要求连续。比如 "ABC" 和 "ACB" 的 LCS 是 "AB" 或 "AC",长度是 2。

2.3.1 LCS 与编辑距离的关系

这两者其实可以互相推导:

编辑距离 = m + n - 2 * LCS长度

其中 m 和 n 分别是新旧序列的长度。为什么?因为 LCS 是「不用动」的部分,剩下的都要删掉或插入。

我举个例子你就明白了:

  • 旧序列 "ABCD",新序列 "ACED"
  • LCS 是 "ACD",长度 3
  • 编辑距离 = 4 + 4 - 2*3 = 2

确实,只需要把 B 删掉,把 E 插进去,两步搞定。

2.3.2 LCS 的 DP 解法

LCS 的递推公式更简单:

if old[i] == new[j]:
    dp[i][j] = dp[i-1][j-1] + 1
else:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])

边界全是 0。

我曾经踩过的坑:在资源受限的 MCU 上做差分时,直接用二维数组存 DP 表,结果内存爆了。后来改用滚动数组优化,空间从 O(m*n) 降到 O(min(m,n))。这个优化技巧,后面章节会详细讲。

2.4 从理论到工程:差异的表示

理论讲完了,我们看看工程上怎么表示差异。常见的差分格式有两种:

格式 描述 典型算法 适用场景
编辑脚本 记录每一步操作(插入/删除/替换) 编辑距离回溯 小文件、文本差分
块匹配 记录「从哪里复制多少字节到新位置」 bsdiff、xdelta 大文件、二进制差分

我个人更倾向于块匹配方式。原因很简单:固件升级包要尽量小,而块匹配能利用「重复数据」大幅压缩差异。

2.5 本章知识结构图

下面这张图,把本章的核心概念串起来了:

差分算法基础知识结构 差分算法基础:知识结构 二进制差异 编辑距离 最长公共子序列 差异表示格式 插入 / 删除 / 替换 动态规划 O(n*m) 不要求连续的子序列 编辑距离 = m+n-2*LCS 编辑脚本 vs 块匹配 bsdiff / xdelta 核心:用最少操作描述新旧差异 编辑距离 → 操作次数;LCS → 不变部分

2.6 小结

这一章我们聊了三件事:

  • 二进制差异的本质:就是序列之间的变换操作
  • 编辑距离:衡量差异大小的数学工具
  • 最长公共子序列:找出「不用动」的部分,反过来就是差异

这些理论看起来有点抽象,但它们是所有差分算法的基石。后面讲 bsdiff、xdelta、HDiffPatch 时,你会发现它们都在用这些思想,只是换了个更高效的工程实现方式。

给你一个思考题:如果旧文件和新文件完全一样,编辑距离是多少?LCS 长度又是多少?想明白了,这两个概念你就真懂了。