本章内容
二分
分治
当你把疑惑一劈为二,困境就只剩下一半。
一、二分查找
1. 何谓“二分”?
“二分”本质是一种 对单调现象反复折半 的搜索思想。
单调现象:随变量增大,目标状态只会保持“假→真”或“真→假”一次性跃迁。
每一次比较都把搜索区间削去一半,直到锁定唯一位置或满足精度要求。
2. 必备前提
- 1. 单调性
-
- • 离散序列:数组按非降或非升排序;
- • 判定函数:
check(x)
先假后真(或先真后假),可以理解为00001111
或者11110000
模式,真假有个临界值。
- 2. 可随机访问或可 O(1) 判定
-
- • 访问
mid
的代价必须远小于遍历整区间。
- • 访问
- 3. 边界已知
-
- • 必须能给出最小可能值
l
和最大可能值r
(或r+1
)。
- • 必须能给出最小可能值
3. 常见两大写法对比
写法 | 区间 | 循环条件 | mid 取法 | 更新 |
闭区间 |
|
|
|
/ |
半开区间 |
|
|
或 |
/ |
只要三件套(区间定义、循环条件、更新规则)保持一致,就不会死循环;混写一定出错。
4. 两大写法举例
4.1 半开区间
① 数组已按非降排序,需要
- • 查找元素 k 的下标,或找 最左 ≥ k 的边界(左边界)
模版
① 设 l = 最小下标,r = n // 半开区间 [l, r)
② while (l < r):
③ mid = l + (r-l)/2
④ if a[mid] >= k :
⑤ r = mid // 保留 mid,收缩右端
⑥ else
⑦ l = mid + 1 // 舍弃 mid 及其左侧
⑧ 返回 l
- • 区间语义:始终保持
[l,r)
:存在 k 值,l
,r
指向最左边 k 位置(若存在多个 k ),若不在 k,指向“满足条件”的第一项位置。
举例: 1 2 2 2 4 5 若查询 2 ,则指向第一个 2 的位置,若查询 3 ,则指向第一个 4 的位置。
- • 收敛性:
r-l
每轮至少减半,循环次数 ≤ ⌈log₂(区间长度)⌉。 - • 全小时越界:可以提前判断
k > a[n]
,二分查找后,即使k > a[n]
,指针l
依然会停留在n
。
② 数组已按非降排序,需要
- • 查找元素 k 的下标,或找 最右 ≤ k 的边界(右边界)
模版
① 设 l = 最小下标,r = n // 半开区间 [l, r)
② while (l < r):
③ mid = l + (r-l+1)/2
④ if a[mid] <= k :
⑤ l = mid // 保留 mid,收缩左端
⑥ else
⑦ r = mid - 1 // 舍弃 mid 及其右侧
⑧ 返回 l
- • 区间语义:始终保持
[l,r)
:存在 k 值,l
,r
指向最右边 k 位置(若存在多个 k ),若不在 k,指向“满足条件”的最后项位置。
举例: 1 2 2 2 4 5 若查询 2 ,则指向最后一个 2 的位置,若查询 3 ,则指向最右一个 2 的位置。
- • 收敛性:
r-l
每轮至少减半,循环次数 ≤ ⌈log₂(区间长度)⌉。 - • 全大时越界:可以提前判断
k < a[1]
,二分查找后,即使k < a[1]
,指针l
依然会停留在1
。
4.2 全闭区间
① 数组已按非降排序,需要
- • 查找元素 k 的下标,或找 最左 ≥ k 的边界
图:闭区间二分循环结束时的左右指针位置(查找第一个 4)
模版
① 设 l = 最小下标,r = n,ans = -1
② while (l <= r):
③ mid = l + (r-l)/2
④ if a[mid] >= k :
⑤ ans = mid // 记录答案
⑥ r = mid - 1 // 舍弃 mid,及其右侧
⑦ else
⑧ l = mid + 1 // 舍弃 mid,及其左侧
⑨ 返回 ans
- • 边界情况: 若
k > a[n]
,则l
指向n+1
,r
指向n
,ans = -1
,可提前特判。
若 k < a[1]
,则 l
指向 1
,r
指向 0
, ans = 1
。
② 数组已按非降排序,需要
- • 查找元素 k 的下标,或找 最左 ≤ k 的边界
图:闭区间二分循环结束时的左右指针位置(查找最后一个 4)
模版
① 设 l = 最小下标,r = n,ans = -1
② while (l <= r):
③ mid = l + (r-l+1)/2
④ if a[mid] <= k :
⑤ ans = mid // 记录答案⑨
⑥ l = mid + 1 // 舍弃 mid,及其左侧
⑦ else
⑧ r = mid - 1 // 舍弃 mid,及其右侧
⑨ 返回 ans
- • 边界情况: 若
k < a[1]
,则l
指向1
,r
指向0
,ans = -1
,可提前特判。
若 k > a[n]
,则 l
指向 4
,r
指向 3
, ans = 3
。
5. 离散与连续
- • 离散二分 —— 目标集合为整数下标或枚举值,循环到空区间即停止。
- • 连续二分 —— 目标区间是实数,常用
while(r - l > eps)
,每次取中点判断函数符号或方向。典型:√x、几何最小距离。
6. 复杂度
- • 时间:
O(log₂N)
—— N 为搜索区间长度或上界 − 下界。 - • 空间:迭代
O(1)
;递归多消耗O(log₂N)
栈。
7. 坑总结
- 1. 溢出
mid = (l + r) / 2
可能超出 32-bit;改用l + (r-l)/2
或用 64-bit 变量。 - 2. 死循环
更新规则不严格收缩区间(如把r = mid
写成r = mid-1
却仍用半开区间)。 - 3. 答案不存在
返回下标前需验证l < n && a[l] ≥ k
,否则越界或误判。 - 4. 不满足单调性
例如峰值数组、循环升序等,需要改成“旋转二分”或先判断段。
8. 简述 lower_bound
, upper_bound
8.1 它们是什么?
名称 | 头文件 | 作用 | 单调含义 |
|
| 返回首个 ≥ 的位置 | 保证区间左侧都 < |
|
| 返回首个 > 的位置 | 保证区间左侧都 ≤ |
二者都以折半查找实现,要求传入的范围已经按比较准则排好序;时间复杂度 O(log n)
,在C++中它们返回的是一个 迭代器,返回值为指向结果位置的 随机迭代器。若搜索失败,都会返回“末尾迭代器”——即 last
本身。
8.2 基本原型
template<class It, class T>
It lower_bound(It first, It last, const T& val); // 默认 operator<template<class It, class T, class Cmp>
It lower_bound(It first, It last, const T& val, Cmp cmp); // 自定义比较// upper_bound 同理,只是比较改成 !(val < *it) 内部条件
区间 [first, last)
必排好序且与 cmp
(若提供)一致;否则行为未定义。
8.3. 最常见三种用法
1) 在 vector
中查找
#include <bits/stdc++.h>
using namespace std;int main(){ios::sync_with_stdio(false);vector<int> v = {1, 2, 4, 4, 5, 7};int k = 4;auto it1 = lower_bound(v.begin(), v.end(), k); // -> v[2]auto it2 = upper_bound(v.begin(), v.end(), k); // -> v[4]cout << "首 ≥ 4 的下标: " << it1 - v.begin() << "\n";cout << "首 > 4 的下标: " << it2 - v.begin() << "\n";
}
输出
首 ≥ 4 的下标: 2
首 > 4 的下标: 4
可用 it2 - it1
快速得到 值为 4 的出现次数。
2) 维持有序插入(离散化、优先表)
void ins(vector<int>& v,int x){auto it = lower_bound(v.begin(), v.end(), x); // 找到插入位v.insert(it, x); // 保证仍有序
}
3) 筛选区间 [L, R]
内元素
vector<int> v = {1,3,3,6,8,9,13};
int L = 3, R = 8;
auto l = lower_bound(v.begin(), v.end(), L); // 首 ≥ L
auto r = upper_bound(v.begin(), v.end(), R); // 首 > R
for(auto it=l; it!=r; ++it) cout<<*it<<" "; // 打印 3 3 6 8
8.4 与 set
/ multiset
set::lower_bound
、upper_bound
成员函数与算法版功能完全一致、但更方便:
set<int> st = {2,5,7};
int x = 4;
auto it = st.lower_bound(x); // 指向 5
if(it != st.end()) cout<<*it;
multiset
同理,只是可保留重复值,upper_bound
常用来删除所有等于某值的元素区间。
8.5 自定义比较
例如把一串字典序字符串按长度有序存储,然后查“首个长度 ≥ k”:
vector<string> vs = {"a","ab","abcd","abcdef"};
int k = 4;
auto cmp = [](const string& a,const string& b){ return a.size() < b.size(); };
auto it = lower_bound(vs.begin(), vs.end(), string(k,'?'), cmp);
cout<<*it; // 输出 "abcd"
传入的 val
只需要同类型或可被比较即可;上例用长度为 k
的随意字符串作“哑值”。
8.6 易错点 & 经验
- 1. 未排序就调用 → UB,不会抛异常而是产生随机结果。
- 2. 返回末尾:写循环前务必先判
it != container.end()
。 - 3.
vector<int> a; int idx = lower_bound(a.begin(), a.end(), k) - a.begin();
记得 减去首迭代器 才得下标。 - 4. 若区间很大,尽量传 原生指针 或
iterator
,而非拷贝大对象。 - 5. 与
binary_search
区分:后者仅返回bool
,无法获取位置。
8.7 快速对照
目标 | 调用 | 说明 |
第一个 ≥ k |
| “左边界” |
第一个 > k |
| “右边界” |
值区间 |
|
即范围 |
首个 ≤ k |
| 先 |
二、二分答案
1. 引子:为何“二分答案”在算法竞赛屡试不爽?
在信息学题目中,我们常常遇到这样的描述:
给定一个判定函数 check(x)
,当自变量 足够小时返回 false,当 足够大时返回 true;
求最小的 ,使 check(x)
为 true。
这类“单调可行性”问题贯穿了载重运输、分段切割、最小化最大值、资源调度、连续数值逼近等整整一个大族。与其对每个 穷举测试,我们完全可以借助二分——让待搜索区间一次次折半,把复杂度从线性 降成对数 。OIer 便把这种“在结果区间上二分”的做法称为 “二分答案”,以区别于传统“在有序数组里找元素”的 索引二分。
2. 核心概念与理论根基
2.1 单调现象
若存在某个临界点 ,使得:
那么我们说 check
对定义域 单调不降(也有题目相反,先真后假,本质对称)。此时,只要能在区间 内找到 check(x)
首次变为 true 的位置,即得最优答案 。
2.2 二分公式与循环不变式
最常用的迭代模板(求最小真值)如下:
long long sol(long long L, long long R){ while(L < R){long long M = L + (R-L)/2; // 防溢出if(check(M)) R = M; // M 可行 → 抛弃右半else L = M + 1; // M 不行 → 抛弃左半含 M}return L; // L==R
}
循环不变式:
- • 每轮保持
check(R)=true
,check(L-1)=false
(若定义); - • 区间长度至少减半;故迭代次数 ≤ 。
若要求最大不可行,只需把“可行时收右端”改为“可行时收左端”(左边界改右边界),并使用 M = L + (R-L+1)/2
(向上取整),即可杜绝死循环。
2.3 整数域 vs 连续域
- • 离散整数:循环条件
while(L<R)
,最终L(R)
即整数答案。 - • 实数/浮点:循环
while(R-L > eps)
,或指定次数for(i=0;i<60;i++)
;结束后常取L
或(L+R)/2
作为近似值,误差 ≤eps
。
3. 最小真·最大假——四个黄金场景
3.1 场景 A:最小化最大值(典型题:路由器布点)
题干(缩编自 2024-09 五级真题)
给定 个村庄坐标 (升序)与路由器数量 ,问最小化相邻两路由器的最小距离,使全部安装完成。
单调性分析
- • 若间距上限 可行(能布完个),则任意更大的间距≥也可行吗?
反向:大间距更严格 →可行集合随 扩大呈单调不增。
因此:设check(d)
判断“能否用间距 ≤ d 放满 k 个”;check
先真后假,二分求最大 d 不可行或最小可行皆可。
判定函数
bool chekc(long long d){long long cnt = 1, prev = x[0];for(int i=1;i<n;++i)if(x[i] - prev >= d){++cnt; prev = x[i];}return cnt >= k;
}
二分主体
long long L = 0, R = x[n-1] - x[0]; // 答案区间
while(L < R){long long M = (L+R+1)>>1; // 向上取整if(check(M)) L = M; else R = M - 1;
}
cout << L; // 最大化最小间距
复杂度:check
O(n),外层 O(log R) ⇒ 总 O(n log R)。
3.2 场景 B:最小容量/速度/天数(船运包裹、打印机问题)
船运包裹(LeetCode 1011 同型,五级编程题改编)
- • 输入重量数组 与期限
D
;每天装一次船,容量C
;求最小C
。 - •
check(C)
:模拟装箱,若天数 ≤D
返回 true。
单调:容量大不比小难装 →check
单调不降。
判定与二分(变量 ≤4 字母):
bool check(long long C){long long cur=0, day=1;for(int i=0; i<n; i++){if(w[i] > C) return 0; // 单件超载if(cur + w[i] > C){day++; cur = 0; }cur += w[i];}return day <= D;
}
long long L=*max_element(w, w+n), R = 1e14;
while(L<R){long long M = L+(R-L)/2;if(check(M)) R = M;else L = M + 1;
}
3.3 场景 C:计数型二分——第 小值
例:两排序数组第 k 小(变式)
- • 目标:在整型域内找一个值
x
,使cnt(x)
=A 中 ≤x
+B 中 ≤x
≥ k。
cnt(x)
随x
增大单调不减 ⇒ 二分最小x
使cnt(x)≥k
。
check
用两指针或双 upper_bound
,复杂度 O(log n + log m)。
3.4 场景 D:实数逼近(牛顿冷却、几何距离)
例:求 (连续域)
double L=0, R=max(1.0,S); // S≥0
for(int i = 0; i < 60; i++){double M = (L+R)/2;if(M*M >= S) R = M;else L = M;
}
printf("%.8f\n",R);
- • 迭代 60 次误差 ≤ 量级。
- •
check(x)
直接比较平方 vsS
。
4. 模板总览
// 整数最小可行
long long bs_int(long long L, long long R){while(L < R){ longlong M = L+(R-L)/2;if(check(M)) R = M;else L = M+1;}return L;
}// 整数最大不可行
long long bs_max(long long L, long long R){while(L < R){longlong M = L+(R-L+1)/2; // 上取整if(check(M)) L = M;else R = M - 1;}return L;
}// 浮点最小可行(固定迭代)
double bs_flt(double L,double R){for(int i = 0; i < 100; i++){double M = (L+R)/2;if(check(M)) R = M;else L = M;}return R;
}
5. 边界框定的四大技巧
- 1. 上界取极值:如船运题上界可设所有重量之和,安全但不失单调。
- 2. 下界取必需值:常用
max
(单个元素最大值)或 0。 - 3. 指数放大:若未知上界,可先指数增大
R*=2
直到check(R)
为真。 - 4. 防溢出:整型用 64 位;中点写
L + (R-L)/2
。
6. 常见陷阱 & Debug 步骤
陷阱 | 诊断 | 解决 |
死循环 | 日志发现 | 检 |
越界 | 返回值 = 上界+1 | 再判 |
非单调 | 样例随机打印 | 重新审题;必要时转量化顺序 |
浮点精度 | 输出 NaN 或无限循环 | 固定迭代次数、不要用 |
7. GESP 真题剪影
- • 2023-12 单选 17:给
while(l<r){ mid=l+r>>1; if(chk(mid)) r=mid; else l=mid;}
问为何死循环?→ 少+1
。 - • 2024-03 判断题:一句“在所有
check(x)
单调的情形下,二分答案复杂度一定是 。”答案判“√”。 - • 2024-09 编程大题:木板锯段求最大化最小长度,测试 ,
log(1e9)
≈30,轻松过时限。
8. 思维串联与比对
方法 | 适用 | 优点 | 缺点 |
枚举 | 任意 | 代码直观 | 可能超时 |
DP | 可拆子结构 | 求最优值 | 状态设计复杂、空间大 |
二分答案 | 单调可判 | 、代码短 | 需保证单调; |
如果一题既满足单调又能写 判定,往往二分即“标程”。
9. 进一步拓展
- 1. 三分搜索:当目标函数先增后减(或先减后增)可用三分;连续极值定位。
- 2. 二分答案 + 贪心 / DS:
check
内部再嵌贵贪心、并查集、线段树,例如 最大化最小差值 + Kruskal。 - 3. 双层二分:外层对答案,内层对辅助变量,如“最短总路程下最小速度”。
10. 总结与口诀
- • 单调 + 判定快 + 边界可给 ⇒ 果断二分答案。
- • 先假后真 →
if(ok) R=M; else L=M+1
;先真后假 方向相反。 - • 整数向下取整用
M = L+(R-L)/2
,向上取整加一。 - • 调试时在循环内输出
L,M,R
三值,肉眼观收缩情况。
牢牢记住这套“二分四步——判单调、定边界、写 check、套模板”。
三、分治算法初识
大纲把“将复杂问题拆分—递归—合并”列为五级算法入门核心;历届真题常以归并/快排选空、复杂度判断和递归代码补全呈现。
1. 概念两句话
- 1. Divide:把规模为 n 的任务切成若干更小的同类子任务;
- 2. Conquer & Combine:递归解决子任务后,将结果合并成整体答案。
只要拆分与合并耗时皆可写成递归式,便可用“主定理”在纸上秒得复杂度。
2. 典范一:归并排序 (Merge Sort)
2.1 思路
- • 拆:把区间
[l,r]
⟶ 两半[l,mid]
、[mid+1,r]
- • 治:递归排序左右半段
- • 合:双指针归并成有序整段(线性)
2.2 代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int a[N], tmp[N];void mrg(int l, int r){ // 归并排序 a[l..r]if(l >= r) return;int mid = (l+r)>>1;mrg(l, mid); mrg(mid+1, r); // 递归int i = l, j = mid+1, k = l; // 合并while(i <= mid && j <= r) tmp[k++] = (a[i] < a[j] ? a[i++] : 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];
}
2.3 复杂度
递归式 T(n)=2T(n/2)+Θ(n)
⇒ T(n)=Θ(n log n)
;额外 Θ(n)
临时数组空间。
2.4 逆序对
归并排序可以统计逆序对的数量,a[j] > a[i]
的时候统计逆序对的数量 cnt += mid-i+1
。
3 典范二:快速排序 (Quick Sort)
3.1 思路
- • 拆:随机选 pivot;一次扫描把数组分成
<p
、=p
、>p
三段 - • 治:递归排
<p
与>p
- • 合:拼接即可(无需额外线性操作)
3.2 代码(原地版)
int part(int l, int r){ // Hoare 分区int p = a[l+((r-l)>>1)];while(l <= r){while(a[l] < p) l++;while(a[r] > p) r--;if(l <= r) swap(a[l++], a[r--]);}return l; // 返回右段起点
}
void qks(int l, int r){if(l >= r) return;int m = part(l, r);qks(l, m-1);qks(m, r);
}
3.3 复杂度
- • 平均:
Θ(n log n)
(均匀分割) - • 最坏:
Θ(n²)
(已排好或全相等且选坏 pivot)。
真题往往让你在选择题里判断“平均”还是“最坏”!
4. 真题高频坑
- 1. 缺“合并”循环 —— 出现在 2024-03 单选填空:
tmp[k++]=a[i++]
漏写导致数组错序。 - 2. 递归出口忘写 —— 导致栈溢出(
l>=r
必写)。 - 3. 快排选择 pivot=arr[l] 对已排序列退化为 O(n²)。题干常问“应如何改进”?答:随机 pivot 或“三数取中”。
- 4. 空间复杂度 归并
Θ(n)
,快排原地Θ(1)
。判断题考过。
5. 练习
- 1. 填空:补齐归并排序中的合并指针循环。
- 2. 选择:给四个递归式,选与归并排序相同复杂度的。
- 3. 编程:随机生成 n (≤ 2 × 10⁵) 整数,用快排输出升序(要求变量≤4字母)。
- 4. 判断:快排平均时间是 O(n log n)。(√)