← 算法课堂:换一种算法到底换了什么

减法从哪一步开始

上一章把矩阵乘法的循环顺序改了三遍:逐格、沿行、分块。数组访问次数从 1088 改到 1792,缓存装入从 832 降到 288,差别是真的。但三者的乘法次数相同——都是 n³。

如果乘法次数不变,无论怎样调整顺序,都只能在一组共同的上界内分配开销。本单元从这个问题进入:要让乘积个数下降,可以修改公式本身吗?

预计用时:45 分钟。前置是《揭开黑盒》24–29 节:矩阵乘法的定义、n³ 这个计数,以及为什么"更少访问"与"更快"不能直接画等号。

先把账本字段补齐

上一章的账本只记了两项:乘法次数、缓存装入。本单元的账本要多一列预处理。在 assets/math-lab/algorithms.js 的 multiply 之外,新方案需要自己的准备阶段——如果这个阶段写在计时范围之外,比较就失真了。

方案计算搬运准备存储
逐格 ijkn³ 乘1088 次访问(n=8)无n² 输出
分块 blockedn³ 乘1792 次访问(n=8)无n² 输出
待定(本单元)待定待定待定待定

本单元的任务是填满最后一行。填之前有一条硬性要求:新方案的输出必须先被独立核对,不能因为"乘法少了"就跳过正确性。exactReference 用 BigInt 给出精确解,checkExact 逐项比对,这两个函数已经在仓库里。

分治的第一个动作是拆维度

乘积个数要下降,就必须让一次乘法的结果不止用于一个输出格,或者让某些格子不必逐个算完。第一个方向是减少必须完成的乘法个数。Mat1:

n 为偶数时,把 A、B、C 各切成四块:

A = [A11 A12]   B = [B11 B12]   C = [C11 C12]
    [A21 A22]       [B21 B22]       [C21 C22]

C11 = A11·B11 + A12·B21
C12 = A11·B12 + A12·B22
C21 = A21·B11 + A22·B21
C22 = A21·B12 + A22·B22

每块是 n/2 阶。八次 n/2 阶乘法,递归下去:

T(n) = 8·T(n/2) + Θ(n²)

按主定理,8 > 2²,于是 T(n) = Θ(n³)。和逐格实现同一量级。 分块递归没有减少乘法次数,只是把同一份工作换了一种组织方式——它换来的是缓存行为,不是渐近复杂度。

这个观察本身有价值:它把"改顺序"和"改公式"明确分开。上一章的成功属于前者。

候选变化:把 8 次改成 7 次

现在只改一个条件:让 n/2 阶乘法的次数下降。第一步不是直接找那个方案,而是先算出需要什么。

设 n/2 阶乘法需要 M 次,则

T(n) = M·T(n/2) + Θ(n²)

希望 T(n) = Θ(n^log₂M) 低于 n³,即 log₂M < 3,也就是 M < 8。整数 M 最大只能取 7。

所以问题被压缩成一句话:能不能用 7 次子矩阵乘法算出 C 的四块?

先验证 M=7 时的新指数:

log₂7 ≈ 2.807
T(n) = Θ(n^2.807)

n = 1024 时,n³ ≈ 1.07×10⁹,n^2.807 ≈ 2.9×10⁸,相差约 3.7 倍。这是一个真实的下降,不是常数因子。

实验前:写下会被反驳的预测

数学上 7 次是下界,工程上未必划算。七次乘法需要更多的加减法(Θ(n²) 项,但常数不小),还要额外的临时矩阵存储。因此:

一次能区分它们的实验:固定输入生成方式(沿用 matrices(n) 的小整数,exactReference 仍适用),只改 n,从 256 到 1024 取若干点,记录两方案的耗时比。若比值随 n 单调穿过 1,两条都成立;若比值始终小于 1,说明在这个实现里 Θ(n²) 项主导,需要改进常数或换设备。

