大卫信奥 · J4知识点总结 · 直播课

J4 知识点 · 十一单元合集

从算法复杂度,到前缀和差分、二分、分治排序、动态规划与指针。
一个学期,十一段旅程——

← 返回主页
J4 封面插图
课程目录
大卫信奥 · J4 知识点 · 一单元
01

时间复杂度与空间复杂度

程序为什么会超时?怎么估算一个算法的快慢?从 TLE 说起,把复杂度的账算明白。

程序超时 时间复杂度 排序对比 空间换时间
时间复杂度与空间复杂度封面
学习目标概览(按内容分组)

四个学习目标,从超时现场走到复杂度分析

A
程序超时
TLE 是什么、为什么发生,看两个真实案例。
B
时间复杂度
五个常见等级、大小关系与实例计算。
C
排序算法
传统 O(n²) 与高级 O(n log n) 对比。
D
空间复杂度
内存单位、MLE 与空间换时间。
01
PART 01 · 程序超时

先看懂:程序为什么会超时

评测只给 1 秒,运算量太大就 TLE——把计算量算出来,是优化的第一步。

TLE 暴力枚举 双指针
程序超时

TLE:超出时间限制

⏳
TLE = Time Limit Exceeded:程序超出了时间限制。编程竞赛 / OJ 评测都有时限(常见 1 秒),超时判 0 分。
?
计算机不是万能的:任务计算量很大时,1 秒内求不出结果。就像游戏里物品太多会卡顿,程序运算量过大就会运行缓慢甚至超时。
!
一个程序运算次数高达 100 亿次,
提交到任何一个平台都会超时——
评测时限通常只有 1 秒,
必须优化算法,降低时间复杂度。
超时现场 · 案例 1

暴力枚举:n = 100000 时要比较约 50 亿次

// 找两个数的和等于 target —— 暴力解法 O(n²) bool findSum(int arr[], int n, int target) { for(int i = 0; i < n; i++) { for(int j = i + 1; j < n; j++) { if(arr[i] + arr[j] == target) { return true; } } } return false; } // n = 100000 时,约 50 亿次比较,必然超时!
1
双重循环
i 和 j 两两配对比较,一共 n(n−1)/2 次。
2
数量级 O(n²)
n 增大时,比较次数按平方速度增长。
超时
n = 100000 时约 50 亿次,超过 1 秒的时限。
超时现场 · 案例 2

优化解法:排序 + 双指针,O(n log n) 秒过

// 排序 + 双指针 —— 优化解法 O(n log n) bool findSumOptimized( int arr[], int n, int target) { sort(arr, arr + n); // O(n log n) int left = 0, right = n - 1; while(left < right) { // O(n) long long sum=1LL*arr[left] +arr[right]; if(sum == target) return true; else if(sum < target) left++; else right--; } return false; } // 总 O(n log n),次数大大减少
1
先排序
快排等排序算法代价 O(n log n)。
2
双指针扫一遍
左右指针向中间移动,只需 O(n)。
通过
总复杂度 O(n log n),从 50 亿次降到约 200 万次。
优化思路

遇到超时,按这四步思考

1
分析复杂度
先写出当前算法的复杂度。
2
估算次数
算最大数据规模下的运算次数。
3
寻找更优解
换更高效的算法或数据结构。
4
预处理 / 换空间
考虑预处理,或空间换时间。
02
PART 02 · 时间复杂度

用固定操作的执行次数衡量快慢

不测真实秒数,而是统计“固定操作”的执行次数与变量 n 的关系——这就是时间复杂度。

概念 五个等级 大小关系
时间复杂度

时间复杂度:执行次数与 n 的关系

怎么度量“快慢”?
不依赖具体电脑,而是统计某些固定操作被执行了几次,看这个次数随着数据规模 n 如何增长。
固定操作示例:sum += n; cout << i;
数一数它执行了几次,就是复杂度的来源。
★
多项操作,看增长最快的一项
n² + n + 1 的时间复杂度是 O(n²)
n 一大,n² 就把 n 和 1 远远甩开,小项可以忽略。
时间复杂度

五个常见等级,一眼认出

O(1)
常量级
sum += n;
// 固定操作
执行次数与 n 无关
O(n)
线性级
for (i=1; i<=n; i++)
sum += i;
执行 n 次
O(n²)
平方级
for (i..n)
for (j..n)
cout << ...; // n² 次
O(log n)
对数级
while (i < n)
i *= 2;
执行 log₂n 次
O(n log n)
线性对数级
for (i..n)
j=1;
while (j<=n) j*=2;
外层 n 次 × 内层 log n 次
更高阶
O(n³) O(2ⁿ) O(n!)
增长极快,
n 稍大就吃不消
!
多项操作并存时,关注随 n 增长更快的那一项:n² + n + 1 → O(n²)。
时间复杂度

大小关系:按消耗时间从少到多排好队

O(1)< O(log n)< O(n)< O(n log n)< O(n²)< O(n³)< O(2ⁿ)< O(n!)
!
n 很大时差距天壤之别:n = 10⁶ 时,n² 比 n log n 慢约 5 万倍,更不用说 2ⁿ 了。
时间复杂度

实例:四段代码,四种复杂度

实例 1 · 简单循环
for(int i = 0; i < n; i++) { cout << i << " "; }
循环 n 次,每次 O(1):→ O(n)
实例 2 · 嵌套循环
for(int i = 0; i < n; i++) { for(int j = 0; j < m; j++) { cout << i << " " << j << endl; } }
外层 n 次 × 内层 m 次:→ O(n×m)
实例 3 · 变化的循环边界
for(int i = 0; i < n; i++) { for(int j = 0; j < i; j++) { cout << i << " " << j << endl; } }
内层 0+1+...+(n−1) = n(n−1)/2:→ O(n²)
实例 4 · 二分查找
int binarySearch(int arr[], int n, int target) { int left = 0, right = n - 1; while(left <= right) { int mid = (left + right) / 2; if(arr[mid] == target) return mid; else if(arr[mid] < target) left = mid + 1; else right = mid - 1; } return -1; }
每次范围减半,最多 log₂n 次:→ O(log n)
练一练

分析下面两段代码的时间复杂度

