核心算法基础:二进制差异、字节级比较、块级别哈希、滚动哈希算法原理
好,咱们进入第二章。说实话,这一章是整个差分更新技术的「地基」。你想想看,如果连两个文件到底差在哪都说不清楚,那后面什么增量包、补丁生成全都是空中楼阁。
我个人习惯把差分算法拆成四个层次来理解:二进制差异是底层视角,字节级比较是朴素方法,块级别哈希是工程优化,滚动哈希是真正让差分实用化的关键。咱们一层一层往上走。
2.1 二进制差异:最朴素的视角
两个文件,在计算机眼里就是两串二进制数据。所谓二进制差异,就是逐比特对比,找出「哪里不同」。
举个例子:
文件A(旧版): 0x48 0x65 0x6C 0x6C 0x6F 0x00
文件B(新版): 0x48 0x65 0x6C 0x70 0x21 0x00
肉眼一看,第4字节从 0x6C 变成 0x70,第5字节从 0x6F 变成 0x21。这就是最原始的差异描述。
关键认知:二进制差异不关心文件格式、编码、语义。它只回答一个问题——「这两个字节串,哪些位置不一样?」
我在项目中遇到过有人直接用 diff 命令对比二进制文件,结果输出一堆乱码。嗯,这里要注意:二进制差异不适合用文本 diff 工具处理,必须用专门的二进制比较工具或算法。
2.2 字节级比较:简单但低效
字节级比较是最直接的实现方式。把两个文件从头到尾逐字节对比,记录所有差异位置。
伪代码大概长这样:
function byteLevelDiff(oldData, newData):
changes = []
for i = 0 to min(len(oldData), len(newData)):
if oldData[i] != newData[i]:
changes.append({offset: i, oldByte: oldData[i], newByte: newData[i]})
// 处理长度不一致的情况
if len(oldData) != len(newData):
// 记录多余或缺失的部分
return changes
这个方法的问题很明显——太慢了。两个 1GB 的文件,逐字节对比要扫描 10 亿次。而且它只告诉你「哪里变了」,不告诉你「哪些部分没变可以复用」。
避坑指南:我曾经在一个嵌入式项目里用字节级比较做 OTA 差分,结果生成补丁花了 40 分钟,用户直接投诉。后来我才意识到,字节级比较只适合小文件(几 KB 以内),大文件必须上块级别哈希。
2.3 块级别哈希:分而治之
既然逐字节太慢,那就把文件切成块,逐块比较。这就是块级别哈希的核心思想。
具体做法:
- 将旧文件和新文件分别切成固定大小的块(比如 4KB 一块)
- 对每个块计算哈希值(MD5、SHA-1 或更快的 xxHash)
- 通过哈希值匹配,找出哪些块在新旧文件中相同,哪些不同
哈希值匹配的逻辑:
function blockLevelDiff(oldBlocks, newBlocks):
// 构建旧文件哈希索引
hashIndex = {}
for i, block in enumerate(oldBlocks):
hashIndex[hash(block)] = i
// 匹配新文件块
matches = []
for j, block in enumerate(newBlocks):
h = hash(block)
if h in hashIndex:
matches.append({oldIndex: hashIndex[h], newIndex: j})
return matches // 匹配的块可以复用,未匹配的块需要传输
这里有个细节:哈希碰撞。两个不同的块可能算出相同的哈希值。我在项目中遇到过这种情况,结果补丁打上去文件就坏了。所以实际工程中,哈希匹配后还要做一次字节级确认,或者使用更安全的哈希算法。
| 块大小 | 优点 | 缺点 |
|---|---|---|
| 512B | 匹配精度高,细粒度复用 | 哈希数量多,索引内存大 |
| 4KB | 平衡性能和精度,常用 | 对小块插入/删除不敏感 |
| 64KB | 哈希数量少,速度快 | 大块不匹配时浪费带宽 |
我的经验:对于二进制文件(如固件、APK),4KB 块大小是黄金选择。对于文本文件(如配置文件),可以适当减小到 1KB。你想想看,文本文件经常只改一两行,块太大反而匹配不上。
2.4 滚动哈希算法原理
块级别哈希有个硬伤——块边界固定。如果新文件在旧文件中间插入了一个字节,那从插入点开始,后面所有块的边界都偏移了,哈希全部不匹配。说白了,一个字节的插入会导致后面整个文件被视为「不同」。
滚动哈希就是来解决这个问题的。它的核心思想是:哈希值可以「滚动」计算,每次只滑动一个字节,就能快速得到新窗口的哈希值。
最经典的滚动哈希是 Rabin-Karp 算法中使用的多项式哈希:
H(window) = (c1 * B^(k-1) + c2 * B^(k-2) + ... + ck * B^0) mod M
// 滚动更新:去掉最左字节,加入最右字节
H(newWindow) = ((H(oldWindow) - c1 * B^(k-1)) * B + c_new * B^0) mod M
其中:
c1...ck是窗口内的字节值B是基数(通常取一个大质数,如 257)M是模数(控制哈希范围)k是窗口大小(即块大小)
你看,滚动哈希把计算复杂度从 O(k) 降到了 O(1)。每次滑动只需要两次乘法和一次加法,性能提升非常明显。
滚动哈希的工程价值:它允许我们在新文件中以任意位置为起点计算哈希,然后去旧文件的哈希索引中查找匹配。这样即使文件中间有插入或删除,也能找到「偏移后」的匹配块。
我记得在实现 rsync 风格的差分算法时,滚动哈希是核心引擎。旧文件先按固定块计算哈希并建立索引,然后新文件用滚动哈希逐字节滑动查找匹配。匹配上的块直接复用,未匹配的字节作为「新数据」传输。这样生成的补丁,对插入/删除操作非常鲁棒。
下面这张图展示了滚动哈希在差分过程中的核心位置:
2.5 四种方法的对比总结
咱们把这四种方法放在一起对比一下,方便你理解各自的应用场景:
| 方法 | 粒度 | 时间复杂度 | 对插入/删除的鲁棒性 | 适用场景 |
|---|---|---|---|---|
| 二进制差异 | 比特级 | O(n) | 差(逐位对比) | 小文件、固件补丁 |
| 字节级比较 | 字节级 | O(n) | 差(逐字节对比) | 几 KB 以内的小文件 |
| 块级别哈希 | 块级(如 4KB) | O(n/k) | 中等(块边界固定) | 大文件、版本管理 |
| 滚动哈希 | 滑动窗口级 | O(n) 但常数极小 | 强(自动适应偏移) | OTA 更新、文件同步 |
个人建议:如果你刚开始做差分更新,直接从滚动哈希入手。块级别哈希可以作为过渡理解,但实际工程中滚动哈希才是主流。rsync、zsync、Google 的 Courgette 都在用这个思路。