C++ 折半搜索(Meet-in-the-Middle):突破枚举瓶颈的高效算法
在算法设计中,当面对 指数级复杂度 的枚举问题(如子集和、组合优化)时,传统暴力枚举往往因数据规模过大而超时。折半搜索(Meet-in-the-Middle) 作为一种分治思想的延伸算法,通过将问题拆分为两个规模减半的子问题,分别求解后合并结果,将时间复杂度从 O(2n) 降至 O(2n/2),大幅提升了处理大规模数据的能力。本文将从算法原理、实现步骤到实战应用,全面解析折半搜索的核心逻辑与 C++ 实现技巧。
一、折半搜索的核心思想
1.1 问题引入:暴力枚举的瓶颈
考虑一个经典问题:子集和问题(给定一个数组和目标值,判断是否存在子集的和等于目标值)。若数组长度为 n,传统暴力枚举需遍历所有 2n 个子集,当 n=40 时,240 约为 1012,远超计算机处理能力(即使每秒处理 108 次操作,也需数天时间)。
1.2 折半搜索的解决方案
折半搜索的核心是 “分而治之”:
- 拆分问题:将原数组平均分为两部分(左半部分 A,右半部分 B),长度分别为 n/2 和 n−n/2。
- 枚举子集:分别枚举两部分的所有子集和,得到两个子集和集合 sumA 和 sumB(每个集合的大小为 2n/2)。
- 合并结果:将原问题转化为 “在 sumA 中找 x,在 sumB 中找 target−x”,通过排序 + 二分查找高效判断是否存在这样的 x。
时间复杂度对比:
- 暴力枚举:O(2n)(n=40 时不可行)。
- 折半搜索:O(2n/2+2n/2log2n/2)=O(n⋅2n/2)(n=40 时,220≈1e6,可轻松处理)。
1.3 算法流程示意图
以数组 [a1,a2,a3,a4,a5,a6] 为例,折半搜索流程如下:
plaintext
原数组 → 拆分左半部分 [a1,a2,a3] 和右半部分 [a4,a5,a6]
↓ ↓
枚举左半所有子集和 → sumA = {0, a1, a2, a3, a1+a2, a1+a3, a2+a3, a1+a2+a3}
枚举右半所有子集和 → sumB = {0, a4, a5, a6, a4+a5, ..., a4+a5+a6}
↓ ↓
对 sumB 排序(便于二分查找)
↓
遍历 sumA 中每个 x,判断 sumB 中是否存在 target - x(二分查找)
二、折半搜索的基础实现(子集和问题)
2.1 问题描述
给定一个整数数组 nums 和目标值 target,判断是否存在非空子集,其元素和等于 target。
2.2 实现步骤
- 拆分数组:将数组分为左右两部分(长度尽量均等)。
- 枚举子集和:编写函数枚举单部分的所有子集和(递归或迭代实现)。
- 排序与二分:对右半部分的子集和排序,遍历左半部分子集和,通过二分查找判断是否存在匹配值。
2.3 C++ 代码实现
cpp
运行
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;// 枚举数组nums的所有子集和,存入result
void enumSubsetSums(const vector<int>& nums, vector<long long>& result) {int n = nums.size();result.clear();// 迭代枚举所有子集(0 ~ 2^n - 1)for (int mask = 0; mask < (1 << n); ++mask) {long long sum = 0;for (int i = 0; i < n; ++i) {if (mask & (1 << i)) { // 第i位为1,表示选中该元素sum += nums[i];}}result.push_back(sum);}
}// 折半搜索判断是否存在子集和等于target
bool subsetSum(vector<int>& nums, int target) {int n = nums.size();if (n == 0) return false;// 拆分左右两部分vector<int> left(nums.begin(), nums.begin() + n/2);vector<int> right(nums.begin() + n/2, nums.end());// 枚举两部分的所有子集和vector<long long> sumLeft, sumRight;enumSubsetSums(left, sumLeft);enumSubsetSums(right, sumRight);// 对右半部分子集和排序(便于二分查找)sort(sumRight.begin(), sumRight.end());// 遍历左半部分子集和,查找是否存在 target - x 在 sumRight中for (long long x : sumLeft) {long long need = target - x;// 二分查找(注意:需排除空集+空集的情况,即x=0且need=0时需至少一个子集非空)if (binary_search(sumRight.begin(), sumRight.end(), need)) {if (x != 0 || need != 0) { // 避免空集return true;}}}return false;
}int main() {vector<int> nums = {3, 34, 4, 12, 5, 2};int target = 9;cout << (subsetSum(nums, target) ? "存在" : "不存在") << endl; // 存在(4+5=9)return 0;
}
2.4 关键细节
- 数据类型:子集和可能超出
int范围(如数组元素较大或长度接近 40),需用long long避免溢出。 - 空集处理:枚举时会包含空集(和为 0),需避免 “空集 + 空集” 的情况(即目标为 0 时,需确保至少一个子集非空)。
- 拆分方式:数组拆分无需严格均分,如 n=5 可拆分为 2 和 3,核心是让两部分规模尽量接近(最小化 2a+2b,其中 a+b=n)。
三、折半搜索的扩展应用
3.1 应用 1:子集和计数(统计满足条件的子集数)
问题:给定数组和目标值,统计所有和等于目标值的非空子集个数。
实现思路:枚举两部分子集和后,对 sumRight 排序并统计每个和的出现次数,遍历 sumLeft 时累加 sumRight 中 target-x 的出现次数。
cpp
运行
#include <unordered_map>// 统计子集和等于target的非空子集个数
int countSubsetSum(vector<int>& nums, int target) {int n = nums.size();vector<int> left(nums.begin(), nums.begin() + n/2);vector<int> right(nums.begin() + n/2, nums.end());vector<long long> sumLeft, sumRight;enumSubsetSums(left, sumLeft);enumSubsetSums(right, sumRight);// 用哈希表统计sumRight中每个和的出现次数unordered_map<long long, int> cnt;for (long long s : sumRight) {cnt[s]++;}int res = 0;for (long long x : sumLeft) {long long need = target - x;if (cnt.count(need)) {if (x == 0 && need == 0) {res += cnt[need] - 1; // 减去空集+空集的情况} else {res += cnt[need];}}}return res;
}// 测试:nums = {1,2,3}, target=3 → 结果2({3}, {1+2})
3.2 应用 2:K 倍子集和(最接近目标的子集和)
问题:给定数组和目标值,找到和最接近目标的子集和(若有多个,返回最小的那个)。
实现思路:枚举两部分子集和后,对 sumRight 排序,遍历 sumLeft 时,用二分查找找到 sumRight 中最接近 target-x 的值,记录全局最优解。
cpp
运行
long long closestSubsetSum(vector<int>& nums, int target) {int n = nums.size();vector<int> left(nums.begin(), nums.begin() + n/2);vector<int> right(nums.begin() + n/2, nums.end());vector<long long> sumLeft, sumRight;enumSubsetSums(left, sumLeft);enumSubsetSums(right, sumRight);sort(sumRight.begin(), sumRight.end());long long best = LLONG_MAX;for (long long x : sumLeft) {long long need = target - x;// 二分查找sumRight中最接近need的值auto it = lower_bound(sumRight.begin(), sumRight.end(), need);// 检查it和it-1两个位置if (it != sumRight.end()) {long long current = x + *it;if (abs(current - target) < abs(best - target) || (abs(current - target) == abs(best - target) && current < best)) {best = current;}}if (it != sumRight.begin()) {--it;long long current = x + *it;if (abs(current - target) < abs(best - target) || (abs(current - target) == abs(best - target) && current < best)) {best = current;}}}return best;
}// 测试:nums = {4, -1, 2, 1}, target=3 → 结果3(4-1=3 或 2+1=3)
3.3 应用 3:多维折半搜索(如 4 数之和)
问题:给定数组和目标值,判断是否存在 4 个不同元素的和等于目标值(可扩展到 k 数之和)。
实现思路:将 4 数拆分为两部分(前两数之和 + 后两数之和),枚举两部分的所有组合和,再合并判断。
cpp
运行
// 枚举数组中所有两数之和(记录索引避免重复)
void enumTwoSum(const vector<int>& nums, vector<pair<long long, pair<int, int>>>& result) {int n = nums.size();for (int i = 0; i < n; ++i) {for (int j = i+1; j < n; ++j) {result.push_back({(long long)nums[i] + nums[j], {i, j}});}}
}// 4数之和:判断是否存在4个不同元素的和等于target
bool fourSum(vector<int>& nums, int target) {int n = nums.size();if (n < 4) return false;// 拆分前两数和后两数vector<int> left(nums.begin(), nums.begin() + n/2);vector<int> right(nums.begin() + n/2, nums.end());// 枚举两部分的所有两数之和(记录原始索引,避免重复使用同一元素)vector<pair<long long, pair<int, int>>> sumLeft, sumRight;enumTwoSum(left, sumLeft);enumTwoSum(right, sumRight);// 对sumRight按和排序,便于二分查找sort(sumRight.begin(), sumRight.end(), [](auto& a, auto& b) {return a.first < b.first;});// 遍历sumLeft,查找sumRight中是否存在 target - sumLeft[i].first,且索引不重叠for (auto& p : sumLeft) {long long x = p.first;int i = p.second.first, j = p.second.second;long long need = target - x;// 二分查找sumRight中值为need的元素auto low = lower_bound(sumRight.begin(), sumRight.end(), make_pair(need, pair<int, int>(0, 0)),[](auto& a, auto& b) { return a.first < b.first; });auto high = upper_bound(sumRight.begin(), sumRight.end(), make_pair(need, pair<int, int>(n, n)),[](auto& a, auto& b) { return a.first < b.first; });// 检查所有和为need的元素,是否索引不重叠for (auto it = low; it != high; ++it) {int k = it->second.first + n/2; // 右半部分的原始索引(偏移n/2)int l = it->second.second + n/2;if (i != k && i != l && j != k && j != l) { // 4个元素索引互不相同return true;}}}return false;
}
四、折半搜索的优化技巧
4.1 枚举子集和的优化(递归 → 迭代)
前文使用迭代法枚举子集和(通过掩码 mask 遍历所有子集),效率高于递归(避免函数调用开销)。对于长度为 m 的数组,迭代法的时间复杂度为 O(m⋅2m),是枚举子集和的最优方式。
4.2 空间优化(去重子集和)
若数组中存在重复元素,子集和会出现大量重复,可通过 unordered_set 去重,减少后续排序和查找的时间:
cpp
运行
// 枚举子集和并去重
void enumSubsetSumsUnique(const vector<int>& nums, vector<long long>& result) {unordered_set<long long> sumSet;for (int mask = 0; mask < (1 << nums.size()); ++mask) {long long sum = 0;for (int i = 0; i < nums.size(); ++i) {if (mask & (1 << i)) sum += nums[i];}sumSet.insert(sum);}result.assign(sumSet.begin(), sumSet.end());
}
4.3 处理超大数组(分块更多)
当 n=60 时,230 约为 1e9,仍超出内存和时间限制。此时可将数组拆分为 3 部分(每部分 20 个元素),时间复杂度降至 O(3⋅220),但需更复杂的合并逻辑(三层循环 + 二分)。
五、折半搜索的适用场景与局限性
5.1 适用场景
- 子集和相关问题(存在性、计数、最接近目标)。
- k 数之和问题(k≥4 时,折半搜索比排序 + 双指针更高效)。
- 组合优化问题(如选择 k 个元素,使某个指标最优)。
- 数据规模中等偏大(n=30∼60),暴力枚举不可行,但折半后可处理。
5.2 局限性
- 数据规模限制:当 n>60 时,230 约为 1e9,即使拆分为 3 部分,也可能因内存不足或时间过长而失败。
- 空间开销:存储子集和需要 O(2n/2) 的空间,当 n=40 时,220 约为 1e6,可接受;但 n=50 时,225 约为 3e7,需占用大量内存。
- 仅适用于可拆分的问题:问题需能拆分为两个独立的子问题,且子问题的解可合并。
六、总结
折半搜索(Meet-in-the-Middle)是一种针对指数级复杂度问题的高效优化算法,核心思想是 “拆分问题 + 枚举子集 + 合并结果”,将时间复杂度从 O(2n) 降至 O(n⋅2n/2),使处理 (n=40