题目 1
for(int i = 1; i <= n; i *= 2) { for(int j = 1; j <= n; j++) { // 某些 O(1) 操作 } }
题目 2
for(int i = 0; i < n; i++) { for(int j = i; j < n; j++) { for(int k = j; k < n; k++) { // 某些 O(1) 操作 } } }
✓ 题目 1 → O(n log n);题目 2 → O(n³)
时间复杂度

递归复杂度:先写递推关系,再解出来

阶乘 · 单支递归
int factorial(int n) { // 基例 T(1)=O(1) if(n <= 1) return 1; // T(n)=T(n-1)+O(1) return n * factorial(n-1); }
解得 → O(n)
斐波那契 · 双支递归
int fibonacci(int n) { if(n <= 1) return n; // T(1) = O(1) return fibonacci(n-1) + fibonacci(n-2); // T(n)=T(n-1)+T(n-2)+O(1) }
解得 → O(2ⁿ) 指数爆炸
二分查找 · 折半递归
int binarySearch(int arr[], int l, int r, int t) { if(l > r) return -1; int mid = l + (r-l)/2; if(arr[mid] == t) return mid; else if(arr[mid] < t) return binarySearch(arr, mid+1, r, t); else return binarySearch(arr, l, mid-1, t); }
T(n) = T(n/2) + O(1) → O(log n)
归并排序 · 二分 + 合并
void mergeSort(int arr[], int l, int r) { if(l >= r) return; int mid = l + (r-l)/2; mergeSort(arr, l, mid); // T(n/2) mergeSort(arr, mid+1, r); // T(n/2) merge(arr, l, mid, r); // O(n) }
T(n) = 2T(n/2) + O(n) → O(n log n)
递归优化 · 记忆化

先初始化每个状态,再调用递归

long long memo[93]; long long fib_fast(int n) { if (n <= 1) return n; if (memo[n] != -1) return memo[n]; return memo[n] = fib_fast(n-1) + fib_fast(n-2); }
int main() { fill(memo, memo + 93, -1); // 每项都置为 -1 int n; cin >> n; // 0 <= n <= 92 cout << fib_fast(n) << endl; return 0; }
!
{-1} 只初始化首项为 −1,其余项是 0;fill 才把整个区间填成 −1。每个子问题计算一次,时间 O(n),记忆表与递归栈均为 O(n)。本例的 n≤92 保证结果可放 long long。
03
PART 03 · 排序算法复杂度

把排序的复杂度账也算清楚

传统三件套平均 O(n²),快排平均、归并与堆排序最坏 O(n log n)——再用实测数据说话。

冒泡 / 选择 / 插入 快排 / 归并 / 堆 性能实测
排序算法复杂度

传统三件套:都是 O(n²),各有千秋

冒泡排序
for(int i = 0; i < n-1; i++) { bool flag = false; for(int j = 0; j < n-1-i; j++) { if(a[j] > a[j+1]) { swap(a[j], a[j+1]); flag = true; } } if(!flag) break; // 提前结束 }
平均 O(n²) · 最优 O(n)(已有序)· 空间 O(1)
选择排序
for(int i = 0; i < n-1; i++) { int min_idx = i; for(int j = i+1; j < n; j++) { if(a[j] < a[min_idx]) { min_idx = j; } } swap(a[i], a[min_idx]); }
平均 O(n²) · 空间 O(1) · 交换次数少
插入排序
for(int i = 1; i < n; i++) { int key = a[i]; int j = i - 1; while(j >= 0 && a[j] > key) { a[j+1] = a[j]; j--; } a[j+1] = key; }
平均 O(n²) · 最优 O(n) · 空间 O(1)
排序算法复杂度

三种高级排序:时间界与稳定性分别看

快速排序 · 分治
int partition(int arr[], int lo, int hi) { int pivot = arr[hi]; int i = lo - 1; for(int j = lo; j <= hi-1; j++) { if(arr[j] < pivot) { i++; swap(arr[i], arr[j]); } } swap(arr[i+1], arr[hi]); return i + 1; }
平均 O(n log n) · 最坏 O(n²)(逆序时)
归并排序 · 合并
void mergeSort(int arr[], int l, int r) { if(l < r) { int mid = l + (r - l) / 2; mergeSort(arr, l, mid); mergeSort(arr, mid+1, r); merge(arr, l, mid, r); } }
最坏 O(n log n) · 稳定 · 辅助空间 O(n)
堆排序 · 堆顶
void heapify(int a[], int n,int i) { while(true) { int k=i,l=2*i+1,r=2*i+2; if(l<n && a[l]>a[k]) k=l; if(r<n && a[r]>a[k]) k=r; if(k==i) break; swap(a[i],a[k]); i=k; } }
最坏 O(n log n) · 不稳定 · 本页迭代下沉 O(1)
排序性能实测

数据变大,差距惊人

数组大小冒泡排序快速排序桶排序
100015000 µs100 µs50 µs
100001500000 µs1200 µs500 µs
!
数据量 ×10:冒泡慢 100 倍,
快速只慢 12 倍——算法差距随规模放大。
这就是为什么数据一大,O(n²) 和 O(n log n) 是两种命运。(具体数值依赖硬件与编译器,仅供参考)
04
PART 04 · 空间复杂度

内存不是无限的

KB / MB / GB 怎么换算?数组占多少内存?超了会怎样?——这一部分把空间账也算明白。

存储单位 MLE 空间换时间
空间复杂度

空间单位与 MLE:内存超了就 0 分

以字节为基本单位,2¹⁰ = 1024 为进率
1 KB = 1024 B
1 MB = 1024 KB = 1024² B
1 GB = 1024 MB = 1024³ B
≡
int a[1000000] 就消耗了 4 MB 空间。
1
超出空间限制 → MLE
Memory Limit Exceeded,评测直接判 0 分。
2
空间大头在数组
解题时主要分析程序中的大数组。
经验法则
大数组总空间 ≤ 120 MB,一般不会超 128 MB 限制。
空间复杂度

空间复杂度:所需存储空间与数据量的关系

O(1)
常量空间
所需空间是一个常量,不随被处理数据量的大小改变。只占几个变量,就是 O(1)。
O(1)
传统排序 · 原地
冒泡、选择、插入都只需常数个额外变量(i、j、flag、key…),空间复杂度 O(1)。
O(K)
计数桶 · 值域 K
需要创建额外数组存计数信息:int bucket[1001]——大小与数据范围相关。
空间换时间

计数桶的成本要同时看 n 与值域 K

算法(本课实现)时间额外空间
计数桶O(n+K)O(K)
冒泡排序平均 O(n²)O(1)
选择排序O(n²)O(1)
插入排序平均 O(n²)O(1)
!
n 是元素个数,K 是值域大小。bucket[1001] 对应 K=1001;先统计 n 项,再扫描 K 个桶。只有给定 K 为常量或 K=O(n) 时,才可按相应假设简化。
05
PART 05 · 算法复杂度实践

用对比把复杂度看透

斐波那契、重复元素检测、最大子数组和——同一道题,不同代价,亲眼看看差距。

递归 vs 递推 三种解法 Kadane
复杂度实践 · 斐波那契

求第 n 项:递归 O(2ⁿ) vs 递推 O(n)

递归版 · O(2ⁿ)
int fib_recursive(int n) { if (n <= 1) return n; return fib_recursive(n-1) + fib_recursive(n-2); }
递推版 · O(n)
int fib_iterative(int n) { if (n <= 1) return n; int a = 0, b = 1; for (int i = 2; i <= n; i++) { int temp = a + b; a = b; b = temp; } return b; }
40
计算第 40 项
递归版:331160281 次函数调用
递推版:39 次加法
调用数与加法数口径不同,不直接当作耗时倍数。
这就是 O(2ⁿ) 与 O(n) 的差距!
复杂度实践 · 重复元素

检测是否有重复元素:同题三解,代价不同

方法时间复杂度空间复杂度适合场景
暴力两两比较O(n²)O(1)数组很小
排序后检查相邻O(n log n)O(1)内存极度受限
哈希表统计O(n)O(n)要最快、内存充足
?
不能修改原数组?只能选暴力或哈希——排序会打乱原数组。
算法设计原则

设计高效算法的六条原则

1
分而治之
大问题拆成小问题(归并、快排)。
2
动态规划
避免重复计算,用空间换时间。
3
贪心策略
每步都做局部最优选择。
4
预处理
一次计算,多次使用。
5
数据结构优化
选对结构,性能大幅提升。
6
算法剪枝
及早终止不必要的计算。
复杂度实践 · 最大子数组和

最大子数组和:从 O(n³) 一路降到 O(n)

暴力枚举 · O(n³)
int maxSum = INT_MIN; for(int i = 0; i < n; i++) for(int j = i; j < n; j++) { int sum = 0; for(int k = i; k <= j; k++) sum += nums[k]; maxSum = max(maxSum, sum); }
每个区间都重新累加,大量重复计算
滚动累加 · O(n²)
int maxSum = INT_MIN; for(int i = 0; i < n; i++) { int sum = 0; for(int j = i; j < n; j++) { sum += nums[j]; // 边移边累加 maxSum = max(maxSum, sum); } }
利用上一次的结果,去掉第三层循环
Kadane 算法 · O(n)
int maxSum = nums[0]; int cur = nums[0]; for(int i = 1; i < n; i++) { cur = max(nums[i], cur + nums[i]); maxSum = max(maxSum, cur); }
每步只保留“到此为止”的最优子段
小结

今天你学会了:

复杂度基础
TLE 与 MLE 五档复杂度 大小关系 记忆化
排序复杂度
冒泡选择插入 快排归并堆 桶排序 O(n²) → O(n log n)
实践与权衡
空间换时间 斐波那契 Kadane
大卫信奥 · J4 知识点 · 二单元
02

前缀和与差分

把「区间和」从 O(n) 降到 O(1),把「区间修改」从 O(n) 降到 O(1)——一对互为逆运算的加速器。

前缀和 区间和 差分 逆运算
前缀和与差分封面
学习目标概览(按内容分组)

四个环节,从问题走到一对逆运算

A
前缀和
水果商人的故事:前 n 项的和与递推关系。
B
区间和
s[R] − s[L−1]:一次减法回答所有区间。
C
完整解题
n、m 到 100000 的区间查询,O(n+m)。
D
差分
区间修改的 O(1) 魔法,与原数组互为逆运算。
01
PART 01 · 前缀和

先把前 n 项的和算好

从水果商人的收购点说起:前 n 项的和就是前缀和,用数组存起来,后面随便查。

水果商人 前缀和数组 递推关系
前缀和 · 引入

水果商人:从 ① 号开始拉水果,一共多少箱?

下标012345678910
箱数03256614173
1
前 1 个收购点:3 箱
2
前 2 个收购点:3 + 2 = 5 箱
3
前 3 个收购点:3 + 2 + 5 = 10 箱
★
前缀和:数列中前 n 项的和。
第 1 项 = 3,第 2 项 = 3 + 2 = 5,
第 3 项 = 3 + 2 + 5 = 10……以此类推。
前缀和 · 数组

前缀和数组:s[i] = s[i-1] + a[i]

// 原数组 a:10 个收购点(下标1开始) int a[11] = {0, 3, 2, 5, 6, 6, 1, 4, 1, 7, 3}; // 前缀和数组 s int s[11] = {}; // 递推:s[i] = s[i-1] + a[i] s[1] = a[1]; // 初始条件 for(int i = 2; i <= 10; i++) { s[i] = s[i-1] + a[i]; // 递推关系 }
∑
s[1] = a[1](初始条件)
s[i] = s[i-1] + a[i](递推关系,i ≥ 2)
1
数组下标也从 1 开始
收购点编号 1~10 ⇔ 下标 1~10。
2
下标 0 赋 0
s[0] = a[0] = 0,方便后面统一计算。
前缀和 · 构建

完整代码:构建并输出前缀和数组

#include <bits/stdc++.h> using namespace std; int main() { // 数组 a:下标 1 开始用,下标 0 赋 0 int a[11] = {0, 3, 2, 5, 6, 6, 1, 4, 1, 7, 3}; int s[11] = {}; // 前缀和数组 s s[1] = a[1]; // ① 初始条件:s[1] = a[1] for(int i = 2; i <= 10; i++) // ② 递推关系 s[i] = s[i-1] + a[i]; for(int i = 1; i <= 10; i++) // ③ 输出前缀和数组 cout << s[i] << " "; return 0; }
运行窗口
3 5 10 16 22 23 27 28 35 38
笔
动手练习
自己写一遍,观察 s 的每一位是怎么“接力”算出来的。
02
PART 02 · 区间和

一次减法,回答所有区间问题

任意区间 [L, R] 的和,不用再一段段累加——s[R] − s[L−1] 一次搞定。

区间 [L, R] 两种方法对比 公式推导
区间和

拉 ③ 号到 ⑦ 号的水果:一共多少箱?

下标012345678910
箱数03256614173
∑
区间 ③~⑦ 的和
= 5 + 6 + 6 + 1 + 4 = 22
对照前缀和:s[7] − s[2] = 27 − 5 = 22
1
区间下标 3 → 7
2
累加 5 个数 5 次加法得到 22
区间和 · 对比

两种方法:循环累加 vs 一次减法

方法 1 · 累加计算
int sum = 0; for(int i = 3; i <= 7; i++) { sum += a[i]; // 需要 5 次加法 }
方法 2 · 前缀和计算
int sum = s[7] - s[2]; // 只需要 1 次减法
⚡
多次查询时优势明显:
累加方法每次都要循环,前缀和每次只需 1 次减法。
O(区间长度) → O(1)
区间和 · 公式

区间和公式:s[R] − s[L−1]

起点下标终点下标计算方法
37s[7] − s[2]
50100s[100] − s[49]
140300s[300] − s[139]
∑
区间 [L, R] 的和:
s[R] − s[L−1]
如:s[7] − s[2] = 22
为什么是 s[R] − s[L−1]?
s[7] = 3+2+5+6+6+1+4 = 27 // 前 7 项 s[2] = 3+2 = 5 // 前 2 项 区间[3,7] = s[7] − s[2] = 22
前 R 项减去前 L−1 项,正好剩 [L, R]。
03
PART 03 · 完整解题

n、m 都到 100000 的区间查询

数据一大,暴力加法必然超时——用前缀和写一版标准解法。

题目描述 样例分析 复杂度对比
区间和问题

区间和问题:n 个数,m 次查询

问题描述
输入 n 个整数和 m 个区间,每个区间给出起点 L、终点 R,输出每个区间的区间和。
输入格式
第一行 n m(1 ≤ n,m ≤ 100000)
第二行 n 个整数(1 ≤ 整数 ≤ 100)
接下来 m 行,每行 L R(0 < L ≤ R ≤ n)
输出格式
输出 m 行,每行一个整数,表示区间和。
输入样例
10 3 2 1 3 6 4 20 15 10 4 11 3 7 1 9 5 8
输出样例
48 65 49
区间和问题 · 分析

样例分析:三个区间,手算一遍

下标012345678910
数值021364201510411
[3,7] = 3+6+4+20+15 = 48 [1,9] = 2+1+...+15+10+4 = 65 [5,8] = 4+20+15+10 = 49
!
n、m 最大 100000:
暴力累加约 n×m ≈ 100 亿次,必然超时,
必须用前缀和!
区间和问题 · 解答

完整代码:前缀和秒答 m 次查询

#include <bits/stdc++.h> using namespace std; int a[100010] = {}; // 原数组 int s[100010] = {}; // 前缀和数组 int main() { int n, m; cin >> n >> m; for(int i = 1; i <= n; i++) cin >> a[i]; // ① 读入 s[1] = a[1]; for(int i = 2; i <= n; i++) // ② 构建前缀和 s[i] = s[i-1] + a[i]; int L, R; for(int i = 1; i <= m; i++) { // ③ m 次查询 cin >> L >> R; cout << s[R] - s[L-1] << endl; // 每次 O(1) } return 0; }
①
读入
下标从 1 开始存。
②
构建前缀和
递推一遍 O(n)。
③
查询
每问一次 O(1)。
区间和问题 · 复杂度

复杂度账本:O(n+m) 打败 O(n×m)

解法构建每次查询总复杂度
暴力累加—O(区间长度)O(n×m)
前缀和O(n)O(1)O(n+m)
!
n = m = 100000 时
暴力:约 100 亿次运算,必然超时
前缀和:约 20 万次运算,轻松通过
04
PART 04 · 前缀和扩展

一维会了,二维也不怕

同样的套路还能用到矩形区域、乘积和异或上——思路是相通的。

二维前缀和 前缀积 前缀异或
二维前缀和 · 矩形容斥

目标矩形 = A − B − C + D

// s[i][j]:从 (1,1) 到 (i,j) 矩形内所有元素的和 for(int i = 1; i <= n; i++) for(int j = 1; j <= m; j++) s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]; // 查询 (x1,y1)~(x2,y2) 的矩形区域和 long long getSum(int x1, int y1, int x2, int y2) { return s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1]; }
+ A:整个前缀
− B:上方前缀
− C:左方前缀
+ D:重叠补回
!
查询时先取整个前缀,减去上方和左方;左上重叠被减了两次,再补一次。a、s 均用 long long,零行/零列初始化为 0,并保证总和可表示。
前缀运算 · 条件要写全

前缀积与前缀异或,各有成立前提

乘积:先放单位元 p[0]=1
long long p[N]; p[0] = 1; for (int i=1; i<=n; i++) p[i] = p[i-1] * a[i]; // 1 <= L <= R <= n long long answer = p[R] / p[L-1];
!
仅适用于元素非零,所有前缀积及答案可表示的情况。课堂可取 n≤30、1≤a[i]≤3。含 0 不能直接用此除法查询。
异或:xo[0]=0,同项抵消
int xo[N]; xo[0] = 0; for (int i=1; i<=n; i++) xo[i] = xo[i-1] ^ a[i]; int answer = xo[R] ^ xo[L-1];
!
x ^ x = 0。异或没有乘积的累乘溢出问题;仍需保证下标和区间合法。
05
PART 05 · 差分

每一项与前一项的差

前缀和的反向操作:把每个数变成与前一项的差,区间修改就变成了两个位置的修改。

差分数组 核心性质 区间修改
差分 · 概念

差分:每一项与前一项的差

★
差分数组 d:d[1] = a[1] − a[0](a[0] 默认为 0),d[i] = a[i] − a[i−1](i ≥ 2)。
原数组 a 035919202330385050
差分 d 0324101378120
差分 · 构建

差分数组的构建:一减到底

// 原数组(下标 0 初始化为 0) int a[10] = {0, 3, 5, 9, 19, 20, 23, 30, 38, 50}; // 差分数组 int d[10] = {}; // 计算差分 for(int i = 1; i <= 9; i++) { d[i] = a[i] - a[i-1]; }
练一练:a = {0, 2, 9, 20, 65},d[1] ~ d[4] = ?
A2, 11, 31, 96
B7, −11, −45, 65
C2, 7, 11, 45
D2, 7, −11, −45
✓ 选 C:d = {2, 7, 11, 45}
差分 · 性质

核心性质:对差分求前缀和 = 原数组

原数组: [0, 3, 5, 9, 19, 20] 差分: [0, 3, 2, 4, 10, 1] 对差分求前缀和(验证): d[1] = 3 → a[1] = 3 d[1]+d[2] = 3+2 = 5 → a[2] = 5 d[1]+d[2]+d[3] = 5+4 = 9 → a[3] = 9 ……
≡
a[i] = d[1] + d[2] + … + d[i]
差分 → 前缀和 → 原数组,正好转一圈。
逆运算
前缀和是加法的累积,
差分是减法的还原。
差分 · 区间修改

区间修改:暴力 O(n) vs 差分 O(1)

暴力方法 · 每次改一整段
// 每次修改都要循环 —— O(n) for(int i = L; i <= R; i++) { a[i] += x; }
差分方法 · 只改两个位置
// 只需要修改两个位置 —— O(1) d[L] += x; // 区间开始位置 d[R+1] -= x; // 结束位置的下一个
1
从 L 开始
对差分求前缀和时,每个位置都会多出 x。
2
从 R+1 开始
再减 x,正好抵消前面的增加。
结果
只有 [L, R] 内的元素增加了 x。
差分 · 完整示例

初始全 0,执行 m 次区间加法

#include <bits/stdc++.h> using namespace std; int main() { int n,m; cin>>n>>m; vector<long long> delta(n+2,0); for(int i=0;i<m;i++) { int L,R; long long x; cin>>L>>R>>x; delta[L]+=x; delta[R+1]-=x; } long long value=0; for(int i=1;i<=n;i++) { value+=delta[i]; cout<<value<<" "; } cout<<endl; return 0; }

输入:1≤n≤10⁵、0≤m≤10⁵。
再给 m 行 L R x,其中 1≤L≤R≤n,|x|≤10⁹。
原数组初始全部为 0。

!
每次修改 O(1),m 次共 O(m),最后恢复 O(n)。long long 可容纳本范围内的累加结果。
输入:5 2
1 3 2
2 5 −1
输出:2 1 1 −1 −1
06
PART 06 · 关系与总结

一对互为逆运算的搭档

前缀和与差分转一圈就能互相还原——最后把两者的适用场景摆在一起对比。

逆运算 场景对照 组合使用
前缀和 ⇄ 差分

互为逆运算:转一圈又回来

int a[] = {0, 3, 5, 9, 19, 20}; // 先前缀和,再差分 int s[] = {0, 3, 8, 17, 36, 56}; // 回到原数组 int d[] = {0, 3, 5, 9, 19, 20}; // 先差分,再前缀和 int d2[] = {0, 3, 2, 4, 10, 1}; // 也回到原数组 int s2[] = {0, 3, 5, 9, 19, 20};
⇄
原数组 —差分→ 差分数组 —前缀和→ 原数组
原数组 —前缀和→ 前缀和数组 —差分→ 原数组
记
一对搭档
前缀和管“查”,差分管“改”,配合使用威力更大。
应用场景总结

怎么选:多次查询用前缀和,多次修改用差分

技术擅长解决时间复杂度
前缀和多次区间查询构建 O(n) · 查询 O(1)
差分多次区间修改修改 O(1) · 恢复 O(n)
∪
频繁查询 → 前缀和;频繁修改 → 差分;
既要查又要改 → 考虑线段树等高级数据结构。
小结

今天你学会了:

前缀和
前 n 项的和 s[i] = s[i-1] + a[i] 区间和 s[R] − s[L−1]
差分
d[i] = a[i] − a[i−1] 前缀和还原原数组 d[L]+=x · d[R+1]−=x
扩展与注意
二维前缀和 前缀积 · 前缀异或 下标从 1 开始 · long long
大卫信奥 · J4 知识点 · 三单元
03

二分查找

每次砍掉一半:在有序数组里,约 20 次比较就能替代 100 万次扫描。

有序数组 折半 mid 边界
二分查找封面
学习目标概览(按内容分组)

四个环节,从一个猜数游戏走到 O(log n)

A
猜数游戏
1 ~ 100 猜一个数,最少几次一定能猜中?
B
折半查找
取中间、比大小、丢掉一半,手推一遍全过程。
C
写成代码
while 模板、mid 写法与死循环的坑。
D
例题与工具
升序数组查找目标值 + lower_bound 等工具。
01
PART 01 · 猜数游戏

先别看代码,先玩个游戏

我心里想一个 1 ~ 100 的整数,只回答「大了 / 小了 / 猜中」——你最少几次能保证猜中?

猜数游戏 每次砍一半 至多 7 次
猜数游戏 · 规则

猜 1 ~ 100 的整数,最少几次一定能猜中?

游戏规则
我心里想一个 1 ~ 100 的整数。你每猜一个数,我只回答三种结果之一:小了 / 大了 / 猜中。
最笨的问法
从 1 开始一个一个问:猜 1、猜 2、猜 3……最坏要 100 次才轮到答案。
?
有没有一种问法,让每一次提问都排除掉一大半可能?
聪明的猜法
你:50 我:小了! 你:75 我:大了! 你:62 我:猜中!
猜数游戏 · 最坏次数

每轮比较后,候选区间缩小

阶段最多剩余候选说明
开始100目标保证在 1~100
前 1~3 次比较后50 → 25 → 12猜中就立即结束
前 4~6 次比较后6 → 3 → 1仍未命中时继续缩小
第 7 次比较命中最后候选最坏不超过 7 次比较
!
第 1 次猜 50:若目标更小,剩 49 个;更大则剩 50 个。因此讨论最坏分支,而不是说无论大小都恰剩 50 个。
02
PART 02 · 折半查找

把猜数游戏搬到有序数组上

数组有序,就相当于答案已经按顺序排好——中间那个元素,就是那声「大了 / 小了」。

前提:有序 取中间 mid 排除一半
二分查找 · 核心思想

每一步都丢掉一半的候选

★
二分查找:在一个有序数组里,每次取中间元素与目标比较,根据大小关系丢掉一半候选,直到找到目标、或者区间为空。
有序
前提条件
数组必须升序排列,否则「大了 / 小了」的判断不成立。
mid
取中间
用中间元素和 target 比一次,代价只有 O(1)。
一半
丢掉一半
每轮候选区间至少减半,所以总共只要 O(log n) 次。
折半查找 · 手推

在有序数组里找 11:三轮就锁定下标 5

下标012345678
数值1357911131517
1
L=0, R=8, mid=4:a[4]=9 < 11 → 丢掉左半,L=5
2
L=5, R=8, mid=6:a[6]=13 > 11 → 丢掉右半,R=5
3
L=5, R=5, mid=5:a[5]=11 → 命中,返回下标 5
⚡
9 个数只比了 3 次:
9 → 4 → 1 → 命中
顺序查找最坏要 9 次
折半查找 · 手推

找不存在的 4:区间收缩到空,返回 −1

轮次LRmid值结论
108499 > 4 → R = 3
203133 < 4 → L = 2
323255 > 4 → R = 1
421——L > R,区间为空
!
当 L > R 时区间里已经没有元素,
循环结束,返回 −1。
折半查找 · 每一轮

每一轮只做三件事,外加一个终止条件

1
定边界
left = 0,right = n − 1,闭区间 [left, right]。
2
算中间
mid = left + (right − left) / 2。
3
比大小
把 a[mid] 和 target 比一次,只有三种结果。
4
缩区间
相等 → 返回 mid;小了 → L = mid + 1;大了 → R = mid − 1。
03
PART 03 · 写成代码

把这三件事写成 while 循环

循环条件写 L <= R,每轮只会更新 L 或 R——最要小心的是:区间必须真的在缩小。

标准模板 mid 怎么写 死循环的坑
二分查找 · 模板

标准模板:while (left <= right)

int binarySearch(int a[], int n, int target) { int left = 0, right = n - 1; // 闭区间 while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] == target) return mid; if (a[mid] < target) left = mid + 1; // 右边 else right = mid - 1; // 左边 } return -1; // 没找到 }
1
初始化
left = 0,right = n − 1。
2
循环里比一次
算 mid、比大小、丢掉一半。
3
两个出口
相等返回下标;L > R 返回 −1。
二分查找 · mid 写法

mid 的两种写法:为什么推荐先减后加

写法 A · (left + right) / 2
int mid = (left + right) / 2;
直观好记,但 left + right 可能超出 int 范围。
写法 B · left + (right − left) / 2
int mid = left + (right - left) / 2;
先求差再相加,结果一样,不会越界。
!
两种写法在 left、right 都很小时结果相同;养成写 B 的习惯,n 再大也不会爆 int。
二分查找 · 边界

更新边界必须 ±1:否则区间不缩小,死循环

// ✗ 错误写法:区间没有缩小 while (left <= right) { int mid = (left + right) / 2; if (a[mid] < target) left = mid; // 还是 mid! else right = mid; }
1
mid 已经比过了:答案不会在 mid 上,所以在它的右边 → L = mid + 1
2
镜像地,a[mid] > target 时答案在左边 → R = mid − 1
死循环
L、R 不再变化,区间永远缩不到空。
二分查找 · 另一种写法

左闭右开写法:while (left < right) + right = mid

写法二 · 区间 [left, right)
int left = 0, right = n; while (left < right) { int mid = left + (right-left) / 2; if (a[mid] < target) left = mid + 1; else right = mid; } // 结束时 left 是第一个 >= target 的位置 int pos = left; bool found = pos < n && a[pos] == target;
位置可能是 n;先检查 pos<n,再检查 a[pos]。right 是不包含的右端点。
两种写法怎么选
// 写法一(闭区间) // 返回下标,找不到返回 -1 // 写法二(左闭右开) // 返回第一个 >= target 的位置 // 两套都能用,但一套代码里只用一种
把两种边界写法混着用,是最常见的出错原因。
二分查找 · 复杂度

复杂度账本:O(log n) 到底有多快

查找方式每次比较后最坏次数n = 10⁶ 时
顺序查找只排除 1 个O(n)约 100 万次
二分查找排除一半O(log₂n)约 20 次
∑
2²⁰ ≈ 100 万
每轮砍一半,20 轮就能覆盖
100 万个数:100 万 → 50 万 → …… → 1
04
PART 04 · 例题

在升序数组里查找目标值

元素互不重复、已经升序排好——把刚写的模板套进完整程序里跑一遍。

题目描述 样例分析 完整代码
查找目标值

查找数组中的目标值

问题描述
给定长度为 n 的升序整数数组 a(元素互不重复)和目标值 target:找到就返回它的下标,找不到返回 −1。
输入格式
第一行 n target(1 ≤ n ≤ 100000)
第二行 n 个升序整数(保证互不重复)
输出格式
一行一个整数:target 的下标,找不到输出 −1。
输入样例
10 7 2 4 6 7 8 10 12 14 16 18
输出样例
3
查找目标值 · 分析

样例分析:找 7,四轮走到下标 3

下标0123456789
数值246781012141618
第1轮 L=0 R=9 mid=4 a[4]=8 > 7 → R=3 第2轮 L=0 R=3 mid=1 a[1]=4 < 7 → L=2 第3轮 L=2 R=3 mid=2 a[2]=6 < 7 → L=3 第4轮 L=3 R=3 mid=3 a[3]=7 = 7 → 返回 3
✓
10 个数的顺序查找最坏要 10 次,
二分只用了 4 次比较。
查找目标值 · 解答

完整代码:把模板套进 main

#include <bits/stdc++.h> using namespace std; int a[100010]; int binarySearch(int n, int target) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] == target) return mid; // ① 命中 if (a[mid] < target) left = mid + 1; // ② 去右边 else right = mid - 1; // ③ 去左边 } return -1; // ④ 没找到 } int main() { int n, target; cin >> n >> target; for (int i = 0; i < n; i++) cin >> a[i]; cout << binarySearch(n, target) << endl; return 0; }
1
读入
n、target 和升序数组 a。
2
二分查找
每轮丢掉一半,最多 log₂n 轮。
3
输出
下标或 −1,一行搞定。
查找目标值 · 现成工具

标准库早就写好了:lower_bound / upper_bound

lower_bound
第一个 ≥ x
p = lower_bound(a, a+n, x) - a;
返回第一个 ≥ x 的下标;可能为 n。
upper_bound
第一个 > x
q = upper_bound(a, a+n, x) - a;
返回第一个 > x 的下标;可能为 n。
binary_search
只判断存在
binary_search(a, a+n, x)
返回 true / false,不给下标。
★
数组必须升序。例:nums = [1, 2, 3, 3, 4, 5] 时 lower_bound(3) → 下标 2,upper_bound(3) → 下标 4;3 的个数 = 4 − 2 = 2。
05
PART 05 · 变体与工具

二分能做的,不止「找一个数」

把「等于 target」换成「满足某个条件」,二分就变成了找边界——这也是下一单元「二分答案」的入口。

找第一个 ≥ x 易错点 当堂练习
二分查找 · 下界

返回插入位置,不一定找到了目标

int lowerBound(int n, int x) { int left = 0, right = n; // [left, right) while (left < right) { int mid = left + (right-left) / 2; if (a[mid] < x) left = mid + 1; else right = mid; // mid 可能是答案 } return left; // 第一个 >= x }
int pos = lowerBound(n, x); bool found = pos < n && a[pos] == x; // 必须先判断 pos < n,才可访问 a[pos]
情形返回值
存在 ≥x 的元素第一个满足条件的下标
x 比所有元素都大,或 n=0返回 n,不能解引用
需要“是否找到 x”pos
!
数组有序且有效元素为 a[0..n-1]。lower_bound 的边界返回规则与“找不到返回 −1”的普通二分不同。
二分查找 · 易错点

三个最常见的翻车现场

✗ 数组没排序
二分的前提是有序。数组无序时要么先 sort,要么老老实实顺序查找。
✗ 边界写成 mid
更新时漏掉 ±1,区间不缩小 → 死循环,程序一直跑不完。
✗ 返回值搞混
题目要的是下标还是「第几个」?找不到统一返回 −1,别返回 0。
当堂练习

用标准模板找 13,mid 依次是多少?

a[0..7] = {1, 3, 5, 7, 9, 11, 13, 15} target = 13 // 标准模板:left = 0, right = 7 // 每一轮的 mid 分别是多少? // 第几轮命中?
mid 依次是:
A3 → 5 → 6
B3 → 4 → 6
C4 → 5 → 6
D3 → 5 → 7
✓ 选 A:mid = 3 → 5 → 6,第三轮 a[6] = 13 命中。
应用场景

二分能解决的,远不止「找一个数」

场景需要什么条件二分的是什么
有序数组查找元素数组升序排列目标元素的下标(本单元)
第一个 ≥ x 的位置数组升序排列满足条件的边界(lower_bound)
答案二分(下一单元)答案随变量单调变化答案本身(check 函数)
∪
只要「答案随某个变量单调变化」,就能二分:把判定条件写成 check(x),让二分去找第一个满足 check 的位置。
小结

今天你学会了:

二分思想
前提:数组有序 每次丢掉一半 O(log n)
代码模板
while (L <= R) mid = L + (R−L)/2 L = mid+1 · R = mid−1
边界与工具
L > R 返回 −1 lower_bound 第一个 ≥ x 一套代码只用一种边界
大卫信奥 · J4 知识点 · 四单元
04

二分答案

答案藏在一个范围里,而「试一下就知道行不行」——于是 10 亿次枚举,变成 30 次判断。

单调性 check 函数 求最大 求最小
二分答案封面
学习目标概览(按内容分组)

四个环节:从一道切割题,走到一套通用模板

A
切割木材
3 根木材切 5 段,每段最长多少?先动手试。
B
单调性
可行的答案连成一片:小的都行,大的都不行。
C
写成模板
check(x) 管判断,二分框架管缩小范围。
D
三道例题
切割木材、分书、跳石头——覆盖求最大与求最小。
01
PART 01 · 切割木材

先别写代码,先动手试

3 根木材,长度 8、4、10,要切出 5 段一样长的——每段最长能有多长?

手工试答案 段数 = 长度 ÷ x 枚举的代价
切割木材 · 动手试

每段切多长?从 1 开始往上试

每段长度 x8 米能切4 米能切10 米能切合计段数够 5 段吗
18 段4 段10 段22 段✓ 远远够
24 段2 段5 段11 段✓ 够
32 段1 段3 段6 段✓ 够
42 段1 段2 段5 段✓ 刚好
51 段0 段2 段3 段✗ 不够了
✓
x = 4 时刚好 5 段;x = 5 时只剩 3 段。
答案就是 4——它左边全可行,右边全不可行。
换个问法,题就变简单了
给定一个 x,能不能切出 ≥ 5 段?
这一步只要把所有木材除一遍,O(n)。
切割木材 · 枚举的代价

从小到大一个个试,最坏要试多少次?

int ans = 0; for (int x = 1; x <= maxLen; x++) { if (count(x) >= k) ans = x; // 可行,记下来 else break; // 一旦不可行就停 } cout << ans;
count(x) 本身还要扫一遍 n 根木材 → 每次 O(n)
1
maxLen = 10:试 5 次就够,感觉还行
2
maxLen = 10⁹:10 亿次判断,每次 O(n)
3
n = 10⁵ 时:10¹⁴ 次运算,彻底超时
?
答案明明「排好了队」:小的一路可行,大的全不可行。既然 x = 4 行、x = 5 不行,那 x = 6、7、8…… 还有必要试吗?
02
PART 02 · 单调性

可行的答案,是一段连续的前缀

先框出答案的可能范围,再确认「大的可行 ⟹ 小的也可行」这条性质——它就是二分的通行证。

答案范围 单调性 分界线
二分答案 · 第一步

先把答案的「可能范围」框出来:L 和 R

下界 L = 1
每段长度是正整数,最小只能取 1。
不能从 0 开始:check 里要除以 x,0 会直接崩。
上界 R = max(a[i])
比最长那根还长,一段都切不出来。
取 10:8 米那根已经切不出整段了。
答案在这里 12345678910
!
范围写错,答案一定错:下界从 1 起,上界取 max(a[i])。宁可放宽一点,也别把真答案挡在区间外面。
二分答案 · 单调性

x 可行,比它小的全都可行

每段长度 x12345678910
合计段数221165322211
够 5 段吗✓✓✓✓✗✗✗✗✗✗
✓ 全挤在左边
切短一点,每根木材能出的段数只会变多,不会变少——所以可行解一定是一段前缀。
✂
✓ 和 ✗ 之间只有一条分界线:
分界线左边最后一个 ✓,就是答案。
二分答案 · 核心思想

猜一个答案,判它行不行,再砍掉一半

猜
取中点 mid
把 mid 当成「假装的答案」,它就是区间的正中间。
判
check(mid)
只回答行不行:能切够 5 段就是 true,否则 false。
砍
丢掉一半
可行就往大试,不可行就往小退——每次少一半。
★
二分答案:在一个单调的答案范围里二分,用 check(x) 判断可行性,找出最后一个(或第一个)可行的值。
03
PART 03 · 手推一遍

在 [1, 10] 里,四轮锁定答案

每一轮只做三件事:算 mid、算段数、决定留哪一半。答案用 ans 单独记下来。

mid 变化 ans 更新 为什么记 ans
手推 · 切割木材

L = 1,R = 10:四轮之后 ans = 4

轮次LRmid段数判断新区间
111053✗ 不够R = 4
214211✓ ans = 2L = 3
33436✓ ans = 3L = 4
44445✓ ans = 4L = 5,结束
第 1 轮 mid = 5 不可行 → 右边全砍掉;
第 2、3、4 轮都可行 → ans 一路 2 → 3 → 4,同时往大试。
∑
范围 10 只要 4 轮;
范围 10⁹ 也只要 30 轮。
二分答案 · 记录可行解

ans 记答案,found 区分是否找到了

long long ans=0; bool found=false; // 每次 check(mid) 为真: ans=mid; found=true; // 再按求最大/最小缩小搜索区间

求最大:可行就向右找更大值。
求最小:可行就向左找更小值。

!
一般情形不要让初值冒充答案。只有上界 R 已确认可行时,求最小才可以用 ans=R 兜底;found=false 时按题目约定报告无解。
04
PART 04 · 写成模板

把「判断」和「二分」拆成两件事

check(x) 只负责回答行不行;二分框架只负责缩小范围。拆开之后,换一道题只需要重写 check。

check 函数 标准模板 方向选择
二分答案 · check

check(x):只回答「行不行」,不问「最好是多少」

// 每段长 x,能不能切出 k 段? bool check(int x) { long long cnt = 0; for (int i = 0; i < n; i++) { cnt += a[i] / x; if (cnt >= k) return true; // 够了,早退 } return false; }
cnt 会超过 int 上限,必须用 long long。
1
参数是「猜的答案」
不是数组、不是下标,而是 mid。
2
返回 bool,不返回最优值
最优由二分去逼近,check 只判可行。
3
能早退就早退
够 k 段立刻 return,平均快一半。
二分答案 · 模板

标准模板:一套架子,求最大求最小只改两行

int solve(int L, int R) { int ans = -1; // 求最小:ans = R while (L <= R) { int mid = L + (R - L) / 2; if (check(mid)) { // mid 可行 ans = mid; // 先存档 L = mid + 1; // 求最大:往大试 // R = mid - 1; // 求最小:往小试 } else { R = mid - 1; // 求最大:往小退 // L = mid + 1; // 求最小:往大退 } } return ans; }
1
定范围
L、R 覆盖所有可能的答案。
2
二分 + 判断
mid 可行就 ans = mid。
3
输出 ans
不要输出 L 或 R。
二分答案 · 方向

求最大往大试,求最小往小试

目标check(mid) 为真check(mid) 为假答案是什么
求最大值ans = mid; L = mid + 1R = mid − 1最后一个可行
求最小值ans = mid; R = mid − 1L = mid + 1第一个可行
→
记法:check 为真,说明 mid 是个可用答案——先存进 ans,再朝「更贪心」的方向继续找:求最大就往上,求最小就往下。
求最大值 → 答案在 ✓ 区的右端
✓✓✓✓✗✗✗✗
求最小值 → 答案在 ✓ 区的左端
✗✗✗✓✓✓✓✓
二分答案 · 识别

先证明可行性的变化只有一个方向

信号要证明什么
答案范围明确在有限、有序的候选答案中搜索
check(x) 判定正确给定 x,能判断此候选答案是否可行
可行性单调真→假求最后一个真,或假→真求第一个真
!
木材长度越小越容易凑够:可行值是前缀,求最大;分书容量越大越容易分配:可行值是后缀,求最小。范围和边界由题意确定。
二分答案 · 复杂度

O(n log V):log 的是答案范围,不是 n

做法时间n = 10⁵、长度 ≤ 10⁹
逐个枚举O(n · V)10¹⁴ 次,跑不完
二分答案O(n log V)30 × 10⁵ = 3 × 10⁶
V = 10⁹ 时 log₂V ≈ 30
范围每翻一倍,只多一次 check。
10¹⁸ 也只要 60 轮。
∑
所以复杂度写成 O(n log V):n 来自 check 扫一遍,log V 来自二分的轮数。两个数分别算,别混在一起。
二分答案 · 四个坑

方向、范围与初值都由题目决定

ans 初值

用 found 标记尚未找到;只在已证明上界可行时用 ans=R。

check 的单调方向

真→假找最后一个真;假→真找第一个真,更新方向相反。

下界是否能取 0

木材 check 除以 x,因此 x 不能为 0;其他题须按题意判断。

搜索范围

上下界应覆盖所有合法答案,区间和 mid 类型要能容纳数值。

05
PART 05 · 三道例题

换个 check,就是另一道题

切割木材求最大,分书求最小,跳石头求「最大的最小」——模板一个字都不用改。

切割木材 分书 跳石头
例题 1 · 切割木材

n 根木材切 k 段,求每段的最大长度

题目描述
给出 n 根木材的长度 a[i],要切出 k 段等长的木段(长度是整数),求每段的最大可能长度。
输入格式
第一行 n k(1 ≤ n ≤ 10⁵,1 ≤ k ≤ 10⁸)
第二行 n 个整数 a[i](1 ≤ a[i] ≤ 10⁹)
输出格式
一行一个整数:每段的最大长度。切不出 k 段则输出 0。
输入样例
3 5 8 4 10
输出样例
4
例题 1 · 完整代码

把 check 套进模板:主函数只剩读入和二分

#include <bits/stdc++.h> using namespace std; int a[100010], n, k; bool check(int x) { long long cnt = 0; for (int i = 0; i < n; i++) { cnt += a[i] / x; // 这根能切几段 if (cnt >= k) return true; // 够了就早退 } return false; } int main() { cin >> n >> k; int maxLen = 0; for (int i = 0; i < n; i++) { cin >> a[i]; maxLen = max(maxLen, a[i]); } int L = 1, R = maxLen, ans = 0; while (L <= R) { int mid = L + (R - L) / 2; if (check(mid)) { ans = mid; L = mid + 1; } else R = mid - 1; } cout << ans << endl; return 0; }
1
读入时求 maxLen
上界顺手就得到了。
2
求最大 → 往大试
可行就 ans = mid、L = mid + 1。
∑
O(n log(maxLen)),
约 3 × 10⁶ 次运算。
例题 2 · 分书(求最小)

让「最累的那个人」页数尽量少

本课范围:1≤m≤n≤10⁵,每本页数 1~10⁹。n 本书按顺序分给 m 个人,每人分到的书必须连续、且至少一本。求「分到最多页数的那个人」最少要背多少页。
答案范围
下界 L = max(pages):最厚那本谁背都得背完。
上界 R = sum(pages):全给一个人。
↓
求最小:可行就 R = mid − 1。
1
check(x):每人上限 x 页
从第一本开始往后塞,塞不下就换下一个人。
2
用到的人数 ≤ m 就可行
贪心得到最少组数;不足 m 组时可继续拆分连续组,不会增大组内页数上限。
3
单本超过 x → 直接不可行
这一条漏了,明明分不下去也会返回可行。
分书 · 两块接成完整程序

总页数、mid、ans、组内累加统一用 long long

int n,m; vector<long long> a; bool check(long long x) { int people=1; long long sum=0; for(long long pages:a) { if(pages>x) return false; if(sum+pages<=x) sum+=pages; else { people++; sum=pages; } if(people>m) return false; } return true; }
int main() { cin>>n>>m; a.resize(n); long long L=0,R=0; for(long long &x:a) { cin>>x; L=max(L,x); R+=x; } long long ans=R; // R=sum,已知可行 while(L<=R) { long long mid=L+(R-L)/2; if(check(mid)){ans=mid;R=mid-1;} else L=mid+1; } cout<<ans<<endl; }
!
输入 3 1、三本各 10⁹ 页时,答案为 3000000000。页数为正且 1≤m≤n,sum 是已知可行的上界。
跳石头 · check 核心片段

端点必须保留,中间石头先排序

河长 1≤L≤10⁹,中间 0≤n≤5×10⁴ 块互异石头,位置在 (0,L);最多移走 0≤m≤n 块。求最大最小间距。

a[0]=0; a[n+1]=L; sort(a.begin()+1, a.begin()+n+1); // 将右侧 check 接到“可行向右”的求最大模板
25 5 2 / 2 11 14 17 21 → 4
// 已排序,a[0]=0,a[n+1]=河长 bool check(int x) { int last = 0, removed = 0; for (int i = 1; i <= n + 1; i++) { if (a[i] - a[last] < x) removed++; // 计数,终点本身不移走 else last = i; // 留下,当新起点 } return removed <= m; }
!
扫描到终点且间距不足时,计数等价于移走前一块保留石头,终点本身没有移动。初值 last=0 是起点下标。
对照 · 两种二分

上一单元二分下标,这一单元二分答案

对比项标准二分二分答案
二分对象有序数组的下标答案本身(一个数值)
比较依据a[mid] 与 target 比大小check(mid) 为真还是为假
循环何时停找到目标就能 return必须跑完整个区间
答案在哪mid(或 −1)最后一个 / 第一个可行的 ans
∪
两者共用同一个骨架:有序(单调)+ 每次砍一半 + 明确的终止条件。区别只在「拿什么去比」。
当堂练习

下面哪个问题,最适合用二分答案?

A 在升序数组里查找 7 的下标
B 将物品按顺序分给 m 人,求最小的最大负重
C 判断 15 是否在升序数组里
D 求数组里的最大值
判断三问:问的是「最值」吗?
范围框得出吗?给 x 能判可行吗?
选一个,并说出理由:
A查 7 的下标
B连续分配的最大负重最小
C判断是否存在
D数组最大值
✓ 选 B:连续分成至多 m 组,给定最大负重上限可 O(n) 判定,且上限越大越容易可行。
小结

判定正确、单调性明确,再套模板

问题可行区间可行后怎样缩边界
木材:求最大长度一段前缀(真→假)L=mid+1,尝试更大
分书:求最小上限一段后缀(假→真)R=mid−1,尝试更小
范围与初值依题目证明记录 ans,并正确区分无解
!
check 为 O(n)、答案范围宽度为 V 时,二分部分为 O(n log V)。下界不统一固定为 1;木材题才因除法排除 0。
大卫信奥 · J4 知识点 · 五单元
05

分治与快速排序

把大问题劈成两半,让基准元素一次归位——平均 O(n log n) 的原地排序。

分而治之 双指针分区 基准 不稳定
分治与快速排序封面
学习目标概览(按内容分组)

四个环节:从「分而治之」到一次分区

A
分而治之
分解、解决、合并——什么样的题能分?
B
分区
双指针一左一右,把小的甩到左边。
C
递归成代码
基准归位,左右两半各排各的。
D
复杂度与优化
什么时候退化?基准怎么选?
01
PART 01 · 分而治之

大问题拆小,小到不用再拆

排序 100 万个数很难,排序 1 个数不用排——中间差的,就是「怎么拆」和「怎么拼」。

三步:分解 / 解决 / 合并 三个适用条件
分治 · 什么是分而治之

分解 → 解决 → 合并,三步走完一个大问题

1
分解
把原问题拆成若干个规模更小、结构相同的子问题。
2
解决
小到能直接算就直接算,否则继续递归拆。
3
合并
把子答案拼回原答案——快排这一步是空的。
★
能分治的题要满足三条:子问题结构相同、规模变小后能直接解、子答案能合并。缺一条,拆了也白拆。
分治 · 两条路线

快排:分解费劲,合并免费

快速排序(本单元)
分解:选基准、分区,比较费力;
合并:左半排好、右半排好,拼起来天然有序,不用合并;
空间:原地,O(log n) 栈。
归并排序(下一单元)
分解:从中间一刀切,极其简单;
合并:两段有序数组要归并成一段,费力;
空间:需要 O(n) 辅助数组。
⇄
同样叫分治,力气花的位置正好相反:快排把功夫下在「分」,归并把功夫下在「合」。
02
PART 02 · 分区

一次扫描,让基准找到最终位置

选一个基准,把「不大于它的」全甩到左边,「大于它的」全甩到右边——它就再也不用动了。

双指针 i / j 先动 j 再动 i 基准归位
分区 · 要做什么

左边都不大于基准,右边都大于基准

取第一个元素当基准 p = a[l]
先把它「寄存」起来,位置空着,等着最后换回来。
1
j 从右往左走:找第一个 ≤ p 的数,停下
2
i 从左往右走:找第一个 > p 的数,停下
3
交换这两个数:重复直到 i 与 j 相遇
i → ← j 30 24 5 58 18 36 12 42 39 p = 30 左边:≤ 30 的"小个子" 右边:> 30 的"大个子" i 找到 58,j 找到 12 → 交换
必须先动 j:保证相遇点的值一定 ≤ p。
手推 · 第一轮分区

j 找到 12,i 找到 58,交换一次

位置012345678
数值30245581836124239
起点ij
停下i 停j 停
⇄
j:39→42→12(≤30,停)
i:30→24→5→58(>30,停)
交换 a[3] 与 a[6]
为什么 j 先走?
相遇点最后要和 a[l] 交换。先动 j 能保证相遇处的值 ≤ p,换过去才不破坏左半区。
手推 · 相遇与归位

从上一轮交换继续追踪,再让基准归位

位置012345678
交换后30245121836584239
本页起点ij
相遇时i j
1
j 继续左移:58、36 都 > 30,到 18 停
2
i 追上来:12、18 都 ≤ 30,i = j = 4
3
基准与 a[4] 交换:30 ↔ 18,基准归位
归位后
18245123036584239
左半 4 个,全 ≤ 30右半 4 个,全 > 30
✓
9 个数变成 4 + 4,基准 30 不再参与后续排序。
03
PART 03 · 写成代码

分区写进 while,递归收在末尾

二十行的 qsort:两个指针、三次交换、两次递归——每一行都要看清楚在干什么。

完整模板 关键行 降序怎么改
快速排序 · 模板

二十行的 qsort:分区 + 两次递归

void qsort(int l, int r) { if (l >= r) return; // 只剩 0/1 个数 int p = a[l]; // 基准:第一个 int i = l, j = r; while (i != j) { while (a[j] > p && i < j) j--; // 右找小 while (a[i] <= p && i < j) i++; // 左找大 if (i < j) swap(a[i], a[j]); } swap(a[l], a[i]); // 基准归位 qsort(l, i - 1); // 排左半 qsort(i + 1, r); // 排右半 }
1
终止条件
l ≥ r 就返回,别让递归停不下来。
2
分区
每一轮都保证 i、j 在往中间靠。
3
递归两半
基准不参与,边界用 i−1 / i+1。
快速排序 · 关键行

五行代码,五个容易写错的地方

代码作用写错的后果
if (l >= r)终止条件漏了会无限递归
int p = a[l]存下基准值用 a[l] 边比较边被覆盖
while (a[j] > p)右指针找 ≤ p改变严格性后要重新分析分区性质与相等值处理
while (a[i] <= p)左指针找 > p漏了 = 会在相等处卡死
swap(a[l], a[i])基准归位退出时 i==j,换 a[j] 等价;基准别再递归
!
两个内层 while 都必须带 i < j,否则指针会冲出边界——这是快排最常见的死循环来源。
快速排序 · 降序

改两个符号,升序变降序

升序(大个子往右赶)
while (a[j] > p && i < j) j--; while (a[i] <= p && i < j) i++;
降序(小个子往右赶)
while (a[j] < p && i < j) j--; while (a[i] >= p && i < j) i++;
⇄
规律:所有比较符号一起反过来。j 找「该去右边」的,i 找「该去左边」的,方向就对了。
04
PART 04 · 复杂度与稳定性

平均很快,最坏很惨

快排的速度全看「分得均不均」:每次对半开是 n log n,每次只切掉一个就退化成 n²。

递推式 有序数据 基准选择 不稳定
快速排序 · 复杂度

T(n) = 2T(n/2) + O(n) → O(n log n)

情况时间复杂度什么时候发生
最好O(n log n)每次都对半分
平均O(n log n)随机数据,绝大多数情况
最坏O(n²)已有序 / 逆序,每次只切一个
为什么是 log n 层?
每层分区总共扫 O(n) 个元素;对半分只需要 log₂n 层。
空间不是 O(1)
原地排序不假,但递归栈要 O(log n)(最坏 O(n))。
快速排序 · 最坏情况

已排好序的数组,反而最慢

[1, 2, 3, 4, 5] p=1 → [] 和 [2,3,4,5] p=2 → [] 和 [3,4,5] p=3 → [] 和 [4,5] p=4 → [] 和 [5] 每次只切掉 1 个,递归 n 层 1 + 2 + 3 + … + n = O(n²)
1
第一个元素永远最小:左半永远是空的
2
n = 10⁵ 时:10¹⁰ 次比较,直接超时
!
所以「第一个元素当基准」只能用来教学,真上场要换策略。
快排 · 基准选择

平均、期望与最坏界,分开说明

选法通常表现最坏/前提
首元素随机输入下可均衡有序或大量等值可 O(n²)
随机基准互异键期望 O(n log n)最坏仍 O(n²)
三数取中常改善普通输入最坏仍 O(n²)
按秩中位数取决于选取与分区实现须按秩平衡且选择代价受控
int mid = l + (r - l) / 2; // 让 a[l] ≤ a[mid] ≤ a[r] if (a[mid] < a[l]) swap(a[mid], a[l]); if (a[r] < a[l]) swap(a[r], a[l]); if (a[r] < a[mid]) swap(a[r], a[mid]); swap(a[mid], a[l]); // 中位数换到首位
!
三数取中只是三个候选的中间值,不是整个区间的中位数。大量相等元素应考虑三路分区;随机化本身不能修复本课双路分区在全等值上的不均衡。
快速排序 · 稳定性

同样大的元素,顺序可能被换掉

三个 3 分别记作 a、b、c
排前:[3a, 3b, 1, 4, 3c]
排后可能是:[1, 3b, 3a, 3c, 4]

3a 与 3b 的先后被打乱了。
为什么会乱?
分区靠远距离交换:左边的 58 和右边的 12 直接对调,中间隔着的相等元素就被跨过去了。
✗
结论:快速排序不稳定。要稳定就换归并排序,或者给元素补一个「原始下标」当第二关键字。
05
PART 05 · 优化与实战

快排不止能排序

同一套分区,改一改就能找第 K 小;重复元素多、数组小,也各有对应的优化。

横向对比 三路快排 第 K 小 STL
横向对比

五种排序,各管一段场景

算法平均最坏空间稳定性
快速排序O(n log n)O(n²)O(log n)不稳定
归并排序O(n log n)O(n log n)O(n)稳定
堆排序O(n log n)O(n log n)O(1)不稳定
冒泡排序O(n²)O(n²)O(1)稳定
插入排序O(n²)O(n²)O(1)稳定
★
选型口诀:数据大、不要求稳定 → 快排;要稳定 / 求逆序对 → 归并;内存吃紧 → 堆排;n 很小 → 插入。
优化 · 重复元素很多怎么办

三路快排核心(已读入 a,0≤n≤2000):切成「小 / 等 / 大」三块

void qsort3(int l,int r) { if(l>=r) return; int p=a[l], lt=l, i=l, gt=r; while(i<=gt) { if(a[i]<p) swap(a[lt++],a[i++]); else if(a[i]>p) swap(a[i],a[gt--]); else i++; } qsort3(l,lt-1); qsort3(gt+1,r); }
1
相等的元素一次到位:
不用再递归它们
2
全是同一个数时:
一轮就从 n² 降到 O(n)
应用 · 第 K 小元素

核心片段:k 从当前区间的第 1 小开始计数

int kth(int l, int r, int k) { int pos = partition(l, r); // 基准归位 int cnt = pos - l + 1; // 左半边元素个数 if (k == cnt) return a[pos]; if (k < cnt) return kth(l, pos - 1, k); // 在左边 return kth(pos + 1, r, k - cnt); // 在右边 }
⚡
快排一次递归两边,快选(quickselect)只递归一边,平均 O(n)。
划分均衡时 n+n/2+…=O(n),一般快速选择最坏仍 O(n²)。完整程序给出 partition;本例 1≤k≤n≤2000。std::nth_element 提供同类选择接口。
现成工具

比赛里直接 sort,但要知道它是什么

sort(a, a + n); // 升序 sort(a, a + n, greater<int>()); // 降序 bool cmp(int x, int y) { return x > y; // 大的排前面 } sort(a, a + n, cmp); // 自定义规则
★
std::sort 是内省排序:快排为主,递归太深自动换堆排,小区间用插入排序——所以最坏也是 O(n log n)。
当堂练习

以 5 为基准,一轮分区后数组是什么样?

a = [5, 3, 8, 4, 2]
p = a[0] = 5

按课上的写法:j 先动、i 后动,相遇后把 a[l] 与 a[i] 交换。
一轮之后是:
A[2,3,4,5,8]
B[4,3,2,5,8]
C[3,2,4,5,8]
D[4,3,2,8,5]
✓ 选 B:j 停在下标 4(2),i 停在下标 2(8),交换后 i、j 在 3 相遇,5 与 4 对调。
小结

今天你学会了:

分治
分解 / 解决 / 合并 子问题结构相同 快排合并为空
分区
j 先走 i、j 相遇即归位 递归 [l,i−1] [i+1,r]
复杂度与坑
平均 O(n log n) 有序退化 O(n²) 不稳定
大卫信奥 · J4 知识点 · 六单元
06

归并与分治深化

一刀切到单个元素,再两两拼回有序——最坏 O(n log n),且排序稳定,顺手数清逆序对。

分解树 双指针合并 稳定 逆序对
归并排序封面
学习目标概览(按内容分组)

四个环节:把「合并」做成主角

A
先拆到底
一刀切两半,切到只剩一个元素。
B
双指针合并
两段有序数组,谁小先取谁。
C
写成代码
merge + mergeSort,二十多行搞定。
D
数逆序对
合并时顺手一加,O(n²) 变 O(n log n)。
01
PART 01 · 先拆到底

从中间切,一直切到只剩一个

8 个数太难排,1 个数不用排。归并排序的「分解」简单到只有一句话:mid 一刀两半。

mid = (l + r) / 2 l == r 停止 log n 层
归并 · 每个框是一段区间

8 个数拆 3 层,最终每段只有一个数

第 0 层
42152068385012
↙ ↘
第 1 层
4215206
8385012
↙ ↘↙ ↘
第 2 层
4215
206
838
5012
↙ ↘↙ ↘↙ ↘↙ ↘
第 3 层
42
15
20
6
8
38
50
12
!
每次只拆当前框内的区间。递归在 l≥r 时终止;合并时按相反方向把两个有序子段接起来。
02
PART 02 · 合并

两段有序,合成一段有序

归并的功夫全在 merge:两个指针各指一段,谁小就把谁抄进临时数组。

双指针 i / j 临时数组 b 谁小取谁
合并 · 怎么合

两个指针,四步合并

1
初始化
i 指左段头,j 指右段头,k 管临时数组。
2
比较取小
a[i] 与 a[j] 比,小的抄进 b,指针后移。
3
抄剩余
一段空了,另一段整段接到 b 末尾。
4
回写
把 b 里这 k 个数抄回 a 的 [l, r]。
★
为什么这样合是对的?两段各自有序,所以每段剩下的数一定不小于已取走的,取小的放前面永远不会错。
手推 · 合并一次

[6, 20] + [15, 42] → 四步合并,三次元素比较

步比较取谁临时数组 b
16 vs 1566
220 vs 15156 15
320 vs 42206 15 20
4左段空了42 直接接上6 15 20 42
→
第 4 步没有比较:一段走空后,另一段剩下的数整段照抄,不用再比。
合并完别忘回写
b[0..3] 要抄回 a[l..r],否则下一层合并用的还是旧数据。
合并 · 代码

merge:三个 while,一个 for

void merge(int l, int mid, int r) { int k = 0, i = l, j = mid + 1; while (i <= mid && j <= r) { // 两段都没空 if (a[i] <= a[j]) b[k++] = a[i++]; else b[k++] = a[j++]; } while (i <= mid) b[k++] = a[i++]; // 左剩 while (j <= r) b[k++] = a[j++]; // 右剩 for (int t = l, k = 0; t <= r; t++) a[t] = b[k++]; // 回写 }
1
a[i] <= a[j]
这里的「=」是稳定性的关键
2
两个收尾 while
谁剩抄谁,不用再比较
3
回写 a[l..r]
b 每次都从 0 开始用
03
PART 03 · 整体代码

递归只有三行

算 mid、递归两半、合并——归并排序的主函数短得不像话,因为难点都在 merge 里。

mergeSort 拼接后的完整程序 降序只改一处
接上前页 merge 的主程序

主函数只有三行:算 mid、递归、合并

int a[100010], b[100010], n; void mergeSort(int l, int r) { if (l >= r) return; // 只剩一个 int mid = l + (r-l) / 2; mergeSort(l, mid); // 左半有序 mergeSort(mid + 1, r); // 右半有序 merge(l, mid, r); // 合成一整段 } int main() { cin >> n; for (int i = 0; i < n; i++) cin >> a[i]; mergeSort(0, n - 1); for (int i = 0; i < n; i++) cout << a[i] << " "; return 0; }
1
l >= r 时终止
同时覆盖单元素与空区间,避免 n=0 时递归出错
2
先递归、后合并
顺序反了,合并的就是两段乱序
∑
完整顺序:头文件 → 全局数组 → merge → mergeSort → main。输入 0≤n≤10⁵;降序合并改用 a[i]>=a[j],相等仍取左。
归并 · 四个易错点

递归写对了,合并写错了,一样白搭

✗ mid 算成 (l+r+1)/2
左右区间会重叠或漏掉元素,递归直接停不下来。
✗ 忘了回写
临时数组白排了,a 还是老样子,上一层合并拿到废数据。
✗ 比较写成 <
相等元素会先取右边,排序就不再稳定。
✗ b 开小了
b 要能装下整个区间,开成 n 最省事。
04
PART 04 · 复杂度与稳定性

多花一倍空间,换来最坏的保证

归并的三层递归每层都合并 n 个数,所以最好最坏都是 n log n——代价是要一块临时数组。

O(n log n) 恒定 O(n) 空间 稳定
归并 · 复杂度

每层合并 n 个,一共 log n 层

情况时间复杂度说明
最好O(n log n)数据有序也照合不误
平均O(n log n)跟数据长什么样无关
最坏O(n log n)没有退化情况
T(n) = 2T(n/2) + O(n)
递归树每层总代价都是 O(n),共 log₂n 层,相乘得 O(n log n)。
!
快排最坏会掉到 n²,归并不会——这是它最值钱的地方。
归并 · 空间

O(n) 买来的「不变慢」

临时数组 b:O(n)
合并时要把两段按序抄进 b 再写回。b 的峰值只要和整个区间一样大,所以开 n 就够。
递归栈:O(log n)
每层只压一个栈帧,深度是 log₂n。两者相加,主导项仍是 O(n)。
⇄
对比:快排 O(log n) 空间但不保证最坏时间;归并 O(n) 空间换来确定性。内存换稳定,就是这笔交易。
归并 · 稳定性

相等时取左边,顺序就保住了

while (i <= mid && j <= r) { if (a[i] <= a[j]) // ← 关键:<= b[k++] = a[i++]; // 相等先取左 else b[k++] = a[j++]; }
★
左段元素在原序列中先于右段元素,相等时先取左,相对次序永远不变——这叫稳定。
本课常见快排实现不稳定:它靠远距离交换,相等元素会被跨过去。要稳定,就选归并。
05
PART 05 · 逆序对

排序顺手数出「反着排」的对

暴力两两比较是 O(n²),而归并合并时,每一次「取右边」都白送一批逆序对。

定义 合并时统计 O(n log n)
逆序对 · 是什么

前面的数比后面的大,就是一对

数组 [7, 5, 6, 4] 里:
7>5、7>6、7>4
5>4、6>4

一共 5 对。
暴力:两层循环
每对都看一眼,O(n²)。n = 10⁵ 时 10¹⁰ 次,超时。
?
排序的时候,能不能顺手把它们数出来?
逆序对 · 关键观察

取右边一个,左边剩下的全都比它大

左 [2, 5, 8],右 [3, 6, 9],i 指 5、j 指 3:

要取 3(右边),说明左边还没取的 5、8 都比 3 大
→ 一次产生 mid − i + 1 = 2 个逆序对。
1
只加一次,不是循环加:
左边是一整段,个数直接算
2
为什么一定大?
左段有序,剩下的只会更大
∑
合并本身 O(n),统计白送,总复杂度不变。
手推 · 数一遍

[2,5,8] + [3,6,9]:一共 3 个逆序对

步比较取谁新增逆序对累计
12 vs 32(左)00
25 vs 33(右)剩 5、8 → +22
35 vs 65(左)02
48 vs 66(右)剩 8 → +13
58 vs 98(左)03
✓
逐个验证:(5,3)、(8,3)、(8,6),正好 3 对。
别忘了最后 9 是「抄剩余」,不产生逆序对。
逆序对 · 代码

只比归并多一行:cnt += mid − i + 1

long long cnt = 0; void merge(int l, int mid, int r) { int i = l, j = mid + 1, k = 0; while (i <= mid && j <= r) { if (a[i] <= a[j]) b[k++] = a[i++]; else { b[k++] = a[j++]; cnt += mid - i + 1; // 关键一行 } } while (i <= mid) b[k++] = a[i++]; while (j <= r) b[k++] = a[j++]; for (int t = l, k = 0; t <= r; t++) a[t] = b[k++]; }
cnt 必须 long long
逆序对最多 n(n−1)/2,n = 10⁵ 时约 5×10⁹,爆 int。
只在「取右边」时加
取左边说明左边这个数比右边所有剩下的都小,不构成逆序对。
∑
O(n log n),与排序同阶。
归并 · 更擅长的场景

归并更适合的三类需求

01
外部排序
数据比内存大:分段排好写回文件,再多路归并。外部归并能以顺序读写降低磁盘 I/O 成本。
02
链表排序
链表也能做分区与快排,但缺少高效随机访问;归并的顺序访问方式通常更合适。
03
稳定需求
按成绩排完再按班级排,要求同班内保持成绩序——只有稳定排序做得到。
对照 · 两种分治排序

同样 n log n,脾气完全不同

特性快速排序归并排序
分治方式分区复杂,合并简单分解简单,合并复杂
最坏时间O(n²)O(n log n)
空间O(log n)O(n)
稳定性不稳定稳定
实测速度通常更快稳定但常数略大
⇄
选择很简单:图快、内存紧选快排;要稳定、要最坏保证、要数逆序对选归并。
当堂练习

合并 [1,4,9] 与 [2,3,10],要比几次?

左段 [1, 4, 9],右段 [2, 3, 10]

按 merge 的写法,每轮比较 a[i] 与 a[j],直到某一段走空。
一共比较几次:
A4 次
B5 次
C6 次
D7 次
✓ 选 B:1/2、4/2、4/3、4/10、9/10 共 5 次,之后右段只剩 10,直接抄。
小结

今天你学会了:

分解
mid 一刀两半 l >= r 停 log n 层
合并
双指针谁小取谁 剩段直接抄 回写 a[l..r]
性质与用法
恒 O(n log n) O(n) 空间 · 稳定 数逆序对
大卫信奥 · J4 知识点 · 七单元
07

简单动态规划

把算过的答案记在表里,用空间换时间——指数级递归,一次循环就能解决。

状态 转移方程 填表 01 背包
动态规划封面
学习目标概览(按内容分组)

四个环节:从「重复计算」到「填一张表」

A
为什么要 DP
斐波那契的递归树里,同一项被算了三次。
B
状态与转移
dp 表存什么?怎么从前一行推出来?
C
两道例题
网格路径、01 背包,一维一二维。
D
优化与坑
滚动数组、逆序遍历、边界初始化。
01
PART 01 · 从递归的浪费说起

同一个子问题,被算了三次

分治假设子问题互不相干;可现实里子问题常常重叠——算过的又算一遍,时间就这么没了。

斐波那契递归树 O(2ⁿ) 的代价 分治 vs DP
递归 · 重复计算现场

算 F(5),F(2) 被算了三次

子问题F(5)F(4)F(3)F(2)F(1)
被算几次11232
int fib(int n) { if (n <= 2) return 1; return fib(n-1) + fib(n-2); } // n = 50 时,调用次数为 25172538049 // 1 秒钟根本算不完
!
调用次数呈指数增长,O(2ⁿ) 是宽松上界,不是精确次数。
但 F(3) 的值只有一个。算一次记下来,后面直接查表——这就是 DP 的出发点。
对照 · 分治与动态规划

同样拆子问题,区别在「要不要存」

特征分治算法动态规划
子问题关系相互独立大量重叠
求解方向自顶向下递归自底向上迭代
中间结果不保存存进表格
时间可能重复计算每个子问题只算一次
⇄
判断标准很简单:子问题会不会重复出现。不会 → 分治;会 → 把答案存起来,就是 DP。
02
PART 02 · 状态与转移

先想清楚:表格里每一格是什么

DP 的难点不在写循环,而在「定义状态」——把题目翻译成一张表,转移方程自然就出来了。

五步套路 两个前提 无后效性
动态规划 · 标准五步

定义 → 转移 → 初始化 → 计算顺序 → 取答案

步骤要写清的问题
1 定义状态dp[i][j] 到底表示什么?
2 写转移该状态依赖哪些已有状态?
3 初始化边界值、不可达值如何设置?
4 定计算顺序确保读取依赖前,依赖已被正确计算
5 取答案题目要哪一格、哪一组状态或哪一次最值?
!
先解释状态的含义,再检查依赖方向。背包从二维压到一维后,遍历方向还关系到读取的是上一行还是本行。
动态规划 · 什么时候能用

最优子结构 + 子问题重叠

① 最优子结构
大问题的最优解,能由子问题的最优解拼出来。
反例:最长简单路径,拆开之后会重复走点,拼不回去。
② 子问题重叠
同一个子问题会反复用到,存下来才划算。
如果子问题从不重复,那它就是普通分治。
+
还有一条隐含要求:无后效性:当前状态包含后续决策所需的历史信息,未来只依赖状态,而非到达它的具体路径。
03
PART 03 · 从斐波那契开始

最小的 DP:一维表格

F[i] = F[i−1] + F[i−2]。一维数组、一个循环,把 2ⁿ 压成 n。

F[1] = F[2] = 1 正着推 还能省空间
斐波那契 · 填表

从左往右,一格一格推

i12345678910
F[i]11235813213455
F[5] = F[4] + F[3]
= 3 + 2 = 5。前两格一加,就是当前格。
→
这就是「转移」:每一格只依赖它前面两格,填表顺序必须从左到右。
斐波那契 · 代码

一个循环干掉指数递归

long long F[55]; int main() { int n; cin >> n; F[1] = F[2] = 1; // 边界 for (int i = 3; i <= n; i++) F[i] = F[i-1] + F[i-2]; // 转移 cout << F[n]; return 0; }
本页 1≤n≤50;F[50]=12586269025,两种版本均使用 long long。
时间 O(n)
n=50 只要 48 次加法;本页递归版本需要 25172538049 次函数调用。
空间 O(n) → O(1)
只用前两格:两个变量滚动即可,不需要整张表。
long long a = 1, b = 1; // 1 <= n <= 50 for (int i=3; i<=n; i++) { long long c = a + b; a = b; b = c; } cout << b;
04
PART 04 · 地下管道

只准向右、向下,有几条路?

二维表格的第一次登场:每一格的路径数 = 上面那格 + 左边那格。

dp[i][j] 上 + 左 边界巧设
地下管道 · 题目

只能向右或向下,来路只有上与左

输入:n m,1<n,m<20。
从 (1,1) 到 (n,m),每次只走右或下一格。输出不同路径数。

样例输入样例输出
2 33
3 410
!
dp[i][j] 的来路只能是 (i−1,j) 和 (i,j−1),所以把这两个状态的路径数相加。
3×4 中的一条示例路径
起点
(1,1)
→
↓
↓
→
终点
(3,4)

黄色路线走 3 次右、2 次下;所有路线共有 10 条。

地下管道 · 填表

dp[i][j] = dp[i−1][j] + dp[i][j−1]

① 状态:dp[i][j] = 走到 (i,j) 的路径数
② 转移:上面那格 + 左边那格
③ 边界:dp[1][0] = 1(虚拟起点)
3 × 4j=1j=2j=3j=4
i=11111
i=21234
i=313610
为什么第一行全是 1?
只能一路向右,没有别的走法。第一列同理。
∑
答案在 dp[n][m];n,m < 20 时最多 C(36,18)=9075135300,要 long long。
地下管道 · 代码

两层 for,一行转移

long long dp[25][25]; int main() { int n, m; cin >> n >> m; dp[1][0] = 1; // 虚拟起点 for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) dp[i][j] = dp[i-1][j] + dp[i][j-1]; cout << dp[n][m]; return 0; }
dp[1][0] = 1 的妙处
给 (1,1) 一个「左边来的」入口,第一行就不用单独写特判。
遍历顺序
i、j 都从小到大:算当前格时,上面和左边都已就绪。
∑
O(n·m) 时间,O(n·m) 空间。
05
PART 05 · 01 背包

每件东西,拿还是不拿

二维 DP 的招牌题:前 i 件物品、容量 j,能装出的最大价值是多少?

选 / 不选 二维表 一维逆序优化
01 背包 · 题目

n 件物品,背包容量 V,价值最大是多少

每件物品有重量 w[i]、价值 v[i],每件最多拿一次。求不超过容量 V 时能拿到的最大总价值。
范围:1≤n≤100,0≤V≤1000,1≤w[i]≤1000,0≤v[i]≤10⁶。
输入:n V,然后 n 行 w[i] v[i]
输出:最大价值
?
拿第 i 件:容量少 w[i],价值多 v[i];不拿:原样照抄上一行。
样例
4 5 1 2 2 4 3 4 4 5 8 // 拿 1+2 两件:2+4=6? // 拿 1、2、3 号:重 6 超了 // 拿 2、3 号:重 5,价值 8 ✓
01 背包 · 状态与方程

dp[i][j]:前 i 件、容量 j 的最大价值

if (j >= w[i]) dp[i][j] = max( dp[i-1][j], // 不拿 dp[i-1][j-w[i]] + v[i] // 拿 ); else dp[i][j] = dp[i-1][j]; // 装不下
不拿:抄上一行
dp[i][j] 至少等于 dp[i-1][j]
拿:腾出 w[i] 的空间
用 dp[i-1][j-w[i]] 再加 v[i]
!
两种选择取 max——这就是「最优子结构」。
01 背包 · 填表

样例跑一遍:最后一行最后一格是 8

物品 \ 容量012345
0(空)000000
1(w1 v2)022222
2(w2 v4)024666
3(w3 v4)024668
4(w4 v5)024668
→
看 dp[3][5]:不拿第 3 件是 6;拿了就是 dp[2][2] + 4 = 4 + 4 = 8,取大得 8。
每一行只依赖上一行,所以可以只留一行——下页的一维优化就来自这里。
01 背包 · 二维写法

两层循环,一次 max

int w[110], v[110], dp[110][1010]; int main() { int n, V; cin >> n >> V; for (int i = 1; i <= n; i++) cin >> w[i] >> v[i]; for (int i = 1; i <= n; i++) { for (int j = 0; j <= V; j++) { dp[i][j] = dp[i-1][j]; // 不拿 if (j >= w[i]) dp[i][j] = max(dp[i][j], dp[i-1][j-w[i]] + v[i]); // 拿 } } cout << dp[n][V]; }
先抄再比
先 dp[i][j] = dp[i-1][j],再考虑拿的情况,少写一个分支。
j 从 0 开始
容量 0 也要填,它全是 0。
∑
O(nV) 时间、O(nV) 空间。
01 背包 · 一维优化

只留一行,但容量必须倒着扫

int dp[1010] = {0}; for (int i = 1; i <= n; i++) for (int j = V; j >= w[i]; j--) dp[j] = max(dp[j], dp[j-w[i]] + v[i]); cout << dp[V];
注意 j 是 V → w[i] 递减。
正序会怎样?
dp[j-w] 已经是本行更新过的值 → 同一件物品被拿两次。
倒序为什么对?
dp[j-w] 还是上一行的值,等价于二维的 dp[i-1][j-w]。
!
完全背包恰好相反:正序遍历,允许重复拿。
动态规划 · 四个坑

方程写对,也可能拿不到分

✗ 边界没初始化
dp[1][j]、dp[i][0] 忘了填,后面拿到的全是 0 或随机值。
✗ 数组开小
n、V 各留 5~10 的余量;二维数组别开成 [n][V] 的精确值。
✗ 忘记 long long
路径数、方案数增长极快,10⁹ 以上就要换 long long。
✗ 遍历顺序反了
按状态依赖确定顺序;读取前须保证依赖已正确计算。一维 01 背包倒序,是为读取上一阶段的值。
当堂练习

4 × 4 的网格,从左上到右下有几条路?

只能向右、向下,用同样的转移:
dp[i][j] = dp[i−1][j] + dp[i][j−1]

第一行、第一列都是 1。
dp[4][4] 等于:
A6
B10
C20
D24
✓ 选 C:逐行填 1 1 1 1 / 1 2 3 4 / 1 3 6 10 / 1 4 10 20。
小结

今天你学会了:

为什么用 DP
子问题重叠 2ⁿ → n 存表避免重算
五步套路
定义状态 转移方程 初始化 计算顺序 取答案
两个模型
网格路径:上 + 左 01 背包:选 / 不选 一维要倒序
大卫信奥 · J4 知识点 · 八单元
08

因数与倍数

因数成对出现,所以只要枚举到 √n;gcd 一次辗转相除,素数一次试除。

因数成对 gcd / lcm 质因数分解
因数与倍数封面
今日路线

四个环节:从「谁整除谁」到分解质因数

01
因数
成对出现,枚举到 √n 就够。
02
gcd 与 lcm
辗转相除,一行递归。
03
素数
试除到 √n,判一个数快不快?
04
质因数分解
短除法拆开,因数个数随手算。
01
PART 01 · 因数与倍数

能整除,就是因数

6 ÷ 3 = 2 没有余数,所以 3 是 6 的因数,6 是 3 的倍数——一件事的两种说法。

a % b == 0 因数成对 枚举到 √n
因数 · 定义

a % b == 0,就写 b | a

如果 a ÷ b 没有余数:

b 是 a 的因数(约数)
a 是 b 的倍数

记作 b | a,读作「b 整除 a」。
只在正整数里讨论
1 是任何数的因数;任何数都是自己的倍数。0 不参与。
代码里就一句
if (a % b == 0) …
★
「因数」和「倍数」是同一件事的两个方向:说 12 的因数是 1、2、3、4、6、12,等价于说这些数都整除 12。
因数 · 对称性

找到一个因数,就白送一个

n因数个数
61、2、3、64
161、2、4、8、165
361、2、3、4、6、9、12、18、369
6 = 1 × 6 = 2 × 3
16 = 1 × 16 = 2 × 8 = 4 × 4

每一对相乘都等于 n,所以只要试到 √n。
√
i 是因数,n / i 也是。枚举 i 从 1 到 √n:i × i == n 时只算一次(完全平方数),否则一次加两个。
02
PART 02 · 最大公约数

一直取余,直到余数为 0

欧几里得两千年前就发现:gcd(a,b) = gcd(b, a mod b)——不用分解质因数也能求最大公约数。

辗转相除 gcd × lcm = a × b 防溢出
gcd 与 lcm · 是什么

公共因数里最大的,公共倍数里最小的

最大公约数 gcd
12 的因数:1 2 3 4 6 12
8 的因数:1 2 4 8
公共的:1 2 4 → gcd = 4
最小公倍数 lcm
12 的倍数:12 24 36 …
8 的倍数:8 16 24 …
公共的:24 → lcm = 24
=
两者有一条铁律:gcd(a,b) × lcm(a,b) = a × b。所以求出 gcd,lcm 一除就有。
辗转相除 · 手推

gcd(48, 18):三步就见底

步骤abr = a mod b变成
初始481812gcd(18, 12)
第 1 次18126gcd(12, 6)
第 2 次1260gcd(6, 0) = 6
→
余数为 0 时,除数就是答案,不是被除数。最后一步 b = 6。
为什么可以这样换?
a 和 b 的公共因数,一定也整除 a − k·b,也就是余数 r。
gcd / lcm · 非负整数

递归、循环与检查溢出的 lcm

long long gcdLoop(long long a,long long b) { while(b) { long long r=a%b; a=b; b=r; } return a; } long long gcdRec(long long a,long long b) { return b==0 ? a : gcdRec(b,a%b); }
bool lcmChecked(long long a,long long b, long long &answer) { if(a==0 || b==0) { answer=0; return true; } long long q=a/gcdLoop(a,b); if(q>LLONG_MAX/b) return false; answer=q*b; return true; }
!
本例 0≤a,b≤10¹⁸。先除后乘仅减少中间值;结果本身仍可能超范围。lcmChecked 返回 false 表示 long long 无法容纳结果。
03
PART 03 · 素数与分解

只能被 1 和自己整除

判素数是数论的基本功:试到 √n 就够;把合数拆成质数相乘,是整个单元的核心工具。

试除到 √n 唯一分解定理 短除法
素数 · 判断

试除到 √n:有因数必然成对

bool isPrime(int n) { if (n < 2) return false; // 0、1 不是 for (int i = 2; i <= n / i; i++) { // 等价 i*i <= n if (n % i == 0) return false; } return true; }
为什么到 √n 就停?
若 n = a×b 且 a ≤ √n ≤ b,那么小的那个因子一定在 √n 之内。
为什么写 i <= n / i?
i*i 在 i 接近 46341 时可能溢出;除法写法更安全。
∑
单个 O(√n);n = 10⁹ 时约 3 万次,很快。
质因数分解 · 短除法

360 = 2³ × 3² × 5,拆法唯一

360 ÷ 2 = 180 180 ÷ 2 = 90 90 ÷ 2 = 45 45 ÷ 3 = 15 15 ÷ 3 = 5 ← 5 是素数,停 360 = 2³ × 3² × 5
唯一分解定理
任何大于 1 的整数,都能唯一地写成若干素数的乘积(不计顺序)。
短除法怎么做
从最小的素数 2 试起,能除就一直除;除不动换下一个素数,直到剩下的数是 1。
★
有了分解式,因数个数、gcd、lcm 全都能算:gcd 取指数较小者,lcm 取较大者。
质因数分解 · 代码

试到 √n,剩下的必是素数

void factor(long long n) { for (long long i = 2; i <= n / i; i++) { while (n % i == 0) { cout << i << " "; n /= i; // 除干净 } } if (n > 1) cout << n; // 剩下的大素数 }
n 在循环里越变越小
条件是 i <= n / i,用的是被除过的 n,更快。
最后那个 if 别漏
例如 14:除完 2 后剩 7,循环已经结束,7 要靠这句输出。
∑
本页 1≤n≤10¹²,n 与 i 用 long long。最坏试除约 √n 次,实际会随剩余 n 缩小。
04
PART 04 · 三个常见套路

完全平方、因数个数、枚举倍数

质因数分解是把钥匙:拆开之后,奇偶性、个数、配对关系都一目了然。

完全平方数 因数个数 调和级数枚举
应用 · 完全平方数

浮点估计后,用整数运算校正

正因数成对出现,只有平方根会与自己配成一对。
所以:正整数因数个数为奇数 ⇔ 完全平方数。

正整数因数个数
16=2⁴5(奇数)
36=2²×3²9(奇数)
18=2×3²6(偶数)
bool isSquare(long long n) { if(n<0) return false; if(n==0) return true; long long r=sqrtl((long double)n); while(r>n/r) --r; while(r+1<=n/(r+1)) ++r; return n%r==0 && n/r==r; }
!
本页 0≤n≤10¹⁸。sqrtl 返回浮点近似;用除法比较修正 r,最后用商和余数验证,避免 r*r 的溢出风险。
质因数分解后的应用

每个指数加一,再把选择数相乘

36=2²×3²2 的指数可选3 的指数可选
因数的构成0、1、2,共 3 种0、1、2,共 3 种
组合总数3 × 3 = 9与前面列举的 9 个因数一致
!
若 n=p₁ᵉ¹p₂ᵉ²…,正因数个数为 (e₁+1)(e₂+1)…。例如 72=2³×3²,得到 4×3=12。此公式在素数与唯一分解之后使用。
应用 · 因数个数

边分解边计数,一次 O(√n)

int cnt = 1; for (long long i = 2; i <= n / i; i++) { int e = 0; while (n % i == 0) { n /= i; e++; } cnt *= (e + 1); // 指数 + 1 } if (n > 1) cnt *= 2; // 剩一个大素数 cout << cnt;
为什么初值是 1?
它是连乘的「空积」,每遇到一个质因数就乘上(e+1)。
剩下的 n > 1
说明还有一个指数为 1 的素数,贡献 (1+1)=2。
应用 · 换个方向枚举

枚举因数太慢?那就枚举倍数

for (int d = 1; d <= n; d++) for (int m = d; m <= n; m += d) cnt[m]++; // m 多了一个因数 d
总次数 = n/1 + n/2 + … + n/n ≈ n ln n
∑
这就是调和级数:看似两层循环,实际 O(n log n),n = 10⁶ 也能跑。
要求「1 ~ n 每个数的因数个数」时,这个写法比逐个分解快得多。
数论 · 四个坑

都写对了,却 WA 在这些地方

✗ i * i <= n
常见 32 位 signed int 的 i*i 可能溢出;有符号溢出行为未定义。正数试除可用 i<=n/i。
✗ lcm 先乘后除
先除后乘可减小中间值,但还需证明结果可表示,或在相乘前检查范围。
✗ 忘了 n > 1
分解 14 只输出 2,漏掉最后的 7。
✗ 把 1 当素数
1 既不是素数也不是合数;循环要从 2 开始。
当堂练习

72 有多少个因数?

先分解:72 = 8 × 9 = 2³ × 3²

再套公式:(3+1)(2+1)
答案是:
A6 个
B8 个
C12 个
D16 个
✓ 选 C:4 × 3 = 12 个(1,2,3,4,6,8,9,12,18,24,36,72)。
小结

今天你学会了:

因数
成对出现 枚举到 √n (e₁+1)(e₂+1)…
gcd 与 lcm
辗转相除 gcd × lcm = a × b 先除后乘
素数与分解
试除到 √n 短除法 剩下的大素数别漏
大卫信奥 · J4 知识点 · 九单元
09

高效素数筛选

不再一个一个判:把合数整批划掉,一次预处理,后面全是查表。

埃氏筛 欧拉筛 区间筛
素数筛封面
今日路线

四个环节:从「逐个判」到「一次划完」

01
为什么慢
每个数都试除到 √n,n 大了就顶不住。
02
埃氏筛
素数至少 2 倍的正整数倍,划为非素数。
03
欧拉筛
每个合数只划一次,做到 O(n)。
04
应用
真题、区间筛、怎么选。
01
PART 01 · 逐个判太慢

要 1 ~ n 的所有素数,还一个一个判?

判一个数 O(√n) 不算慢,可 n 个数加起来就是 n√n——10⁶ 时约 10⁹ 次,超时。

n√n 的代价 换个方向
问题 · 规模一上来就顶不住

n 个数 × 每个 √n 次 = n√n

n逐个判断能不能过
10³约 3 万次轻松
10⁵约 3×10⁷ 次勉强
10⁶约 10⁹ 次超时
for (int x = 2; x <= n; x++) if (isPrime(x)) ans++; // 外层 n 次,内层 √n 次
?
反过来想:与其判断每个数是不是素数,不如把已知素数的倍数全部划掉——剩下的自然就是素数。
02
PART 02 · 埃拉托斯特尼筛

素数至少 2 倍的正整数倍是合数

从小到大扫,遇到没被划掉的数,它就是素数;顺手把它至少 2 倍的倍数标成非素数;从平方起能减少重复。

标记数组 从 i² 开始划 O(n log log n)
埃氏筛 · 四步

开一个标记数组,全当素数

1
全标 0
pri[i] = 0 表示「暂时认为是素数」。
2
从 2 扫
遇到 pri[i] == 0,i 就是素数。
3
划倍数
i²、i²+i、i²+2i… 全部标 1。
4
扫到 √n
更大的素数,其平方已超过 n。
★
为什么从 i² 开始划?因为 2i、3i… 早被更小的素数划过了,从 i² 起才没浪费。
埃氏筛 · 逐步标记

1~30:先排除 1,再划 2、3、5 的倍数

先排除 1:它不是素数
用 2 划:从 4 开始,每次加 2
用 3 划:从 9 开始,每次加 3
用 5 划:从 25 开始,完成
×1
2
3
×4
5
×6
7
×8
×9
×10
11
×12
13
×14
×15
×16
17
×18
19
×20
×21
×22
23
×24
×25
×26
×27
×28
29
×30
!
最终剩下 2、3、5、7、11、13、17、19、23、29,共 10 个素数。下一个素数 7 的平方已超过 30。
埃氏筛 · 代码

两层循环,一次预处理

const int N=1000010; // 本页 0<=n<=1000000 int pri[N]; // 1=非素数,0=尚未标记 void sieve(int n) { fill(pri,pri+n+1,0); pri[0]=pri[1]=1; // 0、1 都不是素数 for(int i=2;i<=n/i;i++) if(!pri[i]) for(long long j=1LL*i*i;j<=n;j+=i) pri[j]=1; }
i * i 别溢出
本示例 n≤10⁶;循环条件用 i≤n/i,标记起点用 1LL*i*i,数组按 n 上限分配。
复杂度
O(n log log n),n = 10⁶ 只有几百万次操作。
∑
预处理一次,后面每个查询 O(1)。
埃氏筛 · 不完美的地方

30 被划了三次

2 的时候划一次(30 是偶数)
3 的时候再划一次(3 的倍数)
5 的时候又划一次(5 的倍数)

同一个合数,被重复标记。
浪费得不算多
总次数是 n/2 + n/3 + n/5 + … ≈ n log log n,已经很接近线性。
?
能不能让每个合数只被划一次?
03
PART 03 · 欧拉筛(线性筛)

每个合数,只被划一次

用「i × 已有素数」生成合数,一旦 i 能被某个素数整除就停手——保证只由最小质因数生成。

primes 数组 break 的时机 O(n)
欧拉筛 · 手推 i = 2…6

每个合数只出现一次

i是素数?本轮标记的合数为什么停
2是 → primes=[2]42 % 2 == 0
3是 → [2,3]6, 93 % 3 == 0
4否84 % 2 == 0
5是 → [2,3,5]10, 15, 255 % 5 == 0
6否126 % 2 == 0
★
12 只在 i=6、p=2 出现一次;18 会在 i=9、p=2 出现——都由最小质因数 2 划掉。
i=4 是合数也要参与:4×2=8 的最小质因数正是 2。
欧拉筛 · 代码

break 的位置,决定了 O(n)

const int N=1000010; // 本页 0<=n<=1000000 int pri[N],primes[N],p; void eulerSieve(int n) { fill(pri,pri+n+1,0); p=0; pri[0]=pri[1]=1; // 1=非素数,非“合数” for(int i=2;i<=n;i++) { if(!pri[i]) primes[p++]=i; for(int j=0;j<p && primes[j]<=n/i;j++) { pri[primes[j]*i]=1; if(i%primes[j]==0) break; } } }
先标记,再判断 break
顺序反了会漏掉 i × primes[j] 这个合数。
为什么能 break?
primes[j] 已是 i 的最小质因数,再乘更大的素数,最小质因数就不是它了。
∑
每个合数恰好被标记一次 → O(n)。
对比 · 三种求素数的方式

从 n√n 到 n log log n 到 n

做法时间复杂度特点
逐个判断O(n√n)写起来最简单,n 大了超时
埃氏筛O(n log log n)短、够快,但合数被重复划
欧拉筛O(n)线性,稍长,常用来求积性函数
→
实际速度受常数、缓存与实现影响;要顺手求别的东西(最小质因数、因数个数),欧拉筛更好用。
04
PART 04 · 真题与扩展

预处理一次,回答一万次

筛法最典型的用法:先把 1 ~ N 的素数表建好,之后每次查询都是 O(1) 查表。

GESP 真题 区间筛 怎么选
真题模型 · 挑战怪物

枚举物理次数,让总伤害恰好等于 h

物理第 i 次伤害 2ⁱ⁻¹;魔法最多一次,伤害必须为素数。血量必须恰好减到 0。
输入 T 组 h;本课范围 1≤h≤10⁵,无解输出 −1。

血量最少次数一种方案
94魔法 2 + 物理 1+2+4
154只用物理 1+2+4+8
1884魔法 181 + 物理 1+2+4
// prime 已筛到最大血量 int answer(int h) { int best=INT_MAX; long long s=0; for(int k=0;s<=h;k++) { int rest=h-s; if(rest==0) best=min(best,k); else if(prime[rest]) best=min(best,k+1); s=s*2+1; } return best==INT_MAX ? -1 : best; }
!
任意 k 次物理总伤害固定为 2ᵏ−1。余量为 0 则不用魔法;余量是素数则加一次魔法。全枚举后取最小值,不能只找不超过 h 的最大素数。
扩展 · 区间筛核心

先筛小素数,再标区间内的倍数

// 已预筛出 sqrt(R) 内的 basePrime vector<bool> isPrime(R-L+1,true); for(long long x=L; x<=R && x<2; x++) isPrime[x-L]=false; // 仅处理区间内的 0、1 for(int p=2;p<=limit;p++) if(basePrime[p]) { long long start=max(1LL*p*p,(L+p-1)/p*p); for(long long j=start;j<=R;j+=p) isPrime[j-L]=false; }

本课约定:0≤L≤R≤10¹²,R−L≤10⁶。
limit=⌊√R⌋,先生成 basePrime。区间表先全部置 true。

!
每个素数 p 的起点为 max(p²,区间内第一个 p 的倍数),避免划掉 p 自己。只处理实际落在 [L,R] 内的 0、1。
[0,10] → 2 3 5 7
[1,1] → 无素数
选型 · 别杀鸡用牛刀

看数据范围,三选一

01
只问几个数
查询次数少、n 不大:直接 isPrime 试除,别费劲筛。
02
要 1 ~ n 全表
先估算数组内存;再按任务、常数和缓存表现选择埃筛或欧拉筛。
03
区间很大
[L, R] 里 R 巨大但区间短:用区间筛。
筛法 · 四个坑

筛法很短,坑很集中

✗ 忘了 pri[0] = pri[1] = 1
0 和 1 会被当成素数输出,第一个测试点就挂。
✗ 内层从 i 开始划
应从 i² 开始;从 2*i 开始仍正确但有重复;从 i*i 开始可减少重复。不能从 i 开始,否则会划掉素数自身。
✗ 欧拉筛 break 写前面
先 break 再标记,会漏掉 i × primes[j] 这个合数。
✗ 数组开小
N = 1e6 + 10:多留 10,防止边界越界。
当堂练习

埃筛里,30 会被划掉几次?

30 = 2 × 3 × 5

想想 i 取哪些素数时,j 会走到 30。
次数是:
A1 次
B2 次
C3 次
D4 次
✓ 选 C:i=2、3、5 时各划一次(都在 √30 内)。
小结

今天你学会了:

埃氏筛
标记数组 从 i² 划 n log log n
欧拉筛
i × primes[j] 整除就 break O(n)
用法
预处理 + 查表 区间筛 0 和 1 先标掉
大卫信奥 · J4 知识点 · 十单元
10

指针与引用

变量住在内存的某个门牌号上;指针记门牌,引用就是同一个人的小名。

& 取地址 * 解引用 数组到指针的转换 引用传参
指针与引用封面
今日路线

四个环节:从「门牌号」到「改到外面的变量」

01
指针是什么
存地址的变量,& 和 * 一进一出。
02
指针与数组
数组表达式常转为首元素指针,a[i] 就是 *(a+i)。
03
改到外面
传地址、传引用,swap 才真的换。
04
常见坑
空指针、野指针、越界。
01
PART 01 · 指针是什么

每个变量,都有一个门牌号

指针变量的值不是数字,而是另一个变量在内存里的地址——顺着地址就能找到它。

存地址 & 取地址 * 解引用
指针 · 定义

int *p = &a; 读作「p 指向 a」

int a = 10; int *p = &a; // p 里存的是 a 的地址 cout << *p; // 输出 10
★
定义时的 int *p 里,* 是「这是指针」的标记;用的时候 *p 才是「取它指向的值」。
aint
10
门牌 0x7ffd…3a
里面装着 10
pint*
0x…3a
门牌 0x…48
里面装着 a 的门牌
指针 · 两个运算符

& 取地址,* 取值(解引用)

&a
取出变量 a 的地址,得到一个指针。

只能对变量用:&a 可以,&(a+1) 不行。
*p
顺着 p 里的地址去找,取出那个变量的值。

写在等号左边就能改它:*p = 20。
⇄
& 和 * 是一对逆运算:& 从变量拿到地址,* 从地址拿回变量。
手推 · *p = 20 发生了什么

改的是 p 指向的那块内存,也就是 a

步骤代码a 的值p 指向
1int a = 10;10—
2int *p = &a;10a
3*p = 20;20a
4cout << a;20a
→
第 3 步没有碰 p 本身,p 还指着 a;改的是「p 指的那块地方」。
这就是「间接访问」:手里只有地址,也能改到原变量。
02
PART 02 · 指针与数组

数组在许多表达式中可转换为首元素指针

数组本身不是指针;转换后可得到 &a[0]。a[i] 等价于 *(a+i)——两种写法,同一块内存。

a == &a[0] a[i] == *(a+i) 指针遍历
数组 · 与首元素指针的联系

a[i] 和 *(a+i) 是同一件事

下标012345678
a[i]538192746
地址aa+1a+2a+3a+4a+5a+6a+7a+8
int a[9] = {5,3,8,1,9,2,7,4,6}; cout << a[2]; // 8 cout << *(a + 2); // 也是 8 int *p=a; // 发生数组到指针转换 cout << sizeof(a) << " " << sizeof(p); // a 不能自增;p 可以在允许的范围内自增
+
a+1 不是「地址加 1 字节」,而是加一个 int(sizeof(int) 字节)——指针加法按类型走。
数组 · 用指针遍历

一个指针走完全程

int a[5] = {1, 2, 3, 4, 5}; int *p = a; // 指向首元素 for (int i = 0; i < 5; i++) { cout << *p << " "; p++; // 挪到下一个 }
输出 1 2 3 4 5
p++ 前进 sizeof(*p) 个字节
因为 p 是 int*,加 1 就是「下一个 int」。
*p++ 与 (*p)++
前者先取值再挪指针;后者把指向的值加一。
03
PART 03 · 改到外面的变量

为什么 swap 写完没反应?

C++ 默认「值传递」:函数拿到的是副本。想改原件,就得把地址或引用递进去。

值传递 地址传递 引用传递
交换 · 两种写法

传值:换了副本;传地址:换了原件

✗ 值传递
void swap(int a, int b) { int t = a; a = b; b = t; } // 调用:swap(x, y); // x、y 一点没变
✓ 传地址
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; } // 调用:swap(&x, &y); // x、y 真的换了
→
形参 a、b 是新开的两个变量,只是初值抄了一份;改它们不影响外面的 x、y。传地址后,函数拿到的是 x、y 的门牌。
引用 · 已绑定对象的别名

不复制原对象,不能重新绑定

// main 内的语句 int a=10; int &r=a; r=20; cout<<a; // 20
!
局部左值引用声明时绑定已有对象。r=b 是给 a 赋值,不是把 r 改绑到 b。
void swapRef(int &x,int &y) { int t=x; x=y; y=t; } // main 内:swapRef(a,b);
!
是否为引用保留存储、如何实现访问,由实现决定;不能保证“不额外占内存”或固定寻址次数。
对照 · 指针与引用

一个是门牌,一个是小名

特性指针引用
本质存地址的变量变量的别名
能否为空可以(nullptr)不可以
能否改指向可以 p = &b不可以
使用方式要 *p 解引用直接用,和变量一样
常见用途动态内存、数组遍历函数参数、避免拷贝
★
比赛里最常见的用法:函数参数写成引用(如 void f(vector<int> &v)),省一次整份拷贝。
04
PART 04 · 应用与常见坑

字符串、越界与野指针

指针给了你直接操作内存的能力,也把所有责任一起交给了你。

字符串结尾 '\0' 空指针 越界
应用 · 字符串

字符串就是一组 char,末尾有个 '\0'

char s[] = "hello"; char *p = s; while (*p != '\0') { cout << *p; p++; }
输出 hello
hello\0
'\0' 的 ASCII 是 0,循环靠它判断结束——没有它就会一直往后读。
指针反例 · 不要执行

先区分错误的类别,再解释未定义行为

类别反例或边界规则
空指针解引用int *p=nullptr; *p=5;nullptr 不能解引用
未初始化指针int *p; *p=5;p 尚无有效指向,不能读写所指对象
悬空指针delete p 后继续 *p对象生命周期已结束,原指针不能再解引用
尾后与越界a+n 合法;*(a+n) 不合法同一数组的尾后位置可用于结束比较
!
这些错误不能推断为必定崩溃或固定的随机值。指针先初始化,只在对象生命周期和有效数组范围内访问。
当堂练习

这段代码输出什么?

int a = 5; int *p = &a; *p = *p + 1; cout << a << " " << *p;
输出是:
A5 5
B6 6
C6 5
D5 6
✓ 选 B:*p 就是 a,加一后 a 变成 6,p 仍指向 a,所以两个都是 6。
小结

今天你学会了:

指针
存地址 & 取地址 · * 解引用 本课平台通常 8 字节
数组
a == &a[0] a[i] == *(a+i) p++ 跳一个元素
传参
值传递是副本 传地址加 & 引用不用 &
大卫信奥 · J4 知识点 · 十一单元
11

文件与异常

让程序从文件读、往文件写;出错的时候,别让整个程序直接崩掉。

freopen fstream try / catch
文件与异常封面
今日路线

四个环节:把 cin/cout 接到文件上

01
重定向
两行 freopen,cin 改读文件。
02
文件流
ifstream / ofstream 精确控制。
03
异常
throw 扔出去,catch 接住。
04
模板与坑
竞赛常用写法,和几个必须知道的坑。
01
PART 01 · 文件读写

把键盘和屏幕,换成两个文件

标准输入 stdin、标准输出 stdout 本来是键盘和屏幕;重定向,就是把它们指到文件上。

freopen ifstream / ofstream 读写四步
文件 · 什么时候需要

数据太多、要留记录,就不能只靠键盘

01
数据量大
10⁵ 个数手敲不现实,题目直接给 .in 文件。
02
要留结果
程序跑完的输出要存下来,方便对比、交作业。
03
批量处理
一次读一整张表(学生成绩、图书清单)。
★
核心概念:stdin / stdout 只是两个「流」。流换成文件,cin、cout 的写法一行都不用改。
重定向 · 两行搞定

freopen:把 stdin / stdout 接到文件

#include <cstdio> #include <iostream> using namespace std; int main() { if(!freopen("test.in","r",stdin)) return 1; if(!freopen("test.out","w",stdout)) return 1; int a,b; if(!(cin>>a>>b)) return 1; cout<<a+b<<'\n'; return 0; }
参数含义
filename相对路径基于进程工作目录,不一定是 cpp 所在目录
"r"读;"w" 写(会清空原内容)
stdin把标准输入指向它
!
遵守题目的 I/O 约定:标准输入输出题移除本地调试重定向;要求文件输入输出时保留正确文件名。
文件流 · 三种对象

ifstream 读、ofstream 写

类型作用典型用法
ifstream只读ifstream fin("in.txt");
ofstream只写ofstream fout("out.txt");
fstream可读可写fstream f("d.txt", ios::in|ios::out);
1
打开
open 或构造时传文件名
2
判断
is_open() 为假就退出
3
读写
和 cin / cout 一样用 >> <<
4
关闭
close(),别让数据留在缓冲
文件流 · main 内的核心片段

打开、读写、检查结果,区分 EOF 与失败

ifstream fin("score.txt"); if(!fin) { cerr<<"打开输入失败"; return 1; } string name; int age; double score; while(fin>>name>>age>>score) cout<<name<<" "<<score<<'\n'; if(!fin.eof()) { cerr<<"输入格式错误"; return 1; } fin.close();
ofstream fout("report.txt"); if(!fout) { cerr<<"打开输出失败"; return 1; } fout<<"张三 95.5\n"; fout.close(); // 在此完成刷新与关闭,便于检查 if(!fout) { cerr<<"写入或关闭失败"; return 1; }
!
默认文件流通过状态位报告失败。局部文件流正常析构会关闭;显式 close 可及时释放资源,并在这里检查刷新/关闭结果。
02
PART 02 · 异常处理

出错的时候,别直接崩

文件打不开、输入不合法——与其让程序默默算错,不如把问题「扔」出来,接住再处理。

throw try / catch e.what()
异常 · 与普通失败区分

失败是否抛出异常,要看接口与配置

情况默认如何报告怎么处理
文件不存在fstream 通常置状态位检查流状态;可主动 throw 或配置 exceptions
std::stoi 输入无法转换抛出相应标准异常按类型 catch 或预先验证
程序主动 throw寻找匹配的 catch沿调用栈传播,未捕获时才终止
整数除零、无效解引用通常为未定义行为先检查条件,不能指望 catch 自动兜底
异常 · 基本语法

try 里扔,catch 里接

int divide(int a, int b) { if (b == 0) throw runtime_error("除数不能为 0"); if (a==INT_MIN && b==-1) throw overflow_error("商超出 int 范围"); return a / b; } int main() { try { cout << divide(10, 0); } catch (const exception &e) { cout << "出错:" << e.what(); } }
throw 一执行
停止当前正常执行,沿调用栈寻找匹配 catch,必要时进行栈展开;这不是普通 return。
catch 按类型匹配
const std::exception& 可接住派生自 std::exception 的异常;catch(...) 才是不区分类型的兜底。
∑
e.what() 返回那句错误信息。
异常 · 分类捕获

越具体的 catch 写前面

try { ... } catch (const invalid_argument &e) { cout << "参数不合法:" << e.what(); } catch (const out_of_range &e) { cout << "下标越界:" << e.what(); } catch (const exception &e) { cout << "其他错误:" << e.what(); }
异常类型什么情况
invalid_argumentstoi("abc")
out_of_rangev.at(100)
bad_allocnew 申请不到内存
runtime_error自己 throw 的通用错误
03
PART 03 · 模板与坑

比赛里怎么用,怎么别踩雷

重定向只占两行;剩下的坑,基本都和「忘了关」「交错了文件」有关。

竞赛模板 交题注意 常见坑
模板 · 比赛常用

两行 freopen + 一句关同步

#include <bits/stdc++.h> using namespace std; int main() { // 只有题目要求或本地调试需要时才重定向 if (!freopen("test.in", "r", stdin)) return 1; if (!freopen("test.out", "w", stdout)) return 1; ios::sync_with_stdio(false); cin.tie(nullptr); // 你的题目逻辑,使用 cin / cout return 0; // 正常结束,让 C++ 输出流刷新 }
sync_with_stdio(false)
关掉 cin 与 scanf 的同步,读得更快。
关了同步就别混用 scanf
混用会读乱顺序,这是经典事故。
!
按题目要求选标准 I/O 或文件 I/O;相对文件路径基于运行工作目录,重定向失败要检查。
文件与异常 · 四个坑

按接口与题面约定处理失败

I/O 约定弄反

标准 I/O 题移除本地调试重定向;文件 I/O 题按指定文件名读写。freopen 失败表示打开失败,不是成功读到空文件。

不检查打开/写入结果

路径基于进程工作目录。输入与输出都需检查状态,写入和关闭后也可能报告失败。

混淆正常析构与异常终止

局部文件流正常析构会关闭;需要及时检查时显式 close。关同步后不提前 fclose(stdout)。

用异常代替边界检查

默认流失败未必 throw;整数除零、越界等也不能依靠 catch 修复。

当堂练习

freopen("test.in", "r", stdin) 做了什么?

三个参数:文件名、打开方式、"重定向到哪个流"。

注意它是重定向,不是「读一行」。
作用是:
A新建 test.in
B把标准输入接到 test.in
C读一行就停
D关闭文件
✓
选 B:之后所有 cin 都从 test.in 读。
小结

今天你学会了:

重定向
freopen 两行 r 读 w 写 遵守题目 I/O 约定
文件流
ifstream / ofstream is_open 先判断 正常析构关闭;及时检查写入
异常
throw / try / catch catch 从具体到通用 e.what()
—

完整程序 · C++17