qinghuan
← 计算机基础 / 全部笔记

单元 29 / 计算机基础

P0 · 必会主线L1 · 基础

复杂度入门:输入扩大后,工作量怎样增长

先数程序做了多少次关键操作,再判断数据翻倍后的变化,分清增长趋势和一次实际运行的耗时。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能从代码统计操作数量,并区分渐近增长、单次成本和实际测量。

学习理由、准备要求与知识点

为什么学它

复杂度提供比较方案的共同语言,避免只凭一次计时选择算法或集合。

先读这些内容

知识点清单

  • 输入规模与关键操作
  • O/Ω/Θ 定义
  • 最好平均最坏情况
  • 循环次数推导
  • 线性平方对数增长
  • 额外空间与调用栈
  • 摊还成本
  • 数据库往返的整体成本

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

复杂度要回答的是:数据变多以后,程序的工作量会怎样增加。先说明 n 代表什么,比如订单数或字符串长度,再数关键操作。大 O 表示增长的上界,大 Ω 表示下界,大 Θ 表示上下界同阶;这些符号描述增长趋势,不是在某台电脑上必定耗时多少毫秒。

例如,两段各处理 n 项的循环顺序执行,工作量通常相加;每处理一项都再完整扫描 n 项,通常要相乘。但不能看到两层循环就断定是平方增长:若内层指针全程只向前走 n 次,总量仍可能很小。小数据的实际耗时还受内存分配和硬件等影响,要数执行次数,也要用测量检验真实场景。

最好、最坏和期望情况需要说明输入条件或概率假设,摊还分析则研究一串操作的总成本,不是同一种平均。空间复杂度还需说明是否包括输入、输出与递归栈。对任意精度整数、长字符串比较或远程调用,单次操作并不一定常数,不能机械套用只适合固定宽度基本值的模型。

文中这几个词,先认一认

输入规模
数据有多大,例如元素数、字符数;一个问题也可能需要同时用行数和列数描述。
渐近上界
例如工作量是 O(n),意思是数据量 n 超过某个起点后,操作次数不超过 c×n,其中 c 是固定常数。这给的是大规模时的增长上限,不是每次恰好做 n 步,也不是承诺多少毫秒。
摊还分析
先算连续多次操作一共花多少,再分摊到每一次,允许其中几次较贵。
预处理
先花一次时间整理数据,例如建索引,以便后续反复查找更快。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

分析从真实执行路径开始。线性查找最坏检查全部 n 项,二分查找依赖已经排序且可快速定位中点,每次排除约一半候选,经过 k 次剩余约 n/2^k 项,因此 k 随 log n 增长。如果排序本身也属于一次性任务,总成本还要包括排序,不能只报告最后查询那一步。

分析递归要分开数两件事:总共做了多少工作,以及同一时刻最多套了多少层调用。两个分别处理一半数据的递归调用,总工作仍可能是线性或线性对数;大量重复求解相同小问题才会明显浪费。把分支数量、每个小问题的规模和合并成本写成递推关系,再推算增长,不能只因为用了递归就判定它慢。

预处理是否值得取决于查询次数。对 n 项数据做 q 次区间求和,逐次扫描最坏 O(nq);建立前缀和需要 O(n),每次查询 O(1),总共 O(n+q),代价是额外 O(n) 存储和更新不再直接。图把一次构建与重复查询分开,提醒分析必须覆盖整个使用周期。

正在加载流程图…

不要只比较单次查询,预处理、存储和更新都属于完整成本。
查看流程图源码(Mermaid)
flowchart TD
 A["明确 n 与查询次数 q"] --> B{"是否重复查询同一数据"}
 B -->|"少量"| C["直接扫描:每次按区间长度"]
 B -->|"大量且数据固定"| D["一次 O(n) 建前缀和"]
 D --> E["每次用两次索引与一次减法"]
 E --> F["总成本 O(n+q),额外 O(n) 空间"]
 C --> G["累计完整工作量再比较"]
 F --> G

按顺序看,每一步做了什么

  1. 定义所有输入规模维度。
  2. 数出各操作的实际执行次数。
  3. 说明最好、最坏或期望前提。
  4. 计算辅助空间与递归栈。
  5. 把构建、查询、更新和实际测量分开评估。

03对照实现代码

从真实二分循环推导对数级比较次数 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/Arrays.java · java.util.Arrays#binarySearch0(int[],int,int,int)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
    public static int search(int[] sorted, int target) {
        int low = 0, high = sorted.length - 1;
        while (low <= high) {
            int mid = low + (high - low) / 2;
            int value = sorted[mid];
            if (value == target) return mid;
            // 每个失败比较都排除中点及不可能的一半。
            if (value < target) low = mid + 1;
            else high = mid - 1;
        }
        return -(low + 1); // 负值编码插入位置,保留零位置可表示性。
    }
}

