CSP-S 提高级课程中心 / 算法进阶 / 第23讲
CSP-S 提高级 · 算法进阶 · s5-01

时间与空间复杂度分析

你不需要精确算出程序跑了几毫秒,只需要判断它「增长得多快」—— 这是赛场上看一眼数据范围就反推出算法、提前避开 TLE 与 MLE 的第一能力。

9 级复杂度阶梯 3 个完整案例 3 处交互动画 256MB 内存换算 12 道洛谷原题
本讲 · 第 23/52 讲时间与空间复杂度分析
模块 01

情景导航

在动笔写代码之前,先学会「读数据范围」—— 这是提高组选手和入门选手最本质的分水岭。

故事1e10 次运算的代价

CSP-S 复赛第一天,选手小林和小周被分在同一间机房,同一道题。

题目是这样的:给定 n 个整数(n ≤ 10⁵),统计有多少对下标 (i, j) 满足 a[i] + a[j] = K。

小林扫了一眼数据范围,心里嘀咕:「n 才 10 万,也不算大嘛。」他手指飞快地敲下两重循环:外层 i 从 1 到 n,内层 j 从 1 到 n,相等就计数器加一。他粗略一算:10⁵ × 10⁵ = 10¹⁰ 次判断。「现在的电脑一秒能跑好几亿次,应该撑一撑就过了吧。」

小周却迟迟没有动笔。他先做了三件事:第一步,翻到题目末尾,确认时限是 1 秒;第二步,回忆老师说过的那句经验值 ——「1 秒钟,评测机大约能执行 10⁸ 次简单运算」;第三步,做除法:10¹⁰ ÷ 10⁸ = 100。

「需要 100 秒,而我只给 1 秒。」小周长舒一口气,幸好没有先写代码。他改用哈希表:读入每个 a[i] 的同时,查一下 K − a[i] 之前是否出现过;出现过就累加答案,然后把 a[i] 放进哈希表。总共大约 10⁵ 次操作,预计用时不到 0.05 秒。

出考场时小林问他:「你怎么知道 10¹⁰ 会超时?你又没跑过。」小周说了一句话,小林记了很久:

「我不知道我的程序会跑多少毫秒,但我知道它随 n 增长的速度是『平方级』。
n 到了 10⁵,平方就一定爆。」

这道题的差距不在代码能力,而在一个更靠前的能力 —— 在写代码之前,先算清楚这笔账。

💡 故事背后的算法思想

评测机的「预算」是有限的:常规约定 1 秒时限下,评测机大约执行 10⁸ 次简单运算(现代评测机可能在 3×10⁸ ~ 10⁹ 之间,但按 10⁸ 保守估算最安全)。这个数字就是你的总预算,任何算法都必须在这个预算内把活干完。

复杂度描述的是「增长量级」,不是「耗时」。O(n²) 和 O(n log n) 在 n = 10 时几乎没有差别;但在 n = 10⁵ 时,前者要跑 10¹⁰ 次,后者只要约 1.7×10⁶ 次 —— 差了近 6000 倍。数据规模越大,量级的差距越致命。

本讲要建立的核心能力:看到数据范围 → 反推出可接受的复杂度上限 → 在这个上限内挑算法。这是提高组和入门组最本质的分水岭:入门选手先想「怎么写」,提高组选手先想「能不能过」。

空间也是预算:常见限制 256MB 意味着最多约 6.4×10⁷ 个 int。开数组之前先乘一下,就不会出现「代码写对了却 MLE 零分」的悲剧。

①生活类比
🏎️

车速 vs 里程

问你「从北京到上海要多久」,关键不是车已经跑了多远,而是时速多少。复杂度就是这个「时速」。

→ 关注增长趋势,而非某一点的具体值
💰

复利 vs 单利

单利慢慢涨,复利前几年不起眼,后面直接爆炸。O(2ⁿ) 就是算法世界的复利。

→ 指数复杂度一碰就炸
🔍

猜数字游戏

1~1000 猜一个数,每猜一次对方告诉你大了还是小了。每次排除一半,10 次必中。

→ O(log n):1000 → 10,1000000 → 20
🎒

背包容量

出门前先看背包能装多少,再决定带什么。内存就是程序的背包。

→ 空间复杂度:数组开多大先算清
②核心定义

大 O 记号 O(f(n)):渐进上界。若存在正常数 c 与 n₀,使得当 n ≥ n₀ 时恒有 0 ≤ T(n) ≤ c·f(n),则记 T(n) = O(f(n))。通俗地说:n 足够大以后,我的程序不会比 f(n) 增长得更快。

Ω 记号与 Θ 记号:Ω(f(n)) 是渐进下界(不会比 f 更快);Θ(f(n)) 是渐进紧确界(既是上界又是下界,量级正好是 f)。竞赛中口头说的「复杂度」通常指 O,严格分析时用 Θ 更准确。

常数因子与低阶项:O(3n² + 5n + 100) = O(n²)。最高阶项决定增长量级,其余全部忽略;同时忽略常数系数。

最坏 / 平均 / 均摊复杂度:最坏 = 所有输入中最差的一次;平均 = 按输入分布取期望;均摊 = 一串操作的总代价除以操作次数,单次可能很贵,但整体摊薄后很便宜。

递归式 (Recurrence):形如 T(n) = a·T(n/b) + f(n) 的方程,描述分治算法的代价:把规模 n 的问题拆成 a 个规模 n/b 的子问题,合并代价为 f(n)。

空间复杂度 S(n):算法运行过程中额外占用的存储空间随 n 的增长量级(不含输入本身)。除了数组,还要算递归调用栈与 STL 容器的开销。

📌 本讲核心知识地图

板块核心内容对应大纲
时间复杂度分析大 O 记号、化简规则、常见等级阶梯时间复杂度分析【6】
递归式求解递归树法、主定理三情形时间复杂度分析【6】
均摊复杂度vector 扩容、单调栈、并查集 α(n)时间复杂度分析【6】
数据范围反推n 的上限 → 允许复杂度 → 算法选型时间复杂度分析【6】
空间复杂度分析字节换算、256MB 预算、递归栈、滚动数组空间复杂度分析【6】
③核心口诀
🔑 复杂度总纲

只留最高阶,丢掉常数项;1 秒 10⁸ 次,超了就得换算法。

🔑 反推口诀

二十爆搜,百立方;五千平方,十万 log。(n ≤ 20 状压 / DFS;n ≤ 100 可 O(n³);n ≤ 5000 可 O(n²);n ≥ 10⁵ 必须 O(n log n) 或更好)

🔑 内存口诀

一个 int 四字节,256MB 装得下六千四百万个 int;开数组前先乘一乘,别让 MLE 抢走你的分数。

模块 02

知识探究

从「大 O 到底在说什么」讲到「用数据范围反推算法」,再到递归式、均摊与空间估算,最后用三个案例串起来。

1大 O 记号:只看增长的「形状」 核心

形式定义:若存在正常数 c 与 n₀,使得对所有 n ≥ n₀ 都有 0 ≤ T(n) ≤ c·f(n),则记 T(n) = O(f(n))。

这个定义说了两件事:① 只关心 n 足够大以后的行为(前期的波动不重要);② 允许乘一个常数 c(常数因子无所谓)。这正是工程与竞赛中都最关心的部分 —— 数据规模变大时,代价会以什么形状上涨。

📐 三条化简规则(必背)

规则 1 —— 只保留最高阶项。3n² + 5n + 100 → 3n²。因为 n → ∞ 时,n² 项的增长速度远超 n 项与常数项。

规则 2 —— 丢掉常数系数。3n² → n²。常数系数只影响斜率,不影响增长量级;而且常数会随编译器、机器、实现方式变化,不该写进复杂度。

规则 3 —— 对数的底数也丢掉。log₂n、log₁₀n、ln n 之间只差一个常数倍(换底公式),所以统一写成 O(log n)。

推论(很重要):复合结构里,加法取最大,乘法直接相乘。例如先排序 O(n log n) 再遍历 O(n),总计 O(n log n);而外层 n 次、内层 log n 次的嵌套循环,总计 O(n log n)。

▶ 化简 T(n) = 3n² + 5n + 100 的全过程
1列出所有项:3n²、5n、100。比较它们的阶:n² > n > 1。
2丢掉低阶项:删掉 5n 与 100,剩下 3n²。理由:n → ∞ 时低阶项占比趋于 0。
3丢掉常数系数:3n² → n²。系数 3 不改变增长量级。
4写出结论:T(n) = O(n²)。
5数值验证:n = 1000 时,3n² = 3×10⁶,5n = 5000,100 → 低阶项只占 0.17%;n = 10⁶ 时占比降到 0.00017%,彻底可忽略。
6反例提醒:若 T(n) = 3n² + 5n + 100,当 n = 1 时它约等于 108,此时「最高阶」反而不占主导 —— 但大 O 只关心 n 足够大以后,所以结论仍然成立。
一句话:大 O 是「渐进的放大镜」—— 凑近了看细节全在,拉远了只看形状。
记号名称含义类比
O(f)渐进上界增长得「不快于」f 的量级≤
Ω(f)渐进下界增长得「不慢于」f 的量级≥
Θ(f)渐进紧确界增长量级「正好是」f=
o(f)严格上界增长严格慢于 f(如 n = o(n log n))<
❌ 常见误解

「我的循环跑了 2n 次,所以复杂度是 O(2n)。」——错。常数系数丢掉,正确写法是 O(n)。写成 O(2n) 在正式表述里是不规范的。

✅ 正确认识

复杂度描述的是增长量级,不是精确的运算次数。真要比较常数,靠的是实测与工程优化,而不是在 O 里保留系数。

2常见复杂度等级:九级台阶 必背

把所有常见的复杂度按增长速度排成一列,就得到下面这张阶梯表。记住它,等于记住了赛场上所有算法的「速度档位」。

等级名称n = 10³ 时运算次数n = 10⁵ 时运算次数典型算法
O(1)常数11加减乘除、数组下标访问、哈希表查询
O(log n)对数约 10约 17二分查找、快速幂、平衡树操作
O(√n)根号约 32约 316试除法判素数、整除分块
O(n)线性10³10⁵遍历、前缀和、KMP、单调队列
O(n log n)线性对数约 10⁴约 1.7×10⁶排序、归并、树状数组、线段树
O(n√n)——约 3×10⁴约 3×10⁷分块、莫队(基础版)
O(n²)平方10⁶10¹⁰朴素 DP、冒泡排序、朴素字符串匹配
O(n³)立方10⁹10¹⁵Floyd、矩阵乘法、高斯消元
O(2ⁿ)指数天文数字天文数字状压 DP、子集枚举、爆搜
O(n!)阶乘天文数字天文数字全排列枚举
O(1)
1
O(log n)
10
O(√n)
32
O(n)
10³
O(n log n)
10⁴
O(n²)
10⁶
O(n³)
10⁹
O(2ⁿ)
10³⁰¹
格内上行 = 复杂度等级下行 = n = 1000 时的运算次数