会反驳 E1 的观察:比值不随 n 上升。会反驳 E2 的观察:小 n 上 7 次方案已经更快。

本单元的实测结果两条都成立,交叉规模约在 n=712~726(见下方三组数据)。反对意见同样成立,而且要写进报告:如果你实际处理的矩阵低于约 700 阶,这个算法不该用。 这不是失败,这是结论。

交叉规模是这份结论的核心量

两种做法的耗时会相等,等号处对应的 n 就是交叉规模(crossover point)。它由三个量决定:

  1. 渐近指数之差(3 vs 2.807)——决定大 n 上谁赢。
  2. 常数因子之比——7 次乘法要配更多加减,这部分不随 n 变小。
  3. 实现质量——递归基、子块复制方式、临时空间复用都会移动阈值。

三者叠在一起,交叉规模可以差出几倍。因此"某算法更快"这句话如果不带规模,是没有内容的。

这正是开篇那个"转折发生在第几次查询"的另一种形式:那里转折由查询次数决定,这里由矩阵阶数决定。两者的结构相同——比较的结论必须带坐标。

参考测量:三组数据

以下是在本机(Node.js 22,Windows,单线程)用 matrices(n) 的小整数输入、对拍全通过之后测得的,脚本在 assets/algo-lab/strassen-lab.mjs。递归基取 64。

第一组:交叉规模本身。 两次独立运行给出的结果并不完全相同:

n运行一 比值运行二 比值
2560.840.88
3840.850.88
5120.940.82
6400.800.88
7681.101.10
8961.411.19
10241.501.78

两次都落在 640 与 768 之间,交叉规模 n ≈ 712~726。这个区间本身就是结论的一部分:交叉规模不是一个精确常数,它带测量噪声。 报告里给一个精确到个位的数,反而掩盖了这一事实。

注意 256→640 段比值不单调(0.94 → 0.80 → 0.88)。这不是算法在反复变化,是单次测量的波动。开篇说的"一次只改一个条件",在测量上还有一条推论——同一个条件要测多次,看趋势而不是看单点。

第二组:递归基会移动结论。

递归基8162432486496128
比值(n=512)0.320.800.590.690.800.950.940.92

递归基 8 时比值掉到 0.32:朴素基底在 8 阶上仍是有效选择,把递归推到那么深只是白付开销。16 之后趋于稳定,但全部低于 1。

第三组:朴素实现自身的规模倍率。 若一切按 n³ 走,规模翻倍应该恰好 8.00 倍。实测 256→512 得 7.05,512→1024 得 8.91(另一次运行为 8.25 与 7.94)。偏差来自缓存效应、计时分辨率与 JIT。这提示:Θ 记号是外推的结构,不是单点数据的乘法。 拿 256 的耗时乘 64 去预测 1024,会差几个百分点。

一个必须写进报告的负结果

上表里,在递归基 64、n=512 时,这个 7 次乘法实现比朴素三重循环慢。它要到约 700 阶才反超。

原因在 未解决的问题 一栏已经写明,但值得在这里点出:该实现每次递归都把子块复制到新数组,递归树里有 Θ(n² log n) 级别的复制量。 渐近指数从 3 降到 2.807 是数学事实,但指数优势只有在大 n 上才能覆盖这笔复制开销。

这个负结果必须保留在报告里。它教的东西比"分治更快"更值钱:

  1. 渐近分析告诉你谁最终会赢,不告诉你什么时候。 2.807 < 3 是真的,交叉规模 700 也是真的,两条同时成立。
  2. 指数优势可以被常数开销推迟到很远的规模。 你的数据处理 512 阶矩阵,"Strassen 更快"这句话对你就没有内容。
  3. 测出负结果不算失败。 按契约写清"尚未排除的原因"和"改到哪一步可能翻转",这份负结果就是合格的交付。

改写实现(子块用视图而非复制、递归基重新调参)之后交叉规模会移到哪里,本单元未测——这是留给读者的下一项任务。

证据:这一步不能省

