📖 第 6.4 章:折半搜索(Meet in the Middle)

⏱ 预计阅读时间:40 分钟 | 难度:🟡 中等(USACO Gold 必备)


前置条件

  • DFS 回溯(第 5.2.12 章)
  • 排序与二分查找(第 3.3 章)
  • 位运算(第 2.6 章)

🎯 学习目标

学完本章后,你将能够:

  1. 理解折半搜索的核心思想:将 O(2^N) 降为 O(2^(N/2) × log)
  2. 解决「子集和」「N 个数中选 k 个」类的大规模枚举问题
  3. 将线性搜索空间拆成两半分别处理,再合并答案

6.4.1 问题引入:子集和

原始问题

给定 N 个整数(N ≤ 40),找是否存在一个非空子集,其和恰好等于目标值 target。

朴素暴力: 枚举所有 2^N 个子集,时间 O(2^40) ≈ 10^12,完全不可行。

关键观察: 40 个元素太多,但 20 个元素的 2^20 = 1,048,576 完全可行!


6.4.2 折半搜索的核心思想

将 N 个元素分成两半(各 N/2 个),分别枚举,然后合并。

原数组:[a1, a2, ..., a40]
         ↓ 分成两半
左半边:[a1, ..., a20]  →  枚举所有子集和 → 2^20 个值
右半边:[a21, ..., a40] →  枚举所有子集和 → 2^20 个值

查找:对于左半边的每个子集和 s,
      在右半边查找是否有子集和等于 (target - s)
      用排序 + 二分查找:O(2^(N/2) × log(2^(N/2))) = O(2^(N/2) × N/2)

时间复杂度: O(2^(N/2) × N),N=40 时约 20 × 10^6,完全可行!


6.4.3 完整实现:子集和判断

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    
    int n; long long target;
    cin >> n >> target;
    vector<long long> a(n);
    for (long long& x : a) cin >> x;
    
    int half = n / 2;
    int left_n = half, right_n = n - half;
    
    // 枚举左半边所有子集和
    vector<long long> left_sums;
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_sums.push_back(s);
    }
    sort(left_sums.begin(), left_sums.end());  // 排序以便二分
    
    // 枚举右半边所有子集和,查找配对
    bool found = false;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        // 在左半边查找 target - s
        long long need = target - s;
        if (binary_search(left_sums.begin(), left_sums.end(), need)) {
            found = true;
            break;
        }
    }
    
    cout << (found ? "YES" : "NO") << "\n";
    return 0;
}
// 时间:O(2^(N/2) × N),N=40 时约 4×10^7
// 空间:O(2^(N/2)) 存储左半边子集和

追踪示例(a = [1, 5, 3, 8], target = 9):

左半边:[1, 5]
  子集和:0(空), 1, 5, 6
  排序:[0, 1, 5, 6]

右半边:[3, 8]
  mask=01(只含3):s=3,need=9-3=6,在左半边找 6 → 找到!
  
输出:YES(子集 {5,3+1=不对...实际是 {1,5} 和 {3})
验证:1+5+3 = 9 ✓(左={1,5},右={3})

6.4.4 计数变体:恰好等于目标的子集数

// 统计和恰好为 target 的子集数量
long long count_subsets(vector<long long>& a, long long target) {
    int n = a.size(), half = n / 2;
    int left_n = half, right_n = n - half;
    
    // 枚举左半边
    vector<long long> left_sums;
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_sums.push_back(s);
    }
    sort(left_sums.begin(), left_sums.end());
    
    // 枚举右半边,用 lower_bound/upper_bound 统计配对数量
    long long ans = 0;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        long long need = target - s;
        auto lo = lower_bound(left_sums.begin(), left_sums.end(), need);
        auto hi = upper_bound(left_sums.begin(), left_sums.end(), need);
        ans += hi - lo;  // 等于 need 的个数
    }
    
    // 减去空集(若 target == 0,空集被算了一次)
    if (target == 0) ans--;
    
    return ans;
}

6.4.5 最大子集和变体

问题: 找和不超过 target 的子集中,和最大的那个。