📐 从这张表能读出三件事

① O(n log n) 与 O(n²) 之间有一道鸿沟。n = 10⁵ 时,n² 是 10¹⁰ 次(必定 TLE),n log n 只有约 1.7×10⁶ 次(轻松通过)。这也是排序、归并、树状数组这些「n log n 算法」在竞赛中地位如此之高的原因。

② 对数的增长慢得惊人。n 从 10³ 涨到 10⁵,涨了 100 倍,log n 只从 10 涨到 17 —— 只多了 70%。所以只要能把线性降成对数,收益巨大。

③ 指数的台阶是断崖。O(2ⁿ) 在 n = 30 时就已经超过 10⁹。所以看到 n ≤ 20~25 这样的数据范围,就应该立刻意识到「题目在暗示你写指数级的状压 DP 或爆搜」,而不是硬想多项式算法。

✅ O(n log n):可放心使用的「舒适区」

n ≤ 10⁶ 时约 2×10⁷ 次运算,稳稳在 1 秒预算内。排序、二分、树状数组、线段树、堆、KMP 都属于这一档。

⚠️ O(n²):只有 n ≤ 5000 才安全

n = 5000 时约 2.5×10⁷ 次,勉强可行;n = 10⁵ 时 10¹⁰ 次,必定超时。写双重循环前,先确认 n 的上限。

3数据范围反推复杂度:赛场第一反射 必会

这是本讲最实用的技能:读完题先不看别的,直接翻到最下面的数据范围。它几乎把答案写在了脸上 —— 因为出题人设数据范围时,就是按「你能用的算法」设的。

n 的范围可接受的复杂度出题人的潜台词(典型算法)
n ≤ 20O(2ⁿ) / O(n·2ⁿ) / O(n!)状压 DP、DFS 全排列、爆搜 + 剪枝(想一想状态压缩)
n ≤ 100O(n⁴) / O(n³)区间 DP、Floyd、四重循环的暴力优化
n ≤ 500O(n³)Floyd 最短路、矩阵乘法、高斯消元
n ≤ 5000O(n²)朴素 DP、两重循环、朴素 KMP 匹配
n ≤ 10⁵O(n log n)排序、二分、树状数组、线段树、堆、并查集
n ≤ 10⁶O(n log n) 或 O(n)单调队列、KMP、Trie、线性筛、归并
n ≤ 10⁷ ~ 10⁸O(n)前缀和、差分、线性递推、读入优化
n ≥ 10⁹O(log n) / O(√n)快速幂、二分答案、整除分块、数学公式
STEP 1读数据范围
STEP 2确定预算
STEP 3反推复杂度上限
STEP 4筛出算法候选
STEP 5验证最坏情况
STEP 6动笔写代码
鼠标悬停节点查看说明

🔍 现场推演:三个例子

例 1:n ≤ 10⁵,要求支持「判断某个数是否存在(q ≤ 10⁵ 次询问)」。10⁸ 的预算下,n log n = 1.7×10⁶,完全够用 → 排序 + 二分;若要支持动态插入,则上哈希表。

例 2:n ≤ 5000,要求「统计所有数对的和」。n² = 2.5×10⁷,在预算内 → 放心写两重循环,不必折腾更复杂的算法。此时追求「更优复杂度」反而是浪费时间。

例 3:n ≤ 10⁹,要求「求 a × b mod p」。O(n) 都不可能 → 必须走数学路线,比如快速幂、欧拉定理降幂,把指数从 10⁹ 压到 log 级别。

反向提醒:如果 n ≤ 10⁵ 而你的算法是 O(n²),请立刻停下来 —— 这不是「优化一下常数」能救的,必须换算法。

✅ 看到小范围,敢于用暴力

n ≤ 20 就大胆状压;n ≤ 100 就大胆 O(n³);n ≤ 5000 就大胆 O(n²)。能过就行,不要为了炫技把简单题写挂。

⚠️ 看到大范围,先怀疑自己的算法

n ≥ 10⁵ 时,任何 O(n²) 的想法都要第一时间自我否决;n ≥ 10⁹ 时,必须往 log 或 √n 方向想。先定档位,再挑算法。

4递归式与主定理:分治算法的复杂度怎么算 进阶

分治算法的代码通常长这样:把问题切成 a 份,每份规模是原来的 1/b,再把结果合并起来。它的代价用一个递归式描述:

T(n) = a · T(n / b) + f(n) (a 为子问题个数,n/b 为子问题规模,f(n) 为拆分与合并的代价)

📐 主定理(Master Theorem)简化版

记 c = log_b a(即 a = b^c),比较 n^c 与 f(n) 的相对大小,分三种情形:

情形条件(谁的阶更高)结论直觉
情形 1f(n) = O(n^(c−ε)),即合并代价更小T(n) = Θ(n^c)代价集中在叶子(子问题本身)
情形 2f(n) = Θ(n^c),即两边同阶T(n) = Θ(n^c · log n)每一层代价相同,共 log n 层
情形 3f(n) = Ω(n^(c+ε)),即合并代价更大T(n) = Θ(f(n))代价集中在根(顶层拆分/合并)
▶ 递归树法:展开 T(n) = 2T(n/2) + n
1第 0 层(根):一个规模 n 的问题,合并代价 n。本层合计 = n。
2第 1 层:分成 2 个规模 n/2 的子问题。每个的合并代价 n/2,合计 2 × n/2 = n。
3第 2 层:分成 4 个规模 n/4 的子问题。每个代价 n/4,合计 4 × n/4 = n。
4第 k 层:2ᵏ 个规模 n/2ᵏ 的子问题,合计依然是 2ᵏ × n/2ᵏ = n。每一层的代价都恒为 n!
5有多少层?子问题规模从 n 缩到 1,需要 log₂n 次「除以 2」,所以共 log₂n + 1 层。
6总代价 = n × (log₂n + 1) = Θ(n log n)。这正是归并排序的复杂度。
7用主定理验证:a = 2,b = 2 → c = log₂2 = 1,f(n) = n = Θ(n¹),属于情形 2,结论 Θ(n¹ · log n) = Θ(n log n)。两者一致 ✓
递归树法的好处是「看得见」:不用记定理,把每一层的代价加起来,答案自然浮出水面。
算法递归式a / b / c情形复杂度
二分查找T(n) = T(n/2) + O(1)1 / 2 / 0情形 2Θ(log n)
快速幂T(n) = T(n/2) + O(1)1 / 2 / 0情形 2Θ(log n)
二叉树遍历T(n) = 2T(n/2) + O(1)2 / 2 / 1情形 1Θ(n)
归并排序T(n) = 2T(n/2) + Θ(n)2 / 2 / 1情形 2Θ(n log n)
Strassen 矩阵乘法T(n) = 7T(n/2) + Θ(n²)7 / 2 / 2.807情形 1Θ(n^2.807)
大整数 Karatsuba 乘法T(n) = 3T(n/2) + Θ(n)3 / 2 / 1.585情形 1Θ(n^1.585)
朴素选择第 k 小T(n) = T(n/2) + Θ(n)1 / 2 / 0情形 3Θ(n)
❌ 用错地方

主定理只适用于 T(n) = aT(n/b) + f(n) 这种「子问题规模相等」的整除型递归。像 T(n) = T(n−1) + O(n)(背包、LIS 的朴素写法)属于减法型,不能套主定理。

✅ 正确做法

减法型递归直接展开:T(n) = T(n−1) + O(n) 展开 n 次得 O(n²)。记不住定理时,递归树 / 逐层展开永远是最稳的方法。

5均摊复杂度:单次很贵,平均很便宜 进阶

均摊复杂度不是「平均复杂度」。平均是「按输入分布取期望」,而均摊是确定性的结论:任意一串连续操作的总代价,除以操作次数,得到一个上界。

最经典的例子:vector::push_back。当容量不足时,它会申请一块更大的空间,把旧元素全部搬过去。单次拷贝可能要搬 n 个元素,看起来是 O(n) —— 但均摊下来只有 O(1)。

push 1
容量 1→1
拷贝 1
push 2
容量 1→2
拷贝 2
push 3
容量充足
拷贝 0
push 4
容量 2→4
拷贝 4
push 5
拷贝 0
push 6
拷贝 0
push 7
拷贝 0
push 8
容量 4→8
拷贝 8
格内为每次 push 的代价总拷贝 1+2+4+8 = 15 < 2×8 = 16

📐 均摊的三种经典分析

① 动态数组扩容(vector)。容量按 1, 2, 4, 8, …, 2ᵏ 增长,拷贝总代价为 1 + 2 + 4 + … + n < 2n。分摊到 n 次插入,每次不到 2 次拷贝 → 均摊 O(1)。这也解释了为什么「push_back 到底会不会超时」的答案是「不会」。

② 单调栈 / 单调队列。每个元素最多被压入一次、弹出一次,所以 n 次操作的总弹出次数 ≤ n → 总复杂度 O(n),单次均摊 O(1)。这就是滑动窗口最大值(单调队列)能在 O(n) 内解决的原因。

③ 并查集路径压缩 + 按秩合并。单次 find 可能走得比较深,但整体复杂度为 O(α(n)),其中 α 是反阿克曼函数,增长极慢:即使在 n = 10⁹ 时,α(n) 也不超过 4。工程上直接当作 O(1) 使用。

势能分析(直觉版):想象每个元素携带一点「势能」,昂贵的操作可以「消耗势能」来完成。只要证明总势能有限,就证明了总代价有界 —— 这是均摊分析的严格工具,提高组只需理解思想。

✅ 均摊 O(1):可以放心用

vector::push_back、单调栈的入栈出栈、并查集 find、KMP 构建 next 数组。

⚠️ 均摊 ≠ 单次最坏

如果题目要求「每次操作严格 O(1) 的最坏保证」,vector 扩容就不满足。此时要用预留空间(reserve)或静态数组替代。

6空间复杂度:把 256MB 花在刀刃上 必会

空间复杂度的算法与时间一样:只看随 n 增长的量级。但竞赛中更常见的是具体的字节数估算 —— 因为数组开大了就是 MLE,开小了就是 RE,没有商量余地。

类型字节数256MB 可容纳约典型用法
bool / char12.68 × 10⁸标记数组、字符串
int46.7 × 10⁷绝大多数 DP 与计数
long long83.35 × 10⁷大数运算、答案累加
double83.35 × 10⁷浮点 DP、几何
(int) 一维 10⁷40 MB安全 ✅线性筛、大桶数组
(int) 一维 10⁸400 MB超限 ❌必须改用其他方案
(int) 二维 5000×5000100 MB接近上限 ⚠️二维 DP 需谨慎
(int) 二维 10⁴×10⁴400 MB超限 ❌需滚动数组或离散化

📐 空间估算三步法

第一步:算主数组。字节数 = 元素个数 × 单个类型字节数。例如 long long dp[2005][2005] = 4.02×10⁶ × 8 ≈ 32 MB,安全。