按契约,正确性排在计时之前。本单元的最小交付:

// 用仓库里的独立参考核对,不能只看"跑通了"
const {multiply, matrices, exactReference, checkExact} = require('./algorithms.js');
const n = 4;
const {a, b} = matrices(n);
const reference = exactReference(a, b, n);
const candidate = /* 你的 7 次乘法实现 */(a, b, n);
if (!checkExact(candidate, reference)) throw new Error('结果不对,后面所有计时作废');

只有过了这一步,才轮到时计。并且计时范围要写清楚:包含调用、输入校验、输出分配与临时空间申请,不包含输入生成与参考计算。每种实现预热一次,轮换顺序取五次中位数与极值——这与 29 节的处理一致,理由也相同:单次样本受 JIT 与后台负载影响,不足以支撑排序。

本节交付

填完开头那张账本表的最后一行,并交付一份报告:

项内容
输入与输出n 的取值范围、元素范围、输出含义
正确性exactReference 的逐项比对结果;n 为奇数时的处理与说明
基线实现multiply(a,b,n,'ijk'),注明源文件版本
候选变化只改了一件事:n/2 阶乘法次数由 8 降为 7
成本账本四项分开记,准备列不可为空
实验设计至少四个 n 取值,两次往返
证据原始样本、环境、计时边界
未解决的问题见下

未解决的问题 一栏本单元至少留四项:

  1. 本实现中测得的交叉规模是 712~726,噪声范围有多大?
  2. 递归的减法次数是多少?它在哪个规模上超过乘法节省?
  3. 本实现的子块复制量是 Θ(n² log n) 级别,这笔开销是否就是它跑不赢朴素的原因? 改成视图或原地运算后交叉规模会移到哪里?
  4. 若把输入规模限制在 512 阶以内,本单元的全部结论是否还有意义?

第四项是必须回答的。这门课不要求每个算法都用上,要求你能判断它什么时候不该用。

从矩阵乘到一般分治

矩阵乘法是一个特例,但它暴露的结构是通用的。任何分治都包含四件事:怎样拆(n 分成几份、每份多大)、递归几次(M)、合起来多少(Θ(n²) 这一项)、什么时候停(递归基,本单元是 1 阶或 2 阶)。

这四项决定了递推式,递推式决定了指数,指数与常数因子的比较决定了交叉规模。下一节(同一种拆法,三种不同的收益)把这个结构从矩阵乘法上取下来,放到三个更小的例子上:大整数乘法、最近点对、以及一个看起来完全不同的问题——怎样在一串数里找出第 k 大的那个。

后者会暴露一件事:分治的收益有时不来自"指数更低",而来自筛掉不必处理的数据。

与后续路线的接口

本单元之后,账本里会多出本单元还没有的量:通信。矩阵乘法的分治在单机上完成,拆开放在多个设备上时,子任务的中间结果需要搬运,而搬运次数与拆法直接相关。

这条线通向第四单元(归约、扫描、通信代价)。本单元可以先记下一个尚未排除的原因:本次测得的耗时差异中,有多少来自算法本身,有多少来自现在的 CPU 缓存层次对分块顺序特别友好。 教学模型能说明机制,没有观测本机计数器,不能给出比例。

参考实现与自测

候选方案、独立参考与测量脚本在 assets/algo-lab/,零依赖,只需要 Node.js 18+:

node assets/algo-lab/strassen-lab.mjs     # 对拍 + 交叉规模 + 递归基扫描
node --test assets/algo-lab/test.mjs      # 只跑正确性断言,不做计时

strassen-lab.mjs 的第 1 步是全部规模的对拍;任何一项失败就直接终止,不进入计时。计时结果不写进断言——本机耗时波动会导致换机器就红,那是环境差异,不是被测代码坏了。

assets/math-lab/test_algorithms.cjs 覆盖的 multiply 三种顺序与 exactReference 对照,在新增分治实现时同样可以复用。