long long max_subset_sum_le_target(vector<long long>& a, long long target) {
    int n = a.size(), half = n / 2;
    int left_n = half, right_n = n - half;
    
    // 左半边所有子集和
    vector<long long> left_sums;
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_sums.push_back(s);
    }
    sort(left_sums.begin(), left_sums.end());
    // 去重(同一和值只需保留一个,降低后续查找复杂度)
    left_sums.erase(unique(left_sums.begin(), left_sums.end()), left_sums.end());
    
    long long ans = 0;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        if (s > target) continue;  // 右半边超限,不用找左半边
        
        // 在左半边找最大的 ≤ (target - s) 的值
        long long need = target - s;
        auto it = upper_bound(left_sums.begin(), left_sums.end(), need);
        if (it != left_sums.begin()) {
            --it;
            ans = max(ans, s + *it);
        }
    }
    return ans;
}

6.4.6 折半搜索 vs 其他方法

方法时间复杂度适用场景
暴力枚举O(2^N)N ≤ 20
折半搜索O(2^(N/2) × N)N ≤ 40
动态规划(背包)O(N × target)target 不大时
二维 DPO(N^2)特殊结构

折半搜索的适用条件:

  1. 问题可以拆成两个独立的「半问题」
  2. 两个半问题的结果可以快速合并(通常用排序+二分)
  3. 总量 N ≤ 40(每半不超过 20)

⚠️ 常见错误

错误原因修复方案
左半边子集和溢出N=40 且元素值大时,子集和超 intlong long
空集未处理mask=0 对应空集,和=0,target=0 时多计若 target=0 且要求非空子集,减 1
分割不均一半 20 一半 20 vs 1 和 39 效率差很多尽量均分:half = n/2
二分边界错误lower_bound vs upper_bound 混用lower_bound 找第一个 ≥,upper_bound 找第一个 >

💪 练习题

🟢 题目 1:子集和存在性

给定 N(≤40)个整数和 target,判断是否存在非空子集和恰好等于 target。

✅ 完整解答

直接使用 6.4.3 节的代码。


🟡 题目 2:子集和计数

给定 N(≤40)个整数和 target,统计和恰好为 target 的非空子集数量(答案可能很大,对 10^9+7 取模)。

✅ 完整解答

使用 6.4.4 节的计数代码,注意模运算(统计时就取模)。

#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;

int main() {
    int n; long long target;
    cin >> n >> target;
    vector<long long> a(n);
    for (long long& x : a) cin >> x;
    
    int half = n / 2, left_n = half, right_n = n - half;
    
    map<long long, long long> left_cnt;  // 用 map 存和→出现次数(便于取模)
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_cnt[s]++;
    }
    
    long long ans = 0;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        long long need = target - s;
        if (left_cnt.count(need))
            ans = (ans + left_cnt[need]) % MOD;
    }
    
    if (target == 0) ans = (ans - 1 + MOD) % MOD;  // 去掉全空集
    cout << ans << "\n";
}

🔴 题目 3:最接近目标的子集和

给定 N(≤40)个正整数和 target,找非空子集中和最大但不超过 target 的那个,输出这个最大和。

✅ 完整解答

直接使用 6.4.5 节的 max_subset_sum_le_target 函数。

追踪(a=[3,5,7,2], target=10):

左半边 [3,5]:子集和 = [0,3,5,8],排序后 [0,3,5,8]

右半边 [7,2]:
  mask=00 (空):s=0,need=10,在左找≤10的最大=8 → 答案候选 0+8=8
  mask=01 (7):s=7,need=3,在左找≤3的最大=3 → 答案候选 7+3=10 ✓
  mask=10 (2):s=2,need=8,在左找≤8的最大=8 → 答案候选 2+8=10 ✓
  mask=11 (9):s=9,need=1,在左找≤1的最大=0 → 答案候选 9+0=9

最大答案:10

💡 章节联系: 折半搜索是 USACO Gold 的独特技巧,每年约出现 1 道。它本质上是「暴力搜索 + 聪明合并」,将指数复杂度减半。与状压 DP(第 6.3 章)都处理 2^N 的搜索空间,但折半搜索不需要 DP 递推关系。