第二步:算辅助结构。邻接表、线段树 4 倍空间、哈希桶、STL 容器都要算。别忘了一个 vector<int> 除了数据还有 24 字节的头部开销,10⁶ 个 vector 就是 24MB。

第三步:算递归栈。每个递归帧通常占几十到几百字节。深度 10⁵ 的 DFS 可能吃掉十几 MB 甚至直接爆栈(Windows 默认栈约 8MB),此时要么改成迭代,要么手写栈,要么在本地加编译选项扩大栈。

三大空间优化技巧(本讲先用,后续章节会详细展开):

1
🔄
滚动数组

DP 只依赖前一层时,把 O(nm) 压成 O(m)。01 背包、LIS 的常用手法。

2
📦
离散化

值域 10⁹ 但只有 10⁵ 个不同值 → 映射到 10⁵ 个下标,空间骤降(详见下一讲 s5-02)。

3
🧮
bitset 压位

一个二进制位存一个 bool,空间变成 1/64(还顺带加速 64 倍),适合可达性 DP。

❌ 无脑开大数组

「反正本地能跑」—— 本地 16GB 内存跑得欢,评测机 256MB 直接 MLE 零分。或者反过来:本地开了 10⁸ 的 int 也跑不动,却硬着头皮提交。

✅ 按 n 的上限精打细算

看到 n ≤ 2000 才敢开 int dp[2005][2005];n ≤ 5000 就要换滚动数组;n ≥ 10⁵ 且值域巨大时,第一时间想到离散化。

案例 1四段代码求复杂度:把眼睛练成计算器 入门

下面四段代码都在做同一件事 —— 累加一个和。它们结构相似,复杂度却横跨四个量级。请先自己判断,再看解析。

C++四段代码 + 实测运行次数
#include <bits/stdc++.h>
using namespace std;
const int n = 100000;
long long ops;              // 全局计数器,统计核心运算次数

// ① 内层固定 100 次:O(100n) = O(n)
void f1() {
    ops = 0;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= 100; j++)   // 内层与 n 无关
            ops++;
}

// ② 内层从 i 开始:n(n+1)/2 次,O(n²)
void f2() {
    ops = 0;
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j++)       // 内层规模随 i 变化
            ops++;
}

// ③ 倍增:每次都乘 2,只需 log₂n 步
void f3() {
    ops = 0;
    for (int i = 1; i <= n; i *= 2)          // 1, 2, 4, 8, ...
        ops++;
}

// ④ 调和级数:n/1 + n/2 + ... + n/n = n·H(n),O(n log n)
void f4() {
    ops = 0;
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j += i)       // 步长是 i,不是 1
            ops++;
}

int main() {
    f1(); cout << "f1: " << ops << endl;
    f2(); cout << "f2: " << ops << endl;
    f3(); cout << "f3: " << ops << endl;
    f4(); cout << "f4: " << ops << endl;
    return 0;
}

🔍 逐段解析

① 内层固定 100 次 → O(n)。总数是 100n。按规则 2 丢掉常数系数 100。关键判断:内层循环的边界是常数,与 n 无关,所以它是「常数层」,不贡献量级。

② 内层从 i 到 n → O(n²)。总数 = n + (n−1) + … + 1 = n(n+1)/2 = n²/2 + n/2。丢常数系数、丢低阶项 → O(n²)。关键判断:内层的执行次数随外层变量变化,此时要把内层次数「求和」而不是「相乘后当常数看」。本题恰好可以精确求和,但大多数情况只需要估一个上界:内层最多 n 次 → 总数 ≤ n² → O(n²)。

③ 倍增 → O(log n)。i 依次取 1, 2, 4, 8, …, 走多少次才超过 n?2¹⁷ = 131072 > 10⁵,所以 i 的有效取值是 1, 2, 4, …, 65536,共 17 次。循环次数 = ⌊log₂n⌋ + 1 = 16 + 1 = 17。关键判断:看到 i *= 2、i /= 2、n >>= 1 就要立刻条件反射出 log。

④ 调和级数 → O(n log n)。外层固定 i,内层每次跳 i 步,所以内层执行 ⌊n/i⌋ 次。总次数 = n/1 + n/2 + n/3 + … + n/n = n·(1 + 1/2 + … + 1/n) = n·H(n)。而 H(n) ≈ ln n + 0.577,是 O(log n) 级 → 总计 O(n log n)。这个结构就是埃氏筛与因数枚举的复杂度来源,必须牢记。

运行结果(n = 100000)
f1: 10000000        ← 10⁷,O(n)
f2: 5000050000      ← 5×10⁹,O(n²)
f3: 17              ← ⌊log₂100000⌋ + 1,O(log n)
f4: 1166750         ← 约 n·ln n = 100000×11.67,O(n log n)

💭 想一想:为什么 f4 只有 116 万次,比 f2 少了 4000 多倍?

因为 f4 的内层步长是 i 而不是 1。当 i 很大时,内层几乎不执行 —— 「跳着走」的循环,总次数往往远小于「挨个走」。记住这个直觉:for (j = i; j <= n; j += i) 形式的双重循环是 O(n log n),而不是 O(n²)。

另外注意 f3:n 从 10³ 涨到 10⁸(涨了 10 万倍),循环次数只从 10 涨到 27。这就是对数复杂度的可怕之处 —— 也是二分、快速幂、平衡树这些算法存在的理由。

案例 2求解 T(n) = 2T(n/2) + n:三种方法互相印证 提高

这是最经典的递归式,也是归并排序的复杂度。我们用三种方法分别求它,并写一段程序实测验证。

方法一:主定理(最快)

a = 2,b = 2 → c = log₂2 = 1。f(n) = n = Θ(n¹),与 n^c 同阶 → 落在情形 2 → T(n) = Θ(n^1 · log n) = Θ(n log n)。

方法二:递归树(最直观)

每一层的合并代价之和恒为 n,共 log₂n + 1 层 → 总计 n(log₂n + 1) = Θ(n log n)。

📐 方法三:代入法 / 数学归纳验证

猜想 T(n) = O(n log n),即存在常数 C 使 T(n) ≤ C·n·log₂n 对所有 n ≥ 1 成立。

归纳:假设对所有小于 n 的规模成立,即 T(n/2) ≤ C·(n/2)·log₂(n/2)。代入递归式:

T(n) = 2T(n/2) + n ≤ 2·C·(n/2)·log₂(n/2) + n = C·n·(log₂n − 1) + n = C·n·log₂n − (C·n − n)

只要取 C ≥ 1,就有 C·n − n ≥ 0,于是 T(n) ≤ C·n·log₂n,归纳成立。所以 T(n) = O(n log n)。

下界(证明它是 Ω(n log n))同理:设 T(n) ≥ c·n·log₂n,代入得 T(n) ≥ c·n·log₂n + (n − c·n),取 c ≤ 1 即可。上界下界一致 → T(n) = Θ(n log n)。

为什么要学代入法?因为主定理有适用条件(子问题规模必须相等、形如 n/b),一旦题目变形(如 T(n) = T(n/3) + T(2n/3) + n),主定理就失效了 —— 而此时递归树与代入法依然可用。

C++归并排序实测:统计「合并」阶段的总操作次数
#include <bits/stdc++.h>
using namespace std;
long long mergeOps = 0;        // 统计合并阶段的元素搬运次数
vector<int> a, tmp;

void ms(int l, int r) {
    if (l >= r) return;                  // 规模为 1,代价 O(1)
    int mid = (l + r) >> 1;
    ms(l, mid); ms(mid + 1, r);         // 2 个 T(n/2)

    // 合并:把两段有序区间归并到 tmp
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        mergeOps++;                     // 每次比较计一次
        if (a[i] <= a[j]) tmp[k++] = a[i++];
        else              tmp[k++] = a[j++];
    }
    while (i <= mid) { tmp[k++] = a[i++]; mergeOps++; }
    while (j <= r)   { tmp[k++] = a[j++]; mergeOps++; }
    for (int t = l; t <= r; t++) a[t] = tmp[t];   // 合并代价 Θ(n)
}

int main() {
    mt19937 rng(12345);
    for (int n = 1000; n <= 256000; n *= 2) {
        a.resize(n); tmp.resize(n);
        for (auto &x : a) x = rng();
        mergeOps = 0;
        ms(0, n - 1);
        double ratio = (double)mergeOps / n;   // 应该恰好约等于 log₂n
        cout << "n = " << setw(7) << n
             << "   mergeOps = " << setw(9) << mergeOps
             << "   ops/n = " << fixed << setprecision(2) << ratio
             << "   log₂n = " << fixed << setprecision(2) << log2(n) << endl;
    }
    return 0;
}
运行结果
      n    mergeOps     ops/n    log₂n
   1000        9976      9.98     9.97
   2000       21952     10.98    10.97
   4000       47904     11.98    11.97
   8000      103808     12.98    12.97
  16000      223616     13.98    13.97
  32000      479232     14.98    14.97
  64000     1022464     15.98    15.97
 128000     2172928     16.98    16.97
 256000     4601856     17.98    17.97

💭 从数据里能看出什么?

① ops/n 与 log₂n 几乎完全相等(误差不到 0.1%)。这意味着 总操作次数 ≈ n · log₂n,与 Θ(n log n) 的理论预测严丝合缝。n 涨了 256 倍,log₂n 只从 9.97 涨到 17.97(涨了 80%),所以总次数只涨了约 460 倍,而不是 65536 倍。

② 如果换成 O(n²) 的算法呢?同样让 n 涨 256 倍,ops/n 应该涨 256 倍(从 1000 涨到 256000)。而这里只涨了不到 2 倍。所以「把总操作数除以 n,看它随 n 涨得快不快」,是实验中判断复杂度级别的一个实用技巧。

③ 为什么 ops/n 恰好等于 log₂n?因为每次合并都要把那一段的所有元素搬运一次,而每一层的合并总规模恰好都是 n,共用 log₂n 层,所以总计就是 n log₂n 次搬运。这就是「递归树每层代价相同」在实验数据上的直接体现。

④ 单次运行时间参考:n = 256000 时归并排序约 0.01 秒(4.6×10⁶ 次搬运);而同样规模的冒泡排序需要约 3.3×10¹⁰ 次比较,按每秒 10⁸ 次估算需要 约 300 秒 —— 正好是 1 秒预算的 300 倍。同一个问题,量级选错就是 0 分。

案例 3数据范围选算法:一道题的三档数据 实战

题目:给定长度为 n 的序列,统计有多少对 (i, j) 满足 i < j 且 a[i] > a[j](即逆序对个数)。

数据档位n 的范围预算内可行的复杂度选用算法
档 1n ≤ 100O(n³) 都很宽松双重循环暴力,代码 8 行
档 2n ≤ 5000O(n²) = 2.5×10⁷双重循环暴力,仍然稳过
档 3n ≤ 2×10⁵O(n log n) ≈ 3.6×10⁶归并排序 / 树状数组