真实 int 数组 binarySearch0 维护 low 与 high,通过中点值比较更新一侧边界,找到就返回,未找到返回负的插入位置编码。模型改用等价的中点计算写法,只处理整个数组并假定已排序;不模拟公开方法的范围校验和其它类型重载。

循环次数的关键不是每轮有多少行,而是候选区间如何缩小。未命中时至少排除中点及一侧不可能范围,剩余数量约减半;从 n 到一需要对数级轮数。数组索引和 int 比较采用常数成本模型,所以这段循环的最坏查找时间为 O(log n)。

二分查找的前提是数据按相同规则排好序,否则没有理由排除其中一半;有重复值时也不保证找到第一项。若每次查询前检查排序,就要额外做 O(n) 工作;若要最左位置,还需改写每轮保留的范围和停止条件。阅读 API 时要分清哪些前提由调用方保证。

实现返回插入位置的负编码,说明结果解释也是接口成本的一部分。调用者必须区分非负命中与负值未命中,不能直接把负值当数组下标。源码分析提供算法证据,但不能据此宣布某次实际业务请求延迟等于对数,因为读取、分配和外部 IO 可能占据主要时间。

代码先调用谁,再调用谁

  1. 公开 binarySearch 接口建立范围前提
  2. binarySearch0 初始化候选闭区间
  3. 比较中点并排除不可能一侧
  4. 返回命中下标或插入位置编码

04动手写一个例子

预处理前缀和实现重复区间求和

对固定 int 数组建立前缀和,查询左闭右开区间 [lo,hi) 的 long 总和。允许空数组和空区间,拒绝 null 与非法范围;构建后不受原数组修改影响。

输入 1
数组[2,-1,4],查询[1,3)
输出 1
3
输入 2
查询[0,3)
输出 2
5
输入 3
查询[2,2)
输出 3
0

建立长度 n+1 的前缀数组,让 prefix[0] 等于零,prefix[i+1] 等于前 i+1 项之和。额外的零位置让从数组起点开始的区间无需特殊分支,也让空区间自然得到零。每个输入元素只用于一次累积,构建成本线性。

区间 [lo,hi) 的和等于前 hi 项的和减去前 lo 项的和,共同前缀被抵消。这一恒等式依赖加法可逆,不意味着任何聚合都能用相减恢复,例如区间最大值不能通过两个前缀最大值相减得到。适用运算性质是数据结构选择的一部分。

返回 long 防止常见 int 总和溢出;对 Java int 数组的可表示长度与元素范围,整体和及任意区间和落在 long 范围内。构建后只保存累计数值,不保存原数组引用,因而原数组修改不会自动更新查询结果。它是固定快照,频繁更新时应选择不同结构。

为什么这样写是对的构建到位置 i 后,prefix[i] 精确等于原数组前 i 个元素之和。

时间 构建 O(n),每次查询 O(1),q 次总成本 O(n+q)。额外空间 O(n) 辅助存储,单次查询 O(1)。

