这一篇在干嘛?

算法写出来能跑,不代表它”好”——数据量一大, 的程序可能要跑两年半。本章建立一套数学语言(大 O 及其亲戚),让你在写代码之前就能算出程序在百万级数据上的表现。这是全书每一章都在用的分析工具。

2.1 四个记号:不只是大 O

表示”输入规模为 时的运行时间”,四个定义层层递进:

大 O(渐进上界),当且仅当存在常数 ,使

白话: 是增长率的”天花板”——数据够大之后, 不会比 涨得更快。日常口语里的”这个算法是 的”指的就是上界。

大 Ω(渐进下界),当 大 Θ(渐进精确界):同时满足 ,不多不少正好这个增长率。小 o(非紧上界) 表示 对任意常数 都成立(比上界更”严”)。

一条速记: 相当于 相当于 相当于 相当于

三条实用法则:

  • ,则顺序执行两段代码合计 (取更快的增长率,即 max);嵌套执行合计
  • 次多项式,则 (低阶项在渐进意义下可忽略)。
  • 对任意常数 成立——对数再乘几次方,也追不过线性。

常见坑

大 O 是上界不是”等于”。说”某算法最坏 、最好 “没问题,但把平均 的快排说成”就是 “是把上界当成了增长率本身;反过来,能说 时别只给 ——信息量完全不同。

2.2 模型与分析什么

分析采用简化的计算模型:一行简单语句耗时一个时间单位、内存无限、没有”地址离得远就更慢”之类的存储层级效应。粗糙,但足够比较算法的量级。

要分析的量:

  • 最坏情形 :上界保证,最常分析——“承诺最惨也不会超过多少”。
  • 平均情形 :对输入分布取期望,往往和最坏差不多量级,但数学上更难。
  • 最好的情形基本没意义(输入恰好特殊,不代表算法能力)。

2.3 经典案例:最大子序列和的四种解法

问题:给定整数 (可为负),求 (子序列和全为负时答案为 0)。

算法 1:三重暴力 ——枚举所有 再逐个求和:

int maxSubSum1(const vector<int> &a) {
    int maxSum = 0;
    for (int i = 0; i < a.size(); ++i)          // 起点
        for (int j = i; j < a.size(); ++j) {    // 终点
            int thisSum = 0;
            for (int k = i; k <= j; ++k)        // 逐个加:重复劳动!
                thisSum += a[k];
            if (thisSum > maxSum)
                maxSum = thisSum;
        }
    return maxSum;
}

算法 2:去掉第三重循环 ——发现 ,内层求和可以顺水推舟,不必从头算。

算法 3:分治 ——把数组从中间劈成两半,最大子序列只有三种可能:全在左半、全在右半、跨越中线。前两种递归求解;第三种从中线向左、向右各扫一遍取最大延伸(两个 循环)。递推关系 ,由主定理解得

算法 4:一次扫描(Kadane) ——兼有最简形式与最高效率,也最不显然:

int maxSubSum4(const vector<int> &a) {
    int maxSum = 0, thisSum = 0;
    for (int j = 0; j < a.size(); ++j) {
        thisSum += a[j];               // 把当前元素并入"当前段"
        if (thisSum > maxSum)
            maxSum = thisSum;          // 记录历史最佳
        else if (thisSum < 0)
            thisSum = 0;               // 当前段已为负:立刻弃掉,从下一元素重新开段
    }
    return maxSum;
}

白话:一路累加,一旦”当前段的和”变成负数,它对后面任何延伸都是拖累——果断扔掉、重新开始。任意时刻只要扫过一遍,最优答案必然在某次”当前段”中出现过。

四种算法实测对比(原书 Figure 2.2,单位秒)

输入规模算法1 算法2 算法3 算法4
N=1000.0001590.0000060.0000050.000002
N=1,0000.0958570.0003710.0000600.000022
N=10,00086.670.0333220.0006190.000222
N=100,000NA(约 9000s)3.330.0067000.002205
N=1,000,000NANA(约 333s)0.0748700.022711

图:四种算法运行时间随 N 的增长曲线(小规模)

图:四种算法在大规模输入下的表现——低效算法直接不可用

白话:N 扩大 10 倍,线性算法时间也只扩 10 倍;二次算法涨 100 倍;三次算法涨 1000 倍——从”眨眼完成”到”两小时起步”,差距全在增长率。另外注意:对线性算法,读入数据本身往往比求解还慢——好算法不该让计算成为瓶颈。

2.4 运行时间估算四法则

分析一段代码,按下面四条机械地算:

  1. 简单语句(赋值、无输入输出的简单调用):。for 循环头自身的比较和自增也并入循环体。
  2. for 循环:循环体时间 × 迭代次数。一般 次循环就是
  3. 嵌套循环:由内向外逐层分析,总时间 = 各层迭代次数的乘积。双层 0..N 循环就是
  4. 顺序语句 / if-else:取各分支中的最大值,不再相加。

一个会”骗人”的例子——计算 的程序:

sum = 0;
for (int i = 1; i <= n; ++i)
    for (int j = 1; j <= i; ++j)    // 注意内层上限是 i 不是 n!
        sum++;

内层迭代 次、外层 次,总量 ,所以是 而非 ——嵌套循环要看清”内层跑多少圈”。

2.5 对数级复杂度的三种来源

判据:某算法是 ,通常因为它每一步把问题规模砍掉一个常数比例(折半、砍 1/3……)。三种典型形态:

① 折半查找(binary search) :有序数组里找 x,与中点比较,每次排除一半。前提是数组已排序(排序本身要 ,所以输入静态且多次查询时才划算)。

② 欧几里得算法(gcd) ,余数每两轮至少减半,故为对数级。这是”对数来源=余数递减”的变体:

long gcd(long m, long n) {
    while (n != 0) {
        long rem = m % n;   // 辗转相除
        m = n;
        n = rem;
    }
    return m;
}

③ 快速幂(exponentiation) :算 ,利用 (N 为偶数)或 (N 为奇数),递归深度即 。朴素的连乘 次则是 —— RSA 加解密的效率差距就在这一个算法上。

白话:凡是”每次把规模折半/取余缩小”的算法,循环次数都约为 ——10 亿次数据也就约 30 步。这是分析树、堆、并查集( 更快)等章节的直觉基础。

最坏情形分析的局限:有时最坏情形过度悲观(如快排平均 但最坏 ,实践中仍是最快的通用排序之一),也可能遇到病态输入(如对已排序输入做快速幂之类依实现而异的退化)。选取何种度量要结合场景,不能只背数字。

常见坑

把大 O 当”平均”用 只是上界,快排最坏 、平均 ,描述时别混。另一个坑:嵌套循环默认 ,但内层若是”折半”或”从 i 开始”,实际是 的一半——常数减半不改变量级,但循环结构变了量级就变。

通关标准

学完本篇你应该能做到:① 说清 O/Ω/Θ/o 的区别并互相换算;② 对任意循环嵌套代码机械地算出大 O(含内层上限依赖外层变量的情形);③ 讲出最大子序列和四种算法的思路与量级,并手写 Kadane 算法;④ 一眼识别对数级算法的三种来源模式。