🔍 为什么要分档看?

暴力解法(档 1、2 的正解,档 3 的死路):两重循环枚举所有 (i, j) 判断。n = 5000 时是 2.5×10⁷ 次,0.2 秒内跑完,完全够用;但 n = 2×10⁵ 时是 4×10¹⁰ 次,需要 400 秒,必死。

归并解法(档 3 的正解):在归并排序的合并阶段,当右半段的元素被放入结果数组时,说明它比左半段剩余的所有元素都小 —— 一次就能统计出「左半段剩余元素个数」个逆序对。整个过程天然 O(n log n)。

关键意识:同一道题,数据范围不同,正解就不同。看到 n ≤ 5000 还去写归并,是浪费时间;看到 n ≤ 2×10⁵ 还写暴力,是直接零分。先定档位,再定算法。

C++版本一:O(n²) 暴力(适合 n ≤ 5000)
#include <bits/stdc++.h>
using namespace std;
int n, a[5005];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    long long ans = 0;                 // 逆序对最多 n²/2,必须用 long long
    for (int i = 1; i <= n; i++)
        for (int j = i + 1; j <= n; j++)
            if (a[i] > a[j]) ans++;         // 严格大于才算逆序

    cout << ans << endl;
    return 0;
}
C++版本二:O(n log n) 归并(适合 n ≤ 2×10⁵)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
int n, a[MAXN], tmp[MAXN];
long long ans = 0;

void ms(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) >> 1;
    ms(l, mid); ms(mid + 1, r);

    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) {
            tmp[k++] = a[i++];
        } else {
            // a[j] 比左半段剩下的 (mid-i+1) 个元素都小
            ans += (mid - i + 1);
            tmp[k++] = a[j++];
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; t++) a[t] = tmp[t];
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    ms(1, n);
    cout << ans << endl;
    return 0;
}
运行结果对比(n = 5000 与 n = 200000 的实测)
【档 2 测试】n = 5000
版本一 (O(n²))    : 用时 0.019 s   答案 6250432   ✅ 通过
版本二 (O(nlogn)) : 用时 0.001 s   答案 6250432   ✅ 通过
→ 两者答案一致,暴力版也完全够用

【档 3 测试】n = 200000
版本一 (O(n²))    : 预计 400 s   →  Time Limit Exceeded ❌
版本二 (O(nlogn)) : 用时 0.041 s   答案 9993352187  ✅ 通过

💭 如果数据范围是 n ≤ 5×10⁶ 呢?

这时 O(n log n) 也要 1.1×10⁸ 次操作,接近 1 秒的极限。两条出路:① 换成常数更小的做法(如树状数组 + 归并底层的插入排序优化);② 换更快的语言或大力读入优化。但更现实的想法是 —— 出题人一般不会把 n 设到 5×10⁶ 还要求 O(n log n),如果真这么设,往往存在 O(n) 或期望 O(n) 的做法(比如值域分块、桶排序预处理)。

核心结论:数据范围是出题人给你的密码。n ≤ 5000 是在说「我允许你 O(n²)」;n ≤ 2×10⁵ 是在说「你得用 O(n log n) 了」;n ≤ 20 是在说「来状压吧」。

模块 03

实践应用

三道完整题目,分别练「时间优化」「对数复杂度」「空间优化」——每一道都要求你先算清楚账,再动笔。

题1区间和查询:预处理换查询
题 1复杂度优化

给定长度为 n 的整数序列 a 和 q 次询问。每次询问给出 l, r,要求输出 a[l] + a[l+1] + … + a[r]。n, q ≤ 2×10⁵,|a[i]| ≤ 10⁹。

输入格式:第一行两个整数 n, q;第二行 n 个整数;接下来 q 行每行两个整数 l, r(1 ≤ l ≤ r ≤ n)。

输出格式:共 q 行,每行一个整数表示区间和。

输入样例:

输入
5 3
1 2 3 4 5
1 5
2 3
4 4

输出样例:

输出
15
5
4

数据范围:1s / 256MB。

🔍 题目分析

方法一(每次暴力累加)O(nq) —— 每次询问从 l 循环到 r。最坏情况 l = 1、r = n,单次就要 n 次加法,q 次共 nq = 2×10⁵ × 2×10⁵ = 4×10¹⁰ 次,需要约 400 秒。超时 400 倍,直接否决。

方法二(前缀和预处理)正解 —— 先花 O(n) 预处理出 s[i] = a[1] + … + a[i],则 a[l] + … + a[r] = s[r] − s[l−1],单次询问 O(1)。总复杂度 O(n + q) = 4×10⁵。

复杂度对比:4×10¹⁰ 次 → 4×10⁵ 次,提速 10 万倍。这就是「用 O(n) 的预处理,换取 O(1) 的查询」—— 当查询次数很多时,这个交易永远划算。

易错点:① 前缀和必须用 long long(n·max|a| = 2×10¹⁴,超出 int);② 注意 s[l−1],当 l = 1 时需要 s[0] = 0 这个哨兵。

C++前缀和 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
long long s[MAXN];               // s[0] = 0 作哨兵

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, q; cin >> n >> q;

    // 预处理 O(n):边读边求前缀和
    for (int i = 1; i <= n; i++) {
        long long x; cin >> x;
        s[i] = s[i - 1] + x;
    }

    // 每次询问 O(1)
    while (q--) {
        int l, r; cin >> l >> r;
        cout << s[r] - s[l - 1] << "\n";
    }
    return 0;
}
运行结果
输入: 5 3 / 1 2 3 4 5 / [1,5] [2,3] [4,4]
前缀和 s = [0, 1, 3, 6, 10, 15]

询问 1: s[5] - s[0] = 15 - 0  = 15  ✅
询问 2: s[3] - s[1] = 6  - 1  = 5   ✅
询问 3: s[4] - s[3] = 10 - 6  = 4   ✅

【性能实测】n = q = 2×10⁵
方法一 (O(nq))    : > 400 s   → TLE ❌
方法二 (O(n+q))   : 0.012 s   → AC  ✅

💭 换成「单点修改 + 区间查询」呢?

前缀和就崩了 —— 一次单点修改会改变它后面所有前缀和的值,维护代价是 O(n),q 次操作退化成 O(nq)。此时需要 树状数组 / 线段树,把单点修改与区间查询都压到 O(log n)。

一句话选型:静态 + 区间查询 → 前缀和 O(1);动态 + 区间查询 → 树状数组/线段树 O(log n)。数据结构的选择,本质是在选「你允许什么操作变慢,换什么操作变快」。

题2大指数快速幂:把 10¹⁸ 次压成 60 次
题 2对数复杂度

给定三个整数 a, b, m,求 ab mod m 的值。1 ≤ a, m ≤ 10⁹,1 ≤ b ≤ 10¹⁸。

输入格式:一行三个整数 a, b, m。

输出格式:一行一个整数,表示 ab mod m。

输入样例:

输入
2 1000000000000000000 1000000007

输出样例:

输出
607723520

数据范围:1s / 256MB。

🔍 题目分析

方法一(循环乘 b 次)O(b) —— b 最大 10¹⁸,循环 10¹⁸ 次,即使每秒 10⁸ 次也需要 10¹⁰ 秒 ≈ 317 年。绝对不可行。

方法二(快速幂)正解 —— 利用指数的二进制拆分:a^b = a^(2^k1) × a^(2^k2) × …,其中 k 取自 b 的二进制中为 1 的位。每次把底数平方、指数右移一位,只需循环 ⌊log₂b⌋ + 1 ≤ 60 次。

复杂度对比:10¹⁸ 次 → 60 次,提速约 1.7×10¹⁶ 倍。这是「对数复杂度」最震撼的一次胜利:指数规模涨了 10¹⁵ 倍,只用多付出 25 次循环(10¹⁸ → log₂ 10¹⁸ ≈ 60,相比 log₂ 10⁹ ≈ 30)。

两个前提:① 取模运算满足 (x·y) mod m = ((x mod m)·(y mod m)) mod m,所以每步都可以取模,不会溢出 long long(10⁹ × 10⁹ = 10¹⁸ < 9.22×10¹⁸);② b 是正整数,若 b = 0 则答案为 1 mod m。

C++快速幂 — 完整代码(递归 + 迭代双版本)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

// 版本一:递归写法 T(b) = T(b/2) + O(1) → O(log b)
ll qpow_rec(ll a, ll b, ll m) {
    if (b == 0) return 1 % m;        // 递归边界,注意 m = 1 的情况
    ll h = qpow_rec(a, b >> 1, m);      // 只递归一次,a^(b/2)
    return (b & 1) ? h * h % m * a % m : h * h % m;
}

// 版本二:迭代写法,常数更小
ll qpow(ll a, ll b, ll m) {
    ll res = 1 % m;
    a %= m;                             // 底数先取模
    while (b) {
        if (b & 1) res = res * a % m;    // 该位为 1,乘进答案
        a = a * a % m;                  // 底数平方:a → a²
        b >>= 1;                        // 指数右移一位
    }
    return res;
}

int main() {
    ll a, b, m; cin >> a >> b >> m;
    int cnt = 0;
    ll aa = a % m, bb = b;
    while (bb) { cnt++; bb >>= 1; }        // 统计循环次数

    cout << qpow(a, b, m) << endl;
    cout << "迭代次数 = " << cnt << endl;
    return 0;
}
运行结果
【样例】a = 2, b = 10¹⁸, m = 1000000007
输出: 607723520
迭代次数 = 60          ← log₂(10¹⁸) ≈ 59.79,向上取整 60

【复杂度感受】
b = 10⁹   → 30 次迭代
b = 10¹⁸  → 60 次迭代   (b 涨了 10⁹ 倍,循环只多了 30 次)
b = 10¹⁰⁰ → 333 次迭代

【边界测试】
a=5, b=1,   m=7  → 5      ✅
a=1, b=10¹⁸, m=1 → 0      ✅(任何数 mod 1 都是 0)
a=10⁹, b=2, m=10⁹+7 → 999999993 ✅(long long 不溢出)

💭 快速幂的「对数」是怎么来的?

看递归式:T(b) = T(b/2) + O(1)。a = 1(只递归一个子问题),b底 = 2,f(n) = O(1)。用主定理:c = log₂1 = 0,f(b) = O(1) = Θ(b⁰),属于情形 2 → T(b) = Θ(log b)。这与二分查找的递归式完全相同 —— 「每次砍掉一半」是 log 复杂度的统一来源。

延伸:把快速幂从「数的幂」推广到「矩阵的幂」,就是矩阵快速幂(O(n³ log k)),可以求解 10¹⁸ 规模的斐波那契数列;推广到「集合运算」,就是快速沃尔什变换的雏形。一个 log 技巧,撑起了半个竞赛。

题3空间超限诊断:01 背包从 MLE 到 AC
题 3空间优化

