折半搜索(Meet-in-the-middle,也叫折半枚举)适合处理这样一类问题:完整方案太多,枚举不完,但只看一半的选择,规模还可以接受。
它的关键动作是:把完整选择拆成两半,各自枚举;固定一半以后,向另一半查询需要的搭档。
学会“从中间切开”只走了一步。真正决定能不能加速的,是两半保存什么信息,以及能不能快速找到搭档。
本文默认你已经会“每个元素选或不选”的子集枚举,了解排序与二分查找。需要回顾时,可以先看 子集枚举 和 二分查找。
一、 数据范围的暗号:为什么偏偏是三四十?
考虑一个最朴素的选择问题:给出
先别急着记算法名,看看哪些参数允许我们付出代价。
| 可见的条件 | 自然尝试 | 需要估算的代价 |
|---|---|---|
| 直接枚举所有子集 | 约 |
|
| 数值范围小,例如模数 |
用可达余数做背包 DP | 时间约 |
| 直接搜索和按值域 DP 都需要重新考虑 |
这里的
把指数切成两部分,数量就变了:
完整选择有约
当
它是一个强烈信号。接下来仍要检查:选择能否切开,半边状态能否简洁表示,两边能否快速合并。
“三四十”不是硬性门槛。每个变量若有三种选择,直接搜索是
同样,几百万个状态能否接受,要看状态大小、排序和查询成本、时限与语言。不能仅凭状态数承诺固定的毫秒数。
二、 从暴力走到折半:省下的究竟是什么?
1. 一个完整方案,恰好对应一对半边方案
先用一个没有取模的模型:从若干正整数中选一些,让总和不超过
把数组分成互不重叠的左右两半。任意子集
其中
这说明切开不会漏掉答案;做计数时,也不会因为切分本身重复计算。后续只需保证查询过程正确处理每一对合法选择。
2. 两半枚举出来,仍然不能两重循环
设左半的所有子集和组成数组
只切开再两两尝试,复杂度没有下降。
现在固定左半的和
这已经是一个有序数组上的前驱查询。将
折半搜索省掉的,正是原来逐对检查搭档的工作。
3. 一次手算,看到“查询”的感觉
设
| 半边 | 所有子集和(排序后) |
|---|---|
| 左半 |
|
| 右半 |
固定左半以后,右半的任务就很具体了:
| 左半和 |
右半最多能用 |
最佳右半和 | 完整方案的和 |
|---|---|---|---|
| 已超过预算 | 跳过 | — |
这里允许直接跳过
你可以把右半理解成提前整理好的一张表:左半报出“我还剩多少额度”,右半立即回答“我最多能补多少”。“双向奔赴”是一个好记的画面,但这张表怎样回答查询,才是需要真正推导的部分。
三、 学折半搜索,要形成哪几种感觉?
1. 切的是决策,保存的是摘要
枚举时,我们走过的是具体选择;合并时,通常只需要它的某些属性。
| 原问题 | 半边状态需要保存什么 |
|---|---|
| 子集和是否恰好等于 |
子集和 |
| 子集和模 |
子集和的余数 |
| 总重量不超过容量,价值最大 | 总重量与总价值 |
| 恰好选 |
选取个数与总和 |
| 网格路径异或值等于 |
相遇位置与半条路径的异或值 |
这里有一个判断标准:如果两种半边选择保存的状态相同,它们面对任意另一半时,是否真的表现相同?
在只关心子集和的最值题中,相同的和可以合并。若题目还限制选取个数,仅存总和就丢失了信息,必须再保存个数。若题目要输出具体方案,还需要记录来源,例如子集掩码。
2. 半边的最优,未必拼成整体最优
不要把每半边都压成一个“最大值”。以模
右半单独更大的
真正安全的压缩,需要证明被删除的状态不可能带来更好的答案。
3. 瓶颈从“枚举”转到了“合并”
同样是折半,合并方式可能完全不同。判等要找精确补数,容量限制要找前驱,计数要保留重数,多属性最优还要处理状态之间的优劣关系。
因此,遇到一道新题时,最值得写在草稿纸上的一句话是:
固定左半状态后,右半需要回答什么查询?
只要这句话能写得具体,下一步往往就是选哈希表、排序二分、双指针,或者更合适的数据结构。
4. 折半与剪枝,可以配合,但省事的原因不同
剪枝是在搜索过程中证明某些分支不必再走;折半则是重新组织完整选择的配对方式。即使没有多少分支可以剪,折半也可能依靠快速合并获得稳定的规模缩减。
对于正重量的容量问题,两者可以配合:生成半边状态时,超过容量便停止扩展。但在取模最大值问题中,当前原始和超过模数,并不表示后续选择无用,不能套用这样的剪枝。
四、 用 CF 888E 走一遍:从两类取模到一次二分
1. 为什么适合作为入门例题?
codeforces 888E: Maximum Subsequence
题意是从
这道题适合学习折半搜索:每个数只有选或不选,半边只需要保存一个余数,合并只依赖排序和二分。它同时提醒我们:看出可以折半以后,还必须处理取模带来的非单调性。
2. 为什么半边只需保存余数?
设两半原始子集和分别为
因此,只保存余数不会影响任意配对的结果。令左右半边的余数数组分别为
注意:这里的“数组”可能包含重复余数;每个半边的空集都要生成一个
3. 取模只会发生两种情况
由于
| 情况 | 结果 | 固定 |
|---|---|---|
| 找严格小于 |
||
| 在回绕区间内, |
对于第一种情况,将 lower_bound(B.begin(), B.end(), m-u)。它指向第一个大于等于
对于第二种情况,若存在回绕候选,那么
到这里,分别检查两类候选,已经可以得到正确算法。
4. 再观察一步:为什么可以舍弃回绕?
对于任意回绕的配对,有:
最后一步来自
也就是说,每一对回绕方案,都存在一对更好的不回绕方案。 因此求全局最大值时,完全可以舍弃回绕候选,每个
这个结论用到了“允许右半不选任何数”。若题目增加了两半必须非空、必须选固定个数等条件,就必须重新检查替代方案是否合法。
5. 沿用刚才的四个数,看一次取模合并
现在令
| 左半余数 |
右半需满足 | 二分查到的 |
候选答案 |
|---|---|---|---|
最后一行对应选取
当 lower_bound 的前驱。
6. 生成、整理、查询:一份完整实现
DFS 的每一层只决定当前数选不选,到半边末尾时记录余数。若一半有
下面的代码只排序右半。重复余数保留也不影响最值,去重是可选优化。
展开 C++17 实现
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int n;
long long m;
vector<long long> a;
// 枚举下标区间 [pos, end) 的所有选择;sum 始终是模 m 的余数。
void dfs(int pos, int end, long long sum, vector<long long>& result) {
if (pos == end) {
result.push_back(sum);
return;
}
dfs(pos + 1, end, sum, result);
dfs(pos + 1, end, (sum + a[pos]) % m, result);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
a.resize(n);
for (int i = 0; i < n; ++i) {
cin >> a[i];
a[i] %= m;
}
int mid = n / 2;
vector<long long> left, right;
left.reserve(1ULL << mid);
right.reserve(1ULL << (n - mid));
dfs(0, mid, 0, left);
dfs(mid, n, 0, right);
sort(right.begin(), right.end());
long long ans = 0;
for (long long u : left) {
// 查找严格小于 m-u 的最大余数,确保相加后不回绕。
auto it = lower_bound(right.begin(), right.end(), m - u);
// 空集保证 right 含 0,而 m-u >= 1,所以前驱一定存在。
--it;
ans = max(ans, u + *it);
}
cout << ans << '\n';
return 0;
}
代码中二分所得位置一定有前驱:
设
空间为
五、 经典应用范式:合并时究竟查询什么?
下面几种是常见入口。它们的区别主要在查询目标,同一道题也可能同时使用排序、二分和状态压缩,不必把工具理解成彼此排斥的分类。
5.1 范式一:精确补数——判定与计数
若要找
- 只问是否存在:右半使用哈希集合,或排序后二分判定。
- 统计方案数:右半保存每个和出现的次数,查询
count[T-u];也可排序后用equal_range得到出现区间。 - 若两个数组都压成“值、次数”,某对补数的贡献是左右次数之积。
例如左半有两个不同子集的和都是
最值题中,相同属性的状态往往可以只保留一个;计数题中,它们可能代表不同选择。去重必须同时保留出现次数。
哈希查询的
这一范式也适用于异或:若
5.2 范式二:有界最值——排序与二分
若目标是
实现时用 upper_bound(B.begin(), B.end(), T-u) 的前驱;若没有前驱,则当前
CF 888E 化简后的条件是严格小于 lower_bound 的前驱。二分函数应由不等号推出来,不能只记一个函数名。
如果改成“总和最接近
5.3 范式三:单调边界——双指针扫描
仍考虑
int j = static_cast<int>(B.size()) - 1;
long long ans = 0;
for (long long u : A) {
while (j >= 0 && u + B[j] > T) {
--j;
}
ans += j + 1;
}
对当前
这里的
双指针不是多属性问题的专属工具。只要查询边界具有这种单调变化,就可以考虑把多次二分换成一次扫描。
5.4 范式四:多属性最优——重量排序与前缀最大值
一般的 0-1 背包中,每个物品有重量和价值。若物品数只有三十左右、重量和价值范围都很大,可以枚举每半边所有的
固定左半状态
右半按重量排序以后,不能简单取“重量最大但仍合法”的那个状态,因为它的价值未必最大。需要再预处理前缀最大价值,二分找到合法前缀末尾,然后取该前缀的最大价值。
例如右半按重量排序为:
| 重量 | 价值 | 截至此处的最大价值 |
|---|---|---|
剩余容量为
还可以删除被支配的状态:若某状态重量更大或相等、价值却更小或相等,那么任何能容纳它的搭档,也能容纳更轻且价值不低的替代状态。这种删除对“只限制重量、只最大化价值”的模型是安全的;添加选取个数等条件后,需要重新判断,必要时按个数分组处理。
六、 不只是切数组:把搜索过程从中间接起来
有时切开的对象是决策过程。以只许向右或向下的网格路径为例,从起点到终点的总步数固定为
从起点走到这条线,枚举前半条路径;从终点反向走到这条线,枚举后半条路径。合并时必须在同一个格子相遇,所以状态里要保存位置,不能把所有半路径的值混在一起配对。
若两半的异或值
原因是相遇格子在两半中各计了一次,直接异或会抵消,需要再补一次。要达到目标
这又回到了“精确补数”的查询。切分对象变了,核心动作相同。
路径正向约束必须能反向枚举,并且中间状态要足以判断兼容性。一般图上的两段路径若还要求顶点互不重复,只存终点和路径长度就不够;它们可能在其他位置重叠。
折半搜索与双向 BFS 都会让人想到“从两边在中间相遇”。双向 BFS 通常按层寻找最短路;这里的折半枚举是在整理半边选择,再匹配、计数或优化,未必是在求最短路,也未必真的从两个端点出发。
七、 什么时候有效,什么时候还不够?
1. 尽量平衡的是状态量
每个元素都是二选一时,左右元素个数接近,生成的状态数也最接近。所以常取
若各变量的选择数不同,则应该尽量平衡两边选择数的乘积,而不只是元素个数。某一半还要排序或保存大状态时,也可以根据时间、空间限制适当调整切分。
2. 空间是需要付出的代价
当
可以只保存一边,另一边 DFS 到叶子时立即查询,降低存储量。这是实现上的取舍,通常不会改变最坏时间复杂度的数量级。
3. 全局条件不一定阻止折半
“恰好选
真正困难的是:为了判断两半是否兼容,必须保存庞大的细节,或者每次仍要逐个检查另一半。此时“切成两半”本身还不足以给出高效算法。
4. 为什么不继续切成四份?
只看枚举量,四份各自确实更小。但最终需要合并四份。若先把两两组合显式生成,每个组合数组又可能有
两半的便利在于:固定一边以后,另一边经常恰好是一个成熟的查询问题。继续切分是否有收益,需要新的合并方法和复杂度分析。
八、 推荐题目:每道题练一个不同的合并动作
建议先做“只有一个属性”的题,再进入多属性或路径状态。下面按学习目标安排;“送礼物”虽然模型直接,但完整数据规模比前两道更大,不必把它当成最轻松的实现练习。
| 顺序 | 题目 | 这道题主要练什么 |
|---|---|---|
| 入门例题 | [[[p: codeforces-888E | CF 888E:Maximum Subsequence]]] |
| 1 | [[[p: luogu-P4799 | P4799:世界冰球锦标赛]]] |
| 2 | [[[p: luogu-P5691 | P5691:方程的解数]]] |
| 3 | [[[p: luogu-P10484 | P10484:送礼物]]] |
| 4 | [[[p: atcoder-abc032_d | ABC032 D:ナップサック問題]]] |
| 5 | [[[p: codeforces-1006F | CF 1006F:Xor-Paths]]] |
每道题开始前,先用一句话回答“固定左半后,我要向右半查什么”,再动手写 DFS。
8.1 从 888E 到计数:世界冰球锦标赛
该题有至多 upper_bound,也可以使用前面的双指针。P4799 题面
做完后应能解释:为什么两个票价相同的比赛仍然可能对应不同方案,为什么不能把右半子集和直接去重。
8.2 从二选一到多选项:方程的解数
该题要求统计
它能纠正一个常见印象:折半搜索不专属于子集,也不一定由
8.3 从模型直接到规模估算:送礼物
该题求总重量不超过
它适合检验:你是否会在写代码前把枚举、排序、存储的成本分别算清,而不是看到“折半”就默认一定很快。
8.4 从子集和到多属性:ABC032 D
该题保证三类条件至少满足一类:
它很适合回看开头的“暗号”:物品数小、重量范围小、价值范围小,分别允许你在哪一个维度上付出代价?
8.5 从切数组到切路径:Xor-Paths
该题在至多
做到这里,应该能独立写出合并式,并解释为什么两条半路径的异或值不能直接相等或直接异或成
九、 动手前的五个检查
- 完整选择是什么? 先明确暴力究竟在枚举哪些决策,计算它的规模。
- 切开会不会丢条件? 每个完整方案是否唯一对应一对半边方案,兼容条件如何保存?
- 另一半需要回答什么? 把条件化成精确补数、阈值查询或带属性的最优查询。
- 状态能不能压缩? 最值、计数、恢复方案需要的信息不同;去重和删劣解都要有理由。
- 总成本有没有算全? 把生成、排序、查询和内存分别估算,确认合并没有退回两两枚举。
可以先遮住代码,用四个数手算一次两半状态和查询过程。若你能说清某个左半状态向右半“要的是什么”,就已经抓住了折半搜索最值得迁移的部分。
10 相关题解
| 题目 | OJ | 难度 |
|---|---|---|
| Maximum Subsequence | codeforces | 1800 |