数会长大,时间会说谎:从算法复杂度到位复杂度

我们平时说一个算法是 \(O(n)\)\(O(n \log n)\)\(O(\sqrt n)\),大多是在一种心照不宣的模型里说的:数组下标、整数加减、比较、取模,都算作一次“基本操作”。这套说法对大多数工程代码很有用。你在 64 位机器上排序一批整数,或者用哈希表查几个 key,通常不需要追问一次整数加法到底碰了多少个 bit。

但这个默认模型有一个容易被忽略的前提:参与计算的数放得进机器字,或者至少我们愿意把它们当成常数大小。

一旦输入里出现可以任意增长的整数,这个前提就开始松动。一个数不是抽象的一个格子,而是一串二进制位。数会长大,比较、加法、乘法、取模都会变慢。于是,同一个算法可能在普通复杂度下看起来温和,在位复杂度下显出完全不同的面目。

这篇笔记讨论的不是“哪种复杂度更正确”,而是:什么时候普通复杂度足够好,什么时候它会让我们误判一个算法。

我们平时默认了什么

看一段普通得不能再普通的代码:

s = 0
for x in a:
    s += x

如果数组长度是 \(n\),程序员通常会说它是 \(O(n)\)。这个判断没有错,只是它省略了一层条件:每次 s += x 的成本被看作 \(O(1)\)

在 C、Go、Rust 这类语言里,如果 sx 是 32 位或 64 位整数,这个近似很自然。机器指令一次就能完成加法,溢出另说,但时间不会随着数值变大而持续增长。

如果 x 是大整数,事情就不同了。两个 \(k\) 位整数相加,需要沿着位或机器字传播进位,位复杂度一般写成 \(O(k)\)。这时上面的求和不再只是 \(O(n)\),更接近 \(O(nk)\),而且 \(s\) 本身还可能越加越长。

普通复杂度把“操作”当单位,位复杂度把“处理过的 bit”当单位。它们不是互相否定,而是在回答不同的问题。

输入规模不是数值大小

最容易出错的地方,是把整数 \(N\) 的数值大小当成输入规模。

如果输入是一个整数 \(N\),它在二进制下的长度大约是 \(k = \log_2 N\)。也就是说,输入文件里写下 \(N\) 不需要 \(N\) 个字符,而只需要 \(\log N\) 个 bit 级别的空间。

这会导致一个反直觉的换算:对 \(N\) 循环一次到头的算法,看起来是 \(O(N)\),但相对于输入长度 \(k\),它其实是 \(O(2^k)\)。普通复杂度里的线性,在位复杂度视角下可能是指数级。

比如:

for i in range(N):
    do_something()

如果 \(N\) 是数组长度,那么 \(O(N)\) 很正常,因为输入本身通常也有 \(N\) 个元素。如果 \(N\) 是输入文件里给出的一个整数,那就不同了。一个 1000 位的整数可以表示大约 \(2^{1000}\) 级别的数,对它从 1 循环到 \(N\),不是“输入稍微大一点”,而是直接离开了可计算的日常世界。

这里的关键不在符号,而在问题建模:\(n\) 有时表示元素个数,有时表示一个数值。前者通常和输入长度同阶,后者可能比输入长度大得多。

试除法为什么不快

判断一个数 \(N\) 是否为质数,最朴素的方法是试除到 \(\sqrt N\)

def is_prime(N):
    if N < 2:
        return False
    i = 2
    while i * i <= N:
        if N % i == 0:
            return False
        i += 1
    return True

在普通分析里,我们会说循环次数是 \(O(\sqrt N)\)。对小整数来说,这个说法很直观,也能指导实现优化,比如只试奇数,只试 \(6m \pm 1\),或者先筛出小质数。

但如果讨论的是大整数判素,\(O(\sqrt N)\) 就不是一个温和的复杂度。令 \(k = \log_2 N\),则 \(\sqrt N = 2^{k/2}\)。也就是说,这个算法对输入位数是指数级的。

这也是密码学里不能用“试到根号”来处理大数的原因。一个 2048 位的天文数字,平方根仍然是 1024 位的天文数字,候选因子数量不是“很多”,而是天文级别。

更细一点看,每次 % 也不是 \(O(1)\)。当 \(N\)\(k\) 位大整数时,取模本身就是大整数除法的一部分。于是试除法在位复杂度下不仅有指数级的迭代次数,每一步还带着非平凡的大整数运算成本。

0-1 背包的伪多项式陷阱

0-1 背包的动态规划通常写成 \(O(nW)\),其中 \(n\) 是物品数,\(W\) 是背包容量。这个复杂度在工程上很有意义:如果 \(W\) 不大,它非常好用,实现简单,常数也不夸张。

问题是,如果从理论复杂度看,\(W\) 是输入中的一个整数。写下 \(W\) 只需要 \(\log W\) 位,而 DP 表却要开到 \(W\)。所以 \(O(nW)\) 对数值 \(W\) 是多项式,对输入长度 \(\log W\) 却可能是指数级。

这类算法常被称为伪多项式时间。它不代表算法不好,反而常常是实用算法。它只是提醒我们:复杂度里的变量到底是“输入中有多少项”,还是“某个字段的数值大小”,这两者不能混在一起。

子集和、硬币找零、某些整数规划的动态规划都有类似味道。当目标值、容量、金额上界较小,它们很好;当这些数可以用很短的二进制编码表示一个巨大值,表格法就会暴露出本质限制。

大整数运算不是常数