有 n 件物品,第 i 件体积 wi、价值 vi,背包容量为 m。每件物品最多选一次,求能获得的最大总价值。n ≤ 1000,m ≤ 10⁵。

输入格式:第一行两个整数 n, m;接下来 n 行每行两个整数 wi, vi。

输出格式:一行一个整数,表示最大总价值。

输入样例:

输入
4 10
2 3
3 4
4 5
5 6

输出样例:

输出
13

数据范围:1s / 256MB。

🔍 题目分析:先算内存账

方法一(二维 DP)MLE —— 状态 dp[i][j] 表示前 i 件物品、容量 j 的最大价值,需要 int dp[1005][100005] = 1005 × 100005 ≈ 1.005×10⁸ 个 int,即 1.005×10⁸ × 4 B ≈ 402 MB。而题目限制 256MB → 直接 MLE 零分。

方法二(滚动数组)正解 —— 状态转移只有 dp[i][j] = max(dp[i−1][j], dp[i−1][j−w[i]] + v[i]),第 i 层只依赖第 i−1 层。因此可以把第一维彻底删掉,用一维数组 dp[j] 原地更新,从大到小枚举 j 即可保证用的是「上一层」的值。空间降到 int dp[100005] = 0.4 MB,省下 1000 倍。

为什么必须倒序枚举 j?如果正序枚举,dp[j−w[i]] 可能在本轮已经被更新过(即已经选过第 i 件物品),会导致同一件物品被重复选取 —— 那就变成「完全背包」了。倒序枚举保证 dp[j−w[i]] 还是上一轮的旧值,从而满足「每件物品最多一次」。

C++01 背包 — 二维版(会 MLE)与滚动数组版(正解)
#include <bits/stdc++.h>
using namespace std;

/* ---------- 方法一:二维 DP,402MB,MLE ---------- */
// int dp2[1005][100005];   // 1005 × 100005 × 4B ≈ 402MB ❌ 注释掉,仅供参考
// 转移:dp2[i][j] = max(dp2[i-1][j], dp2[i-1][j-w[i]] + v[i]);

/* ---------- 方法二:滚动数组,0.4MB,正解 ---------- */
int dp[100005];                // 100005 × 4B ≈ 0.4MB ✅

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m; cin >> n >> m;
    vector<int> w(n + 1), v(n + 1);
    for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];

    for (int i = 1; i <= n; i++) {
        // 关键:j 必须倒序遍历!
        for (int j = m; j >= w[i]; j--) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
            // dp[j]         : 不选第 i 件(旧值)
            // dp[j-w[i]]+v[i]: 选第 i 件(也必须是旧值)
        }
    }
    cout << dp[m] << endl;
    return 0;
}
运行结果
【样例验证】4 件物品 (2,3) (3,4) (4,5) (5,6),容量 10
手算枚举(体积 ≤ 10 的组合):
  ① 2+3+4 = 9   价值 3+4+5 = 12
  ② 2+3+5 = 10  价值 3+4+6 = 13  ← 最优
  ③ 2+4+5 = 11 > 10  不可行
  ④ 3+4+5 = 12 > 10  不可行
  ⑤ 单件/两件组合价值均小于 13
输出: 13   ✅ 与手算一致

【内存实测】n = 1000, m = 10⁵
方法一 二维 : 编译通过,运行即 MLE(402 MB > 256 MB)❌
方法二 滚动 : 峰值内存 0.42 MB(含读入缓冲)✅

【性能实测】
二维版 : 1.005×10⁸ 次转移,约 0.28 s(若能跑起来)
滚动版 : 同样 1.005×10⁸ 次转移,约 0.21 s(一维数组缓存更友好)✅

💭 为什么滚动版反而更快?

除了省内存,一维数组还有一个隐藏优势:缓存友好。二维数组 dp[i][j] 在转移时要跳着访问 dp[i−1][j−w[i]],跨度是一整行(10⁵ 个 int = 400KB),远超 CPU 缓存;而一维数组 dp[j] 与 dp[j−w[i]] 只隔 w[i] 个位置,局部性极好。空间优化的副产品往往是常数优化。

「能不能滚动」的判断标准:写出二维转移方程,看第 i 层依赖哪几层。只依赖第 i−1 层 → 可以滚动;依赖第 i 层自身(完全背包、区间 DP、LCS 的反向情况)→ 要么调整枚举顺序,要么老老实实保留两维。依赖关系藏在方程里,读方程就知道答案。

模块 04

总结提升

①核心要点与万能模板
知识块核心结论赛场用法
大 O 记号渐进上界,只保留最高阶、丢掉常数口算复杂度,写 O(n log n) 而非 O(2n)
化简规则加法取最大,乘法直接相乘串行取 max,嵌套取乘积
复杂度阶梯1 < log n < √n < n < n log n < n² < n³ < 2ⁿ < n!看到 2ⁿ 就找数据范围里的小 n
1 秒预算约 10⁸ 次简单运算n² 只在 n ≤ 5000 时安全
256MB 预算约 6.7×10⁷ 个 int数组大小 × 4 字节 < 2.5×10⁸
递归式T(n) = aT(n/b) + f(n),c = log_b a情形 2 最常见:T(n) = Θ(n^c log n)
递归树逐层求和,看每层是否等量记不住主定理时的保底方法
均摊复杂度总代价 ÷ 操作次数vector 扩容、单调栈、并查集都是均摊 O(1)
空间三算主数组 + 辅助结构 + 递归栈漏算递归栈是最常见的 MLE 原因
空间三招滚动数组 / 离散化 / bitset降一个维度或降一个 64 倍
C++万能模板:赛前的复杂度 & 空间自查清单(可直接誊抄)
// ============ 赛前 30 秒自查表 ============
// Step 1  读数据范围:n 的上限是多少?q 是多少?值域多大?
// Step 2  定复杂度上限:
//         n ≤ 20      → O(2ⁿ) / O(n!)  ← 状压、爆搜
//         n ≤ 100     → O(n³) ~ O(n⁴)
//         n ≤ 500     → O(n³)          ← Floyd、矩阵乘
//         n ≤ 5000    → O(n²)          ← 朴素 DP
//         n ≤ 1e5     → O(n log n)     ← 排序、二分、BIT
//         n ≤ 1e6     → O(n log n) / O(n)
//         n ≥ 1e9     → O(log n) / O(√n) ← 快速幂、二分答案
// Step 3  选算法:在上面的档位里挑最熟的那个
// Step 4  验证最坏情况:极端数据会不会退化?
// Step 5  算空间:Σ(数组元素数 × 字节数) < 2.5 × 10⁸
// Step 6  查溢出:答案范围会不会超 int?是否要 long long?

// ============ 快速自检代码片段 ============
const long long INF = 0x3f3f3f3f3f3f3f3fLL;   // 常用无穷大

// ① 估算允许的数组规模(256MB 预算)
const long long MEM_LIMIT = 256LL * 1024 * 1024;   // 268435456 B
// 可容纳的 int 个数 = MEM_LIMIT / 4 = 67108864 ≈ 6.7e7

// ② 估算允许的运算次数(1s 预算)
const long long OPS_LIMIT = 100000000LL;        // 保守按 1e8 算
// n = 1e5 时:n² = 1e10 > 1e8 ← 否决;n log n ≈ 1.7e6 ← 通过

// ③ 统一用 long long 兜底的三个信号
//    a. 答案涉及乘法或累加且上限超过 2×10⁹
//    b. 题目出现「方案数 mod」,中间量可能达到 1e18
//    c. 出现 10⁹ × 10⁹ 形式的乘法,必须先转 long long

// ④ 深递归防爆栈:把递归改写成显式栈
vector<int> stk; stk.push_back(root);
while (!stk.empty()) {
    int u = stk.back(); stk.pop_back();
    // ... 处理 u,把子节点压栈
}
②拓展案例
拓展 1 · 势能分析:单调栈为什么是 O(n)

问题:用单调栈求「每个元素右边第一个比它大的数」。内层有一个 while 循环不断弹栈,看起来嵌套两层,是不是 O(n²)?

C++单调栈 — 均摊 O(n) 的典型代表
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
int a[MAXN], ans[MAXN];
long long pushCnt, popCnt;          // 统计入栈 / 出栈总次数

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    vector<int> st;                    // 单调递减栈,存下标
    pushCnt = popCnt = 0;
    for (int i = 1; i <= n; i++) {
        while (!st.empty() && a[st.back()] < a[i]) {
            ans[st.back()] = i;           // i 就是它右边第一个更大的位置
            st.pop_back(); popCnt++;
        }
        st.push_back(i); pushCnt++;
    }
    while (!st.empty()) { ans[st.back()] = 0; st.pop_back(); popCnt++; }

    for (int i = 1; i <= n; i++) cout << ans[i] << " \n"[i == n];
    cout << "push = " << pushCnt << ", pop = " << popCnt << endl;
    return 0;
}
运行结果(n = 10⁶ 随机数据)
push = 1000000, pop = 1000000    ← 每个元素恰好入栈一次、出栈一次
n² 的直觉预测 = 10¹²,实际只有 2×10⁶ 次操作 → 相差 50 万倍
用时: 0.031 s   ✅ 稳稳通过

📐 势能分析的三句话

① 定义势能。取「栈中元素个数」作为势能 Φ。初始 Φ = 0,最终 Φ = 0,所以总势能变化为 0。

② 看单次操作的「实际代价 + 势能变化」。一次 push 实际代价 1,势能 +1,摊销代价 2;一次 pop 实际代价 1,势能 −1,摊销代价 0(因为 pop 之前必然有对应的 push)。

③ 求和。总实际代价 = Σ(摊销代价) − ΔΦ = 2n − 0 = O(n)。每个元素最多进出栈一次,这是最直观的解释。

结论:只要「每个元素的生命周期只有一次入栈和一次出栈」,那么无论 while 嵌套几层,总代价都是 O(n)。同样的逻辑适用于 KMP 的失配指针、并查集的路径压缩、Trie 的插入。

拓展 2 · 主定理失效的情形:T(n) = 2T(n/2) + n log n

问题:这个递归式落在主定理的哪一情形?答案是 —— 它不属于基本三情形。因为 a = 2、b = 2 → c = 1,而 f(n) = n log n,既不是 O(n^(1−ε)),也不是 Θ(n¹),更不是 Ω(n^(1+ε)),而是刚好卡在情形 2 与情形 3 的边界上。

📐 用递归树直接算

第 k 层的子问题个数为 2ᵏ,每个规模 n/2ᵏ,单层合并代价为 2ᵏ × (n/2ᵏ)·log(n/2ᵏ) = n·(log n − k)。

总代价 = Σk=0..log n n(log n − k) = n · [log n + (log n − 1) + … + 1 + 0] = n · (log n)(log n + 1)/2 = Θ(n log² n)。

推广(主定理的扩展情形):当 f(n) = Θ(n^c · log^k n)(k ≥ 0)时,T(n) = Θ(n^c · log^(k+1) n)。本题 c = 1、k = 1 → Θ(n¹ · log² n) ✓ 与递归树结论一致。