构建遍历 n 次;每次查询仅做固定边界检查、两次数组读取和一次减法,不扫描区间长度。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 37 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
public class Main {
    static final class RangeSums {
        private final long[] prefix;
        RangeSums(int[] values) {
            if (values == null) throw new IllegalArgumentException("values");
            prefix = new long[Math.addExact(values.length,1)];
            // 多一个零前缀位置,让起点和空区间统一处理。
            for (int i = 0; i < values.length; i++)
                prefix[i+1] = prefix[i] + values[i];
        }
        long sum(int lo, int hi) {
            if (lo < 0 || hi < lo || hi >= prefix.length)
                throw new IllegalArgumentException("range");
            return prefix[hi] - prefix[lo]; // 抵消共同前缀。
        }
    }
    static void require(boolean condition) {
        if (!condition) throw new AssertionError();
    }
    public static void main(String[] args) {
        int[] input = {2,-1,4};
        RangeSums sums = new RangeSums(input);
        // 完整、部分、空区间和空输入各有确定结果。
        require(sums.sum(0,3) == 5);
        require(sums.sum(1,3) == 3);
        require(sums.sum(2,2) == 0);
        require(new RangeSums(new int[0]).sum(0,0) == 0);
        require(new RangeSums(new int[]{Integer.MAX_VALUE,Integer.MAX_VALUE})
                .sum(0,2) == 4294967294L);
        input[0] = 99; // 构建结果是数值快照,不保留可变输入引用。
        require(sums.sum(0,3) == 5);
        boolean rejected = false;
        try { sums.sum(2,1); } catch (IllegalArgumentException e) { rejected = true; }
        require(rejected);
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 空区间返回加法单位元零。
  • 空数组仍有一个零前缀位置。
  • 负数元素不破坏前缀差恒等式。
  • 原数组后续修改不会更新快照。
进阶选读 · 可选深入:频繁更新的区间查询

场景数据不断发生单点修改,仍需大量区间和。

做法Fenwick 树或线段树把更新与查询都组织为对数级路径,明确下标约定并保留单位元;根据更新形态再考虑懒标记。

复杂度常见单点更新与区间和各 O(log n),存储 O(n)。

好处与代价实现复杂度高于前缀和;只读数据不必为并不存在的更新能力付出维护成本。

05项目里怎么用

复杂度能先排除数据增多后工作量涨得太快的方案,实际提速仍需测量。小型性能实验要考虑热身、编译器优化、输入分布,以及结果是否真正被使用;业务测试则看完整请求耗时、吞吐和资源。小数据某次跑得快,不代表大规模增长更好,一次测量也不代表稳定表现。

把外部调用放进循环时,时间成本往往由网络或数据库决定,而不是本地几次比较。批量请求、索引和缓存可能改变调用次数,但同时带来一致性、内存和失效管理。复杂度表达式可以把调用成本写成变量,帮助团队看清真正值得优化的部分。

资源分析应包含输出规模和失败路径。一个理论上快速的算法若必须输出百万项,仍需要相应时间与内存;递归、临时数组和缓存也会占空间。对不可信输入应设合理上限,给出清晰拒绝或分页策略,而不是仅在文档写 O(n) 就认为任何 n 都可接受。

容易踩的坑

  • 只看到嵌套循环就判定平方复杂度。
  • 把摊还与概率平均混为一谈。
  • 忽略排序、索引建立和输出成本。
  • 把大 O 当成具体毫秒数或质量排名。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
直接扫描实现简单且无额外索引重复查询成本高查询少或数据频繁整体变化
前缀和预处理固定数据查询常数时间更新后需重建或维护大量只读区间和
动态树结构兼顾更新和查询实现与常数成本更高频繁局部更新并查询

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01O(n) 是否意味着一定比 O(n log n) 快?

展开参考答案与推导

不在所有有限规模上成立。常数、输入分布和实现质量会影响实际时间;渐近分析说明增长趋势,仍需结合数据规模和测量,不能把符号直接当作毫秒比较。

02两个指针的嵌套循环为何可能线性?

展开参考答案与推导

如果两个指针都只向前且各最多推进 n 次,总推进次数受线性界限制。应数整个执行过程的总动作,不能把语法嵌套层数机械相乘。

03前缀和为什么需要 n+1 个位置?

展开参考答案与推导

prefix[0] 表示零项之和,prefix[i] 表示前 i 项。这样区间和统一写成 prefix[hi]-prefix[lo],从零开始和空区间无需额外分支,边界也容易证明。

换个情况,还会处理吗

01二分查询很快但整个功能仍慢,可能漏算什么?

展开参考答案与推导

可能每次查询前都重新排序、读取远程数据或复制大数组。应计算完整生命周期成本,并判断排序和索引能否复用,不能只报告内部二分循环的复杂度。

02前缀和查到旧值,是线程问题吗?

展开参考答案与推导

先确认结构是否定义为固定快照。原数组修改不会自动更新累计值,这是当前接口语义;需要动态更新时应重建或选择更新结构,再讨论并发同步。

03大整数算法写 O(n),还应补什么前提?

展开参考答案与推导

说明 n 表示元素数还是数位数,并计入加乘除的数位成本。任意精度操作不是固定宽度机器指令,忽略值长度可能严重低估时间与空间。

写代码试一试

01验证负数区间

输入 / 条件
[-3,2,-1] 查询[1,3)
输出 / 预期
1

提示 前缀可下降。

展开参考答案与推导

建立该数组并断言结果为一。前缀和不要求单调,减去共同前缀仍成立;不能把前缀数组必须递增当成此算法的前提。

02给查询统计操作次数

输入 / 条件
任意长度区间
输出 / 预期
固定两次数组读取

提示 只统计定义好的基本操作。

展开参考答案与推导

在教学版本中为两次 prefix 读取分别计数,比较短区间与长区间次数相同。这个计数说明算法结构,不是测得 CPU 指令数,也不包括边界检查等未定义计费项目。

03为什么不能用前缀最大值做相减查询?

输入 / 条件
[5,1,2] 查询[1,3)
输出 / 预期
最大值2

提示 最大运算不可通过相减撤销。

展开参考答案与推导

前缀最大值在多个位置都为五,无法恢复后两项最大值二。需要稀疏表或线段树等适合最大聚合的结构,选择数据结构必须依据运算性质而非照搬求和公式。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能从代码统计操作数量,并区分渐近增长、单次成本和实际测量。

本篇重点加练

重点加练成本模型与摊还分析,要求解释输入规模和累计操作而不只背公式。

  • 输入规模与关键操作
  • 摊还成本

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    以线性查找为例分别定义输入规模和关键操作,写出上界、下界与紧确界陈述;每个陈述注明适用的输入集合和常数前提。

    提交内容
    符号定义、操作计数及三类界的表述。
    怎样算完成
    • 复杂度符号含义准确
    • 不把 O 当作实际毫秒数

    练到的知识点输入规模与关键操作 · O/Ω/Θ 定义

  2. 作业 02代码推演

    分析在数组中找首个目标的循环,输入长度 n,目标分别位于首项、末项及不存在;再明确一种随机输入分布推导平均比较次数。

    提交内容
    三类计数与平均情形前提。
    怎样算完成
    • 最好最坏平均分开
    • 平均结果依赖明确概率模型

    练到的知识点最好平均最坏情况 · 循环次数推导

  3. 作业 03原理问答

    字符串查找算法用 n 表示记录数,却忽略每条字符串长度。为该任务重新定义输入规模和关键操作,给出不同长度分布的成本表达。

    提交内容
    输入模型、计数项和比较样本。
    怎样算完成
    • 参数含义明确
    • 比较成本与记录数量分开

    练到的知识点输入规模与关键操作

综合应用

  1. 作业 04编程实践

    实现线性遍历、双重配对与有序二分三个小程序,加入操作计数,在 n=16、32、64 运行;同时记录额外数组和递归深度。

    提交内容
    Java 程序、自写结果测试、计数表及运行证据。
    怎样算完成
    • 比较工作量而非只看耗时
    • 空间计算不遗漏调用栈
    • 二分输入有序性被保证

    练到的知识点线性平方对数增长 · 额外空间与调用栈

  2. 作业 05原理问答

    设计动态数组连续追加的成本账本,给出一次昂贵扩容与一段连续操作的不同计量方式;说明摊还结论需要怎样的容量增长政策。

    提交内容
    容量序列、累计成本推导与前提。
    怎样算完成
    • 单次和累计成本分开
    • 增长策略改变后的边界明确

    练到的知识点摊还成本

  3. 作业 06面试场景

    接口在内存里用 O(n) 算法处理一百条记录,但逐条查询数据库,另一个实现增加排序却只发一次查询。制定端到端成本模型和公平测量计划。

    提交内容
    计算/分配/往返成本表与实验计划。
    怎样算完成
    • 远程等待纳入总成本
    • 不只按本地 Big-O 排优劣

    练到的知识点输入规模与关键操作 · 线性平方对数增长 · 数据库往返的整体成本

  4. 作业 07编程实践

    实现一个可选固定增量或成倍扩容的整数容器模型,只计数搬移量不追求库级性能;对同样的追加序列记录每次和累计成本。

    提交内容
    Java 模型、自写内容/容量边界测试与计数证据。
    怎样算完成
    • 两策略处理相同输入
    • 分配次数和搬移量分别计量

    练到的知识点输入规模与关键操作 · 摊还成本

  5. 作业 08代码推演

    某缓存平时插入常数步骤,每累计 k 次插入执行一次清理。用符号表示连续 n 次操作的总量,区分 k 固定与随 n 增长两种政策。

    提交内容
    累计成本账本和前提说明。
    怎样算完成
    • 昂贵操作次数被明确计算
    • 结论注明参数关系

    练到的知识点摊还成本

进阶挑战 · 掌握前置后选做

  1. 作业 09编程实践

    完成数组后:为重复区间求和实现扫描与预处理两个版本,输入含负数和空区间;给定不同查询次数比较总成本,并用独立参考核对。

    提交内容
    Java 实现、自写边界测试、总成本推导及运行证据。
    怎样算完成
    • 预处理成本计入
    • 时间空间权衡与查询量相关
    • 数值范围政策明确

    练到的知识点最好平均最坏情况 · 循环次数推导 · 额外空间与调用栈 · 摊还成本

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    系统关心的指标从总吞吐改为每次操作最多 2ms。重新评审摊还成本作为验收依据是否充分,提出还需测量或约束的部分。

    提交内容
    吞吐与延迟合同对照及实验设计。
    怎样算完成
    • 单次与累计保证分别陈述
    • 结论能对应业务时限

    练到的知识点输入规模与关键操作 · 摊还成本

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