位复杂度最朴素的切入点,是给常见整数操作重新标价。假设操作数都有 \(k\) 位,那么比较和加减法通常是 \(O(k)\);朴素乘法是 \(O(k^2)\);除法和取模更复杂,实际成本依赖具体算法和实现。

现代大整数库不会永远使用小学乘法。数字较小时,朴素算法常数小;数字变大后,可能切换到 FFT 或 NTT 风格的乘法。位复杂度关心的是这种增长趋势:数字越长,算一次就越贵。

这会影响很多看起来循环次数很少的算法。比如计算阶乘:

x = 1
for i in range(1, n + 1):
    x *= i

如果把乘法当成 \(O(1)\),这段代码像是 \(O(n)\)。但 \(n!\) 的位数大约是 \(\Theta(n \log n)\),中间的 x 一直在变长。后面的乘法比前面的乘法贵得多。位复杂度下,我们不能只数循环次数,还要看中间对象的大小如何增长。

类似的问题也出现在精确有理数计算里。两个分数相加以后,分子分母可能膨胀;如果不及时约分,表达式树很小,数字却很大。符号计算系统和高精度数值程序经常要在“保持精确”和“控制位数”之间做取舍。

欧几里得算法

求最大公约数的欧几里得算法是复杂度分析里的老朋友:

def gcd(a, b):
    while b:
        a, b = b, a % b
    return a

普通说法会关注循环次数。每轮之后数会变小,最坏情况和斐波那契数有关,因此迭代次数是对数级的。若 \(a\)\(b\) 不超过 \(N\),循环次数是 \(O(\log N)\)

这句话仍然有价值,但在位复杂度下还没说完。令输入位数为 \(k\),迭代次数大约是 \(O(k)\);每次 % 处理的是大整数,不能看成 \(O(1)\)。如果用朴素除法,单次取模可能带来更高成本。更高级的 gcd 算法会利用半 gcd 等技巧降低位复杂度,但那已经是在另一层模型里优化了。

这个例子很适合提醒自己:循环次数复杂度和每步操作复杂度是两层东西。普通复杂度常常把第二层折叠成常数,位复杂度把它重新展开。

沉默的 key

不仅数论算法会遇到位复杂度。很多普通数据结构算法也暗含“key 很短”的假设。

排序 \(n\) 个整数,比较排序通常写作 \(O(n \log n)\)。如果每个整数都有 \(k\) 位,比较两个整数最坏要看 \(O(k)\) 位,复杂度就更接近 \(O(n \log n \cdot k)\)。当然,真实机器上会按字比较,缓存、分支预测和数据分布都会影响表现,但从位复杂度视角看,比较不是免费的。

字符串排序也是同一个问题的日常版本。比较两个字符串不是 \(O(1)\),而是取决于公共前缀长度。很多工程 bug 或性能毛刺都来自这里:理论上用了平衡树或排序,复杂度看起来漂亮,实际 key 很长,比较成本成了主角。

图算法里也有类似细节。Dijkstra 算法常写成 \(O((V + E)\log V)\),默认距离比较和边权加法是常数。如果边权是大整数,路径和可能越来越长,优先队列里的比较和松弛操作都要付出位级成本。大多数业务图不会走到这一步,但一旦权重来自高精度金额、符号表达式或密码学对象,这个假设就不能再沉默。

两把尺子如何共存

普通复杂度并不是“粗糙的位复杂度”。它是另一种抽象,适合另一类问题。

在 word-RAM 模型里,一个机器字能容纳 \(O(\log n)\) 位,机器字上的基本操作算 \(O(1)\)。这和真实计算机很接近,所以我们分析数组、哈希表、堆、图遍历、排序时,通常不会把每次下标计算和整数比较拆成 bit 操作。否则复杂度表达会变得沉重,反而遮住主要结构。

位复杂度适合那些数字本身就是问题核心的场景:大整数算术、判素数、因数分解、密码学、精确计算、伪多项式动态规划、理论复杂性分析。在这些问题里,“数有多大”不能只看数值,还要看它如何编码、占多少位、每一步是否制造了更大的中间结果。

实际写代码时,可以把它当成一个分层习惯:先用普通复杂度看控制流和数据结构,再问一句,基本操作是否真的是常数。如果答案依赖“大概都是 64 位整数”,那工程上可能已经够了;如果答案是“这些数可能有几千、几百万位”,复杂度分析就必须下沉到位级。

总结

当复杂度表达式里出现 \(N\)\(W\)\(C\)\(M\) 这类数值参数时,可以停一下,问它到底代表什么。

如果它代表输入中元素的个数,比如数组长度、节点数、边数,\(O(N)\) 通常就是自然的线性复杂度。如果它代表输入中某个整数的值,比如背包容量、目标和、待判素的整数本身,那么真正的输入长度更接近 \(\log N\)。这时,\(O(N)\) 可能已经是指数级,\(O(\log N)\) 反而只是线性级别。

这不是玩符号游戏。它改变我们对算法可行性的判断:试除到 \(\sqrt N\) 并不适合大整数判素;\(O(nW)\) 的背包 DP 很实用,但不是强多项式算法;大整数求和、排序、gcd、幂运算,都要把数字位数纳入成本。

复杂度分析像测量时间,但时间不是凭空流动的。它流过循环,流过内存,也流过每一个被我们默认成常数的 bit。普通复杂度让我们看见程序的骨架,位复杂度让我们看见数字的重量。两把尺子都留在手边,很多算法的真实性格才会显出来。

posted @ 2026-05-29 16:41  Ofnoname  阅读(7)  评论(0)    收藏  举报