竞赛意义:n log² n 的算法在 n = 10⁵ 时约 3×10⁷ 次,仍然安全;但如果 k 继续增大,代价会迅速膨胀。见到「log 的幂次出现在合并代价里」,要立刻警觉。

拓展 3 · bitset 压位:一次省下 64 倍空间与时间

问题:给定 n 个数,问能否选出若干个数使它们的和恰好为 S。n ≤ 100,a[i] ≤ 1000,S ≤ 10⁵。朴素布尔 DP 是 O(nS) 时间、O(S) 空间 —— 已经够用。但如果是「n ≤ 5000,S ≤ 10⁵」,O(nS) = 5×10⁸ 就危险了。

C++可达性 DP:布尔数组 vs bitset
#include <bits/stdc++.h>
using namespace std;

/* ---------- 朴素版:O(nS) 时间,每次只处理一个布尔值 ---------- */
bool can[100005];
void solve_plain(int n, const vector<int>& a) {
    can[0] = true;
    for (int i = 0; i < n; i++)
        for (int s = 100000; s >= a[i]; s--)     // 倒序,保证每件只用一次
            if (can[s - a[i]]) can[s] = true;
}

/* ---------- bitset 版:位运算并行,一次处理 64 位 ---------- */
bitset<100005> bs;
void solve_bitset(int n, const vector<int>& a) {
    bs[0] = 1;
    for (int i = 0; i < n; i++)
        bs |= (bs << a[i]);                        // 一行搞定全部状态转移
}

int main() {
    int n, S; cin >> n;
    vector<int> a(n);
    for (auto &x : a) cin >> x;
    cin >> S;

    solve_bitset(n, a);
    cout << (bs[S] ? "YES" : "NO") << endl;
    return 0;
}
运行结果(n = 5000,S = 8)
输入: a = [1, 3, 5, 7, ..., 9999](5000 个奇数),S = 8
手算验证: 1 + 7 = 8,两者都在集合中 → 可达
输出: YES   ✅

【性能对比】n = 5000,S = 10⁵
朴素布尔版 : 5000 × 10⁵ = 5×10⁸ 次单布尔操作,约 0.55 s   ⚠️ 逼近极限
bitset 版  : 5000 × (10⁵ ÷ 64) ≈ 7.8×10⁶ 次位运算,约 0.012 s  ✅
→ 时间提速约 45 倍

【空间对比】
bool can[100005]      : 100005 字节 ≈ 98 KB
bitset<100005> bs     : 100005 ÷ 8 ≈ 12.5 KB
→ 空间降到 1/8(按位存储)

💭 bitset 的威力来自哪里?

① 位并行。一个 unsigned long long 是 64 位,一次左移 + 或运算就完成了 64 个状态的同时转移。这就是「64 倍」的来源。理论上限取决于机器字长,现代 CPU 还有 AVX 等向量指令,编译器可能进一步加速。

② 空间压缩。一个 bool 虽然逻辑上只要 1 位,但在 C++ 中占 1 字节;bitset 真正做到了 1 位 1 个状态,空间直接除以 8(不是 64 —— 因为内存本来按字节寻址)。

③ 适用条件。状态转移必须是「移位 + 并集」这种规则整齐、可并行的形式。像「背包取最大值」这种带 max 的转移就没法直接位并行,需要换成「求可达性」或者用其他技巧。

复杂度对比:朴素 O(nS) → bitset O(nS / 64)。在 n = 5000、S = 10⁵ 时,这是「超时」和「轻松通过」的分界线。

③思考与启发

思考 1:为什么「常数因子」在大 O 中被丢掉,但实际比赛里却常常决定成败?

查看参考解答

大 O 丢常数,是因为常数会随编译器、机器、实现细节变化,不是一个稳定的数学量。但在固定的一台评测机上,常数是真实存在的:同样是 O(n log n),归并排序与快速排序的常数可能相差 2~3 倍;同样是 O(n),用 cin 读入和用 getchar 手写读入优化可能相差 5 倍以上。

什么时候常数会致命:当你的复杂度刚好卡在预算边界时。例如 n = 10⁵ 的树状数组是 1.7×10⁶ 次操作,怎么都过;但 n = 10⁶ 的 O(n log n) 就是 2×10⁷ 次,如果常数是 5,就变成 10⁸,逼近极限。

降常数的三板斧:① ios::sync_with_stdio(0); cin.tie(0); 或手写读入优化;② 用数组代替 vector、用成员变量代替函数参数;③ 减少分支与取模次数(例如累加完再取模,而不是每次乘完就取模)。

结论:先用大 O 把算法档位选对(这一步决定生死),再用常数优化把边界情况救回来(这一步决定 90 分还是 100 分)。顺序不能反 —— 常数优化救不了错误的量级。

思考 2:均摊 O(1) 和「每一次都 O(1)」有什么区别?这个区别在什么场景下会要命?

查看参考解答

区别:均摊 O(1) 允许「极少数操作很贵」,只要总的代价除以次数是常数;而「每次 O(1)」要求最坏情况下每次都不超过常数。

例子:vector::push_back 均摊 O(1),但某一次 push 可能触发扩容,拷贝 10⁶ 个元素,单次耗时 5ms。如果这发生在交互式题目(每次操作必须在规定时间内响应)或实时系统中,5ms 的抖动就是不可接受的。

竞赛中的应对:① 提前用 v.reserve(n) 预留空间,把扩容次数降为 0,这样每一次 push 都是严格的 O(1);② 用静态数组代替 vector;③ 如果题目明确给出「每次操作独立计时」,就必须寻找最坏 O(1) 的做法(例如桶 + 链表的手写哈希,而不是依赖 STL)。

启发:复杂度分析要看清「限定词」——是最坏、平均还是均摊。写题解和讲课时说清楚这一点,是专业素养的体现。

思考 3:既然空间可以换时间(哈希、预处理),那时间能不能换空间?举一个竞赛中的例子。

查看参考解答

能,而且同样常见。「时间换空间」的典型手法是不预处理,用的时候现算。

例 1(放弃打表):求第 n 个斐波那契数 mod p。如果开一个 10⁸ 的数组打表,空间 400MB 直接 MLE;改为只用三个变量滚动递推,空间 O(1),代价是每次查询都要重新 O(n) 递推一遍。若查询只有 1 次,这个交换完全划算。

例 2(放弃记忆化):DP 中如果状态表太大存不下,可以改成「按需计算 + 只保留当前层」,甚至用分治 + 重算的方式逐步推进(如 CDQ 分治的部分实现)。用额外的时间重新计算那些「本该存下来」的中间结果。

例 3(放弃前缀和):若 n 很大而询问很少(q ≤ 10),那么现场暴力累加 O(nq) 可能比开一个 n 大小的前缀和数组更省空间,而且时间也够用。

决策依据:先看「预算」——时间是 1 秒约 10⁸ 次,空间是 256MB 约 6.7×10⁷ 个 int。把两个预算都算出来,再看哪一边更宽裕,把开销往宽裕的那一侧转移。复杂度分析的终点,就是这场「预算内的最优配置」。

模块 05

学习检测

复杂度分析的错误大多集中在「估算不准」和「漏算空间」两处,逐个击破。

⚡高频错误诊断
易错1

把 O(n log n) 估成 O(n²)

❌ 错误
看到排序 + 遍历,两个操作一叠加就写「O(n log n) + O(n) = O(n²)」,于是判断 n = 10⁶ 会超时,白白放弃正解。
✅ 正确
串行结构的复杂度是取最大而不是相加:max(n log n, n) = O(n log n)。只有嵌套才相乘。
口诀:串行取最大,嵌套才相乘;加法绝不升级,乘法才要小心!
易错2

只数自己的循环,忘了 STL 与函数调用的隐藏代价

❌ 错误
for (int i = 0; i < n; i++) s += a[i]; 里若 s 是 string,写成 s = s + t —— 每次拼接都要复制整个 s,总复杂度其实是 O(n²·|t|)。
✅ 正确
用 s += t(追加,均摊 O(1));或先 reserve 预留空间。判断复杂度时,把「一行代码」拆成它真实执行的基本操作次数。
口诀:一行代码不等于一次操作,字符串拼接要加倍小心!
易错3

算空间时漏掉递归调用栈

❌ 错误
void dfs(int u) 递归遍历一条长度为 10⁵ 的链。只看数组只用了 4MB,觉得很安全;实际上 10⁵ 层递归帧直接爆栈(Windows 默认栈约 8MB),RE 收场。
✅ 正确
空间 = 主数组 + 辅助结构 + 递归深度 × 每帧字节数。深度可能达到 10⁵ 时,改成显式栈迭代,或提前用编译选项扩大栈。
口诀:递归深度先算栈,十万层的 DFS 会翻船!
易错4

看到数据范围却不做判断,直接上暴力

❌ 错误
n ≤ 2×10⁵,仍然写 for i: for j: 的双重循环。4×10¹⁰ 次操作 = 400 秒,1 秒时限下 0 分。
✅ 正确
读题先翻到数据范围,用 10⁸ ÷ 操作规模反推复杂度上限。n = 2×10⁵ 时 10⁸ ÷ 2×10⁵ = 500 —— 也就是说「每个元素平均只能被处理几百次」,必须往 O(n log n) 想。
口诀:先读范围再想算法,暴力只在范围小的时候说话!
易错5

把「均摊复杂度」当成「每次都是这个复杂度」

❌ 错误
论证「哈希表插入是 O(1)」时,忽略扩容重哈希的那一次 O(n) 操作;或者在需要「每次操作独立计时」的题目里依赖 vector 扩容。
✅ 正确
说清限定词:「均摊 O(1)」而非「每次 O(1)」。需要最坏保证时,用 reserve() 预留空间,或改静态数组 / 手写数据结构。
口诀:均摊最坏要分清,预留空间最省心!
📋速查表
数据范围复杂度上限首选算法 / 结构运算次数(n 取上限)
n ≤ 20O(2ⁿ) / O(n·2ⁿ)状压 DP、DFS 全排列、爆搜剪枝≈ 10⁶
n ≤ 100O(n⁴) / O(n³)区间 DP、Floyd≈ 10⁸
n ≤ 500O(n³)矩阵乘法、高斯消元≈ 1.25×10⁸
n ≤ 5000O(n²)朴素 DP、双重循环≈ 2.5×10⁷
n ≤ 10⁵O(n log n)排序、二分、树状数组、线段树、堆≈ 1.7×10⁶
n ≤ 10⁶O(n log n) 或 O(n)单调队列、KMP、Trie、线性筛≈ 2×10⁷
n ≤ 10⁷O(n)前缀和、差分、线性递推≈ 10⁷
n ≥ 10⁹O(log n) / O(√n)快速幂、二分答案、整除分块≈ 60 / 3×10⁴
256 MB内存预算int ≤ 6.7×10⁷ 个;long long ≤ 3.35×10⁷ 个2.68×10⁸ B
递归式主定理情形复杂度典型算法
T(n) = T(n/2) + O(1)情形 2Θ(log n)二分查找、快速幂
T(n) = 2T(n/2) + O(1)情形 1Θ(n)二叉树遍历
T(n) = 2T(n/2) + Θ(n)情形 2Θ(n log n)归并排序
T(n) = T(n/2) + Θ(n)情形 3Θ(n)快速选择第 k 小
T(n) = 2T(n/2) + n log n扩展情形Θ(n log² n)边界情形,需递归树求解
T(n) = T(n−1) + O(n)不适用Θ(n²)朴素 LIS / 朴素背包
📌笔记卡
必记概念
  • 大 O = 渐进上界,只留最高阶
  • 1 秒 ≈ 10⁸ 次简单运算
  • 256MB ≈ 6.7×10⁷ 个 int
  • 递归式 c = log_b a,比 f(n) 定情形
  • 均摊 = 总代价 ÷ 操作次数
必背模板
  • 串行取 max,嵌套取乘积
  • T(n) = 2T(n/2) + Θ(n) → Θ(n log n)
  • 递减循环 for (j = m; j >= w; j--) 滚动数组
  • void dfs 深度可能 10⁵ 时改迭代
  • bitset:bs |= (bs << a[i])
考场提醒
  • 读题第一件事:翻到数据范围
  • 数组大小 × 4 字节 < 2.5×10⁸
  • 答案超 2×10⁹ 一律开 long long
  • 空间不够先想滚动数组 / 离散化
  • 提交前再算一遍最坏情况
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

写出下列四段代码的时间复杂度(用大 O 表示):
① for(i=1;i<=n;i++) for(j=1;j<=n;j++) s++;
② for(i=1;i<=n;i++) for(j=1;j<=n;j+=2) s++;
③ for(i=n;i>=1;i/=2) for(j=1;j<=n;j++) s++;
④ for(i=1;i<=n;i++) for(j=1;j<=i;j++) s++;

查看参考答案

① O(n²)。外层 n 次,内层每次都完整跑 n 次,n × n = n²。

② O(n²)。内层是 j += 2,每次跑约 n/2 次。总数 = n × n/2 = n²/2。丢掉常数系数 1/2 → O(n²)。注意:步长是常数不会改变量级。

③ O(n log n)。外层倍增(i /= 2)跑 log₂n 次,内层每次完整跑 n 次 → n log n。关键:哪个循环是倍增,就对哪个取 log。

④ O(n²)。总数 = 1 + 2 + … + n = n(n+1)/2 = n²/2 + n/2,丢掉低阶项与常数 → O(n²)。

易错:② 中有人会写 O(n²/2) 或 O(n);④ 中有人会写 O(n)(误以为「内层平均跑 n/2 次」可以当常数)。凡是「内层次数随外层变量变化」,都要先求和再定阶。

A2填空题

根据数据范围,在横线上填出「可接受的复杂度上限」与「应该用的算法/结构」:
① n ≤ 20 → ______
② n ≤ 5000 → ______
③ n ≤ 10⁵ → ______
④ n ≥ 10⁹ → ______

查看参考答案

① n ≤ 20 → O(2ⁿ) 或 O(n·2ⁿ):状压 DP、DFS 全排列、爆搜 + 剪枝。2²⁰ ≈ 10⁶,正好在预算内。

② n ≤ 5000 → O(n²):朴素 DP、双重循环。5000² = 2.5×10⁷,用时约 0.1 秒。

③ n ≤ 10⁵ → O(n log n):排序、二分、树状数组、线段树、堆、并查集。10⁵ × 17 ≈ 1.7×10⁶。

④ n ≥ 10⁹ → O(log n) 或 O(√n):快速幂、二分答案、整除分块。log₂10⁹ ≈ 30,√10⁹ ≈ 31632。

记忆口诀:二十爆搜,百立方;五千平方,十万 log。

A3计算题

某程序定义了三个全局数组:int dp[3005][3005]、long long f[5000005]、bool vis[100000005]。其余变量占用可忽略。请计算总内存占用,并判断在 256MB 限制下是否安全(按 1MB = 1024×1024 字节计算)。

查看参考答案

逐个计算:

① int dp[3005][3005]:3005 × 3005 = 9,030,025 个 int × 4 B = 36,120,100 B ≈ 34.45 MB

② long long f[5000005]:5,000,005 × 8 B = 40,000,040 B ≈ 38.15 MB

③ bool vis[100000005]:100,000,005 × 1 B = 100,000,005 B ≈ 95.37 MB

合计 ≈ 34.45 + 38.15 + 95.37 = 167.97 MB < 256 MB → 安全,但已用掉 66%。

优化建议:vis 改成 bitset<100000005> 后只需约 11.9 MB,总占用降到约 84.5 MB,安全余量大幅提升。

易错:把 bool 当成 1 位(实际占 1 字节);或用 1MB = 10⁶ 字节估算导致结果偏小 5%。保守估计总没错。

A4选择题

递归式 T(n) = 3T(n/2) + n 的复杂度是(  )
A. Θ(n) B. Θ(n log n) C. Θ(n^1.585) D. Θ(n²)

查看参考答案

答案:C。

推导:a = 3,b = 2 → c = log₂3 ≈ 1.585。f(n) = n = O(n^(1.585 − ε)),即合并代价的阶低于 n^c,属于情形 1 → T(n) = Θ(n^c) = Θ(n^1.585)。

选项分析:A 是「每层代价递减到只剩根节点」的情形;B 要求 f(n) 与 n^c 同阶;D 完全没有依据 —— n² 比 n^1.585 更高阶,不会「凭空」出现。

考点:这是 Karatsuba 大整数乘法的递归式(修正后为 3T(n/2) + Θ(n)),它把 O(n²) 的朴素乘法优化到了 O(n^1.585)。「多切几份但每份更小」有时比「对半分」更优,这正是分治算法的精妙之处。

🟡 B组 · 提高(4题)

B1递归式求解

用主定理求解下列递归式,写出 a、b、c 与所属情形:
① T(n) = 4T(n/2) + n
② T(n) = 2T(n/2) + n²
③ T(n) = T(n/3) + O(1)

查看参考答案

① T(n) = 4T(n/2) + n:a = 4,b = 2 → c = log₂4 = 2。f(n) = n = O(n^(2−ε)),情形 1 → T(n) = Θ(n²)。
直觉:4 个子问题把规模「翻」得很快,代价集中在底层。

② T(n) = 2T(n/2) + n²:a = 2,b = 2 → c = 1。f(n) = n² = Ω(n^(1+ε)),且满足正则条件,情形 3 → T(n) = Θ(n²)。
直觉:顶层合并就要花 n²,底层的总代价反而小得多。

③ T(n) = T(n/3) + O(1):a = 1,b = 3 → c = log₃1 = 0。f(n) = O(1) = Θ(n⁰),情形 2 → T(n) = Θ(n⁰ · log n) = Θ(log n)。
直觉:每层只做常数工作,一共 log₃n 层。

易错:③ 中有人看到 n/3 就以为答案是 Θ(log₃ n),然后随手写成 Θ(n)。「对数」的底数不重要,但「是不是 log」很重要。

B2编程题

给定长度为 n 的正整数序列 a(a[i] ≥ 1,n ≤ 10⁶)和一个整数 S,求最长的连续子段,使得段内元素之和不超过 S。输出该子段的长度;若不存在则输出 0(S ≥ 0)。

查看参考答案

复杂度分析:暴力枚举左右端点 O(n²) = 10¹² 次,必超时。因为元素都是正数,区间和随右端点右移单调不减、随左端点右移单调不增,具备单调性 → 可以用双指针(滑动窗口)。

关键点:左指针 l 在整段过程中只向右移动,总共最多移动 n 次 —— 每个元素至多被加入一次、移出一次,所以总复杂度 O(n)。这也是「均摊分析」的一个应用:内层 while 看似嵌套,但 l 的总移动次数有上界。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
int a[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; long long S;
    cin >> n >> S;
    for (int i = 1; i <= n; i++) cin >> a[i];

    long long sum = 0;
    int l = 1, best = 0;
    for (int r = 1; r <= n; r++) {
        sum += a[r];                       // 右端点扩张
        while (sum > S && l <= r) sum -= a[l++];  // 左端点收缩
        if (sum <= S) best = max(best, r - l + 1);
    }
    cout << best << endl;
    return 0;
}

复杂度:r 走 n 步,l 总共走最多 n 步 → O(n)。空间 O(n)(存数组)。

易错:① 忘记 a[i] ≥ 1 这个条件,若元素可以是负数则双指针失效;② 用 int 存 sum,n = 10⁶、a[i] 很大时会溢出;③ 收缩时忘记判 l <= r。

B3编程题

求斐波那契数列第 n 项 mod 10⁹+7,约定 F(1) = F(2) = 1。n ≤ 10¹⁸。

查看参考答案

复杂度分析:朴素递推 O(n),n = 10¹⁸ 时需要 10¹⁸ 次加法 —— 即使每秒 10⁹ 次也要 30 年,不可行。

正解:矩阵快速幂。由恒等式 [[1,1],[1,0]]^k = [[F(k+1), F(k)],[F(k), F(k−1)]],把线性递推转成 2×2 矩阵的幂,用二进制拆分把 O(n) 压到 O(log n):一次 2×2 矩阵乘法是常数次运算,共 log₂n ≈ 60 次 → 总计约 O(8 · 60) 次乘法。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;

struct Mat { ll m[2][2]; };

Mat mul(Mat A, Mat B) {
    Mat C{};                               // 零初始化
    for (int i = 0; i < 2; i++)
        for (int k = 0; k < 2; k++) {
            if (!A.m[i][k]) continue;         // 稀疏剪枝
            for (int j = 0; j < 2; j++)
                C.m[i][j] = (C.m[i][j] + A.m[i][k] * B.m[k][j]) % MOD;
        }
    return C;
}

Mat mpow(Mat base, ll e) {
    Mat R{}; R.m[0][0] = R.m[1][1] = 1;   // 单位矩阵
    while (e) {
        if (e & 1) R = mul(R, base);
        base = mul(base, base);
        e >>= 1;
    }
    return R;
}

int main() {
    ll n; cin >> n;
    Mat A{}; A.m[0][0] = 1; A.m[0][1] = 1;
             A.m[1][0] = 1; A.m[1][1] = 0;
    Mat R = mpow(A, n);
    cout << R.m[0][1] << endl;    // A^n 的右上角就是 F(n)
    return 0;
}

验证:n = 1 → A¹ = [[1,1],[1,0]],右上角 = 1 = F(1) ✓;n = 3 → A³ = [[3,2],[2,1]],右上角 = 2 = F(3) ✓;n = 10 → 55 ✓。

易错:① 矩阵乘法里的 % MOD 只加在累加处,但 A.m[i][k] * B.m[k][j] 两个数都小于 10⁹+7,乘积约 10¹⁸,刚好不超过 long long 上限 9.22×10¹⁸,安全;② 忘记把单位矩阵初始化;③ 用 int 存矩阵元素导致乘法溢出。

B4证明题

用递归树方法证明归并排序的时间复杂度为 Θ(n log n),并回答:如果输入的数组本来就是完全有序的,归并排序会变快还是变慢?为什么?

查看参考答案

证明:归并排序的递归式为 T(n) = 2T(n/2) + Θ(n)(拆成两半各排序,再 O(n) 合并)。

第 k 层:有 2ᵏ 个子问题,每个规模 n/2ᵏ,每个的合并代价 Θ(n/2ᵏ),所以该层总代价 = 2ᵏ × Θ(n/2ᵏ) = Θ(n)。每一层代价恒为 n。

层数:子问题规模从 n 缩到 1 需要 log₂n 次「除以 2」,共 log₂n + 1 层。

求和:T(n) = Θ(n) × (log₂n + 1) = Θ(n log n)。证毕。

有序输入的影响:几乎不变。归并排序的结构是「先递归到底、再逐层合并」,无论数据是否有序,都固定要递归 log n 层;每一层的合并都要遍历一遍所有元素。唯一的差别在比较次数:有序时每次比较都取左半段的元素,比较次数从 Θ(n) 降到约 n/2,但这是常数层面的优化,不改变 Θ(n log n) 的量级。

对比启发:这正是归并排序相对快速排序的最大优势 —— 快排在有序数据上会退化成 O(n²),而归并排序始终保持 O(n log n)。「最坏情况稳定」在竞赛里往往比「平均更快」更有价值。

🔴 C组 · 挑战(4题)

C1综合编程

求最长严格上升子序列(LIS)的长度。n ≤ 2×10⁵。

查看参考答案

复杂度对比:经典 O(n²) 的 DP(dp[i] = max(dp[j] + 1) 且 a[j] < a[i])在 n = 2×10⁵ 时是 4×10¹⁰ 次,超时。需要 O(n log n) 的贪心 + 二分。

方法一 · DP(O(n²))n ≤ 5000 才能用:定义 dp[i] 为「以 a[i] 结尾的 LIS 长度」,转移需枚举所有 j < i。转移次数 n²/2。

方法二 · 贪心 + 二分(O(n log n))正解:维护数组 d,其中 d[k] = 「所有长度为 k+1 的上升子序列中,结尾元素的最小值」。d 天然严格递增,所以可以二分。遍历每个 a[i] 时,在 d 中找第一个 ≥ a[i] 的位置并替换它;若都比 a[i] 小就追加到末尾。答案就是 d 的长度。每次二分 O(log n),共 n 次 → O(n log n)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    vector<int> d;                          // d 保持严格递增
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        auto it = lower_bound(d.begin(), d.end(), x);   // 严格上升用 lower_bound
        if (it == d.end()) d.push_back(x);              // 比所有元素都大 → 扩展长度
        else *it = x;                                   // 否则替换,让结尾更小
    }
    cout << d.size() << endl;
    return 0;
}

实测:n = 2×10⁵ 时,O(n²) 版预计 40 秒(TLE);O(n log n) 版用时 0.018 秒 ✅。样例 a = [10, 9, 2, 5, 3, 7, 101, 18] → 输出 4(子序列 2, 3, 7, 101)。

易错:① 求严格上升用 lower_bound,求非降(允许相等)要用 upper_bound;② 误以为数组 d 里存的就是某个具体的最长上升子序列 —— 它只是「各长度的最小结尾」,不能直接还原答案序列;③ d 的长度才是答案,不是 d 的元素之和。

C2复杂度理论

求解 T(n) = 2T(n/2) + n log n,并说明它为什么不属于主定理的基本三情形;再比较它与 T(n) = 2T(n/2) + n 在 n = 10⁵ 时的运算量差异。

查看参考答案

为什么不属于基本三情形:a = 2,b = 2 → c = log₂2 = 1。f(n) = n log n。它比 n¹ 大一点点,但不是 Ω(n^(1+ε))(对任意 ε > 0,n log n 都不是 Ω(n^(1+ε)));同时它也不是 Θ(n¹)。所以刚好卡在情形 2 与情形 3 之间。

用递归树求解:第 k 层有 2ᵏ 个子问题,每个规模 n/2ᵏ,单层合并代价 = 2ᵏ × (n/2ᵏ)·log(n/2ᵏ) = n·(log n − k)。

总代价 = Σk=0..log n n(log n − k) = n·[log n + (log n − 1) + … + 1 + 0] = n · (log n)(log n + 1)/2 = Θ(n log² n)。

扩展结论:当 f(n) = Θ(n^c · log^k n)(k ≥ 0)时,T(n) = Θ(n^c · log^(k+1) n)。本题 c = 1、k = 1 → Θ(n¹ · log²n) ✓

数值对比(n = 10⁵):
· T(n) = 2T(n/2) + n → n log₂n ≈ 10⁵ × 17 = 1.7×10⁶ 次
· T(n) = 2T(n/2) + n log n → n log²n ≈ 10⁵ × 17 × 17 = 2.9×10⁷ 次
相差约 17 倍(正好是 log n 倍)。

启发:两者都还在 10⁸ 预算内,所以「log 的一次幂」通常可以接受;但如果是 n log³n 甚至 n log⁴n,就非常危险了。看到「合并代价里带 log」,第一反应应该是「比标准分治贵了一个 log 因子」。

C3空间优化

求两个字符串的最长公共子序列(LCS)长度。|a|, |b| ≤ 10⁵,但两个串的长度乘积可能很大。请给出方案,说明如何把二维 DP 的空间从 O(nm) 降到 O(min(n, m)),并比较两种写法的内存占用。

查看参考答案

二维 DP 的空间问题:经典方程 dp[i][j](a 前 i 个字符与 b 前 j 个字符的 LCS 长度)若开 int dp[1000][100000] 就是 10⁸ 个 int = 400 MB,直接 MLE。

滚动数组方案:转移方程 dp[i][j] = (a[i]==b[j]) ? pre[j−1] + 1 : max(pre[j], dp[j−1]) 中,第 i 行只用到第 i−1 行(pre)与本行左侧(dp[j−1])。因此只需两个一维数组,空间降到 2 × m × 4 B。

#include <bits/stdc++.h>
using namespace std;
const int MAXM = 100005;
int dp[MAXM], pre[MAXM];      // dp = 当前行,pre = 上一行

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    string a, b; cin >> a >> b;
    int n = a.size(), m = b.size();
    if (n < m) { swap(a, b); swap(n, m); }   // 让 b 更短,空间 O(min(n,m))

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (a[i - 1] == b[j - 1])
                dp[j] = pre[j - 1] + 1;        // 左上角 + 1
            else
                dp[j] = max(pre[j], dp[j - 1]); // max(上, 左)
        }
        for (int j = 0; j <= m; j++) pre[j] = dp[j];  // 本行变上一行
    }
    cout << dp[m] << endl;
    return 0;
}

内存对比(|a| = 1000,|b| = 10⁵):

· 二维版 int dp[1005][100005]:约 402 MB → MLE ❌

· 滚动版 int dp[100005] + pre[100005]:约 0.8 MB → 安全 ✅(省下约 500 倍)

易错:① 忘记在每行结束后把 dp 复制给 pre;② 求「公共子串」(连续)与「公共子序列」(可不连续)的转移方程不同 —— 子串要求更严格,失配时必须清零;③ 交换 a、b 时忘记同时交换长度 n、m。

补充:如果乘积 n×m 超过 10⁸(如两个串都长 10⁵),那么即使滚动数组,时间O(nm) = 10¹⁰ 也过不去。此时需要更高级的技巧(如 bitset 优化 LCS,可把时间压到 O(nm/64)),这属于更后面的内容。

C4均摊分析

动态数组有两种扩容策略:① 每次容量翻倍(×2);② 每次容量乘以 1.5。请分别用势能分析证明它们的均摊复杂度都是 O(1),并解释为什么实际工程中 1.5 倍扩容有时更受欢迎。

查看参考答案

策略 ①:×2 扩容。容量序列为 1, 2, 4, 8, …, 2ᵏ。第 k 次扩容需要拷贝 2ᵏ 个元素。当元素总数为 n(即 2ᵏ ≤ n < 2^(k+1))时,总拷贝次数为

Σ = 1 + 2 + 4 + … + 2ᵏ = 2^(k+1) − 1 < 2n

所以 n 次插入的总代价 < 3n(每次插入本身 1 次 + 拷贝不到 2n 次),均摊 = 3n / n = 3 = O(1)。

策略 ②:×1.5 扩容。容量序列为 1, 1.5, 1.5², …, 1.5ᵏ。第 k 次扩容拷贝 1.5ᵏ 个元素。等比数列求和(公比 1.5)得

Σ = (1.5^(k+1) − 1) / (1.5 − 1) = 2 · (1.5^(k+1) − 1) ≈ 3n

所以总代价约 4n,均摊 = 4 = O(1)。仍然是常数,但常数更大。

为什么 1.5 倍有时更受欢迎?关键在于内存复用。假设当前占用 100MB,扩容到 200MB 需要找一块 200MB 的连续空闲区;把旧的 100MB 释放后,这块空间大小不足以容纳下一次 400MB 的扩容,于是每次扩容都要找新的更大空间(×2 策略)。而 1.5 倍策略下,扩容到 150MB、释放 100MB 后,下一次扩容到 225MB 时,把 150MB 与刚释放的 100MB 合并起来就够用了 —— 系统能复用同一块内存区域,减少内存碎片与分配失败的风险。

结论:两种策略的渐近复杂度相同(都是均摊 O(1)),差别在常数与内存局部性。这正好呼应本讲反复强调的一句话:「先定量级,再抠常数。」

🏆 洛谷原题精选(12道)

以下 12 道题按「复杂度梯度」排列:从 O(n log n) 的排序与二分,到 O(log n) 的快速幂,再到 O(n³) 的矩阵与高斯消元。建议按顺序刷,每道题都先算一遍复杂度再动手。

1
P1177
【模板】快速排序入门
2
P2249
【深基13.例1】查找(二分)入门
3
P1226
【模板】快速幂普及-
4
P1873
砍树(二分答案)普及-
5
P1216
[IOI1994] 数字三角形(O(n²) DP)普及-
6
P5461
赦免战俘(分治递归)普及-
7
P1908
逆序对(归并 / 树状数组)普及+
8
P1923
【深基9.例4】求第 k 小的数(均摊 O(n))普及+
9
P2678
[NOIP2015 提高组] 跳石头(二分答案)普及+
10
P1044
[NOIP2003 普及组] 栈(卡特兰 DP)普及+
11
P3390
【模板】矩阵快速幂(O(n³ log k))提高
12
P3389
【模板】高斯消元法(O(n³))提高