折半搜索(meet in the middle)

文章介绍了折半搜索算法在解决观赛方案问题中的应用,通过将比赛分为两部分,降低从O(2^n)的时间复杂度到O(n2^(n/2)),并给出了洛谷P4799的具体实例。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

介绍

折半搜索,又称 meet in the middle \text{meet in the middle} meet in the middle,指将整个搜索过程分为两部分,并对两部分分别进行搜索,最后得到两个答案序列,将这两个答案序列进行合并,即可得到最终的答案。

这样做的目的是降低时间复杂度。举个例子,如果每层搜索都有两种选择,那么时间复杂度是 O ( 2 n ) O(2^n) O(2n)的。如果我们用折半搜索,那时间复杂度就降为 O ( 2 n / 2 + k ) O(2^{n/2}+k) O(2n/2+k),其中 k k k指将两个答案序列合并的时间复杂度。


例题

洛谷P4799 [CEOI2015 Day2] 世界冰球锦标赛

题目大意

n n n场比赛,第 i i i场比赛的门票的价格为 a i a_i ai Bobek \text{Bobek} Bobek m m m元钱,问他有多少种不同的观赛方案。

1 ≤ n ≤ 40 , 1 ≤ m ≤ 1 0 18 , 1 ≤ a i ≤ 1 0 16 1\leq n\leq 40,1\leq m\leq 10^{18},1\leq a_i\leq 10^{16} 1n40,1m1018,1ai1016

题解

我们首先可以想到的是用状压枚举每一种情况,但这样的时间复杂度为 O ( 2 n ) O(2^n) O(2n),会 TLE \text{TLE} TLE

我们考虑用折半搜索解决问题。

先将所有比赛分为两部分,分别求出两个部分中所有可能的观赛方案的花费。那么,我们在前一部分中取方案 a a a,后一部分中取方案 b b b,则只有满足方案 a a a和方案 b b b的花费之和小于等于 m m m,这两种方案才会对答产生贡献。

那么,我们用一个数组 w w w记录前一部分的每种方案的花费,然后将 w w w从小到大排序。对于后一部分的每种方案的花费 t t t,我们在 w w w中二分求所有满足花费小于等于 m − t m-t mt的观赛方案数量,再将其贡献在答案中即可。

求出 w w w并排序的时间复杂度为 O ( n 2 n / 2 ) O(n2^{n/2}) O(n2n/2),求出每个 t t t并二分查找的时间复杂度为 O ( n 2 n / 2 ) O(n2^{n/2}) O(n2n/2),所以总时间复杂度为 O ( n 2 n / 2 ) O(n2^{n/2}) O(n2n/2)

code

#include<bits/stdc++.h>
using namespace std;
int n,w1=0;
long long m,now,ans=0,a[45],w[1<<20];
int main()
{
	scanf("%d%lld",&n,&m);
	for(int i=1;i<=n;i++){
		scanf("%lld",&a[i]);
	}
	for(int s=0;s<1<<(n/2);s++){
		w[++w1]=0;
		for(int i=1;i<=n/2;i++){
			if((s>>i-1)&1) w[w1]+=a[i];
		}
	}
	sort(w+1,w+w1+1);
	for(int s=0;s<1<<(n-n/2);s++){
		now=0;
		for(int i=1;i<=n-n/2;i++){
			if((s>>i-1)&1) now+=a[n/2+i];
		}
		ans+=upper_bound(w+1,w+w1+1,m-now)-w-1;
	}
	printf("%lld",ans);
	return 0;
}
<think>题目要求逐行解释给定的C++代码。该代码解决了一个问题:给定n个整数,计算有多少种不同的取数方案(非空子集),使得子集中数字的和为0。注意,这里采用了折半搜索Meet in the Middle)的方法来优化,因为n最大为20,直接搜索会超时。 代码解释如下: ```cpp #include<bits/stdc++.h> using namespace std; ``` - 包含所有标准库头文件,使用std命名空间。 ```cpp int n,N,a[21],ans[2000001],s,tot; ``` - `n`:整数的个数。 - `N`:折半后的前半部分元素个数,即`n/2`。 - `a[21]`:存储输入的n个整数。 - `ans[2000001]`:数组,索引表示子集的二进制状态(状态压缩),值为1表示该子集和可以为0,否则为0。 - `s`:记录满足条件的方案数。 - `tot`:用于离散化的计数器。 ```cpp vector<int> p[2000001]; ``` - `p`是一个向量数组,用于存储折半搜索中前半部分的和对应的状态。`p[i]`存储所有前半部分和等于某个特定值(离散化后的索引为i)的状态(即哪些数被选,用二进制表示)。 ```cpp map<int,int> b; ``` - `b`是一个映射,用于将前半部分搜索得到的具体和(sum)离散化为一个整数索引,以便在后半部分搜索中快速查找。 ```cpp void dfs1(int x,int sum,int now){ ``` - 前半部分的深度优先搜索(DFS)函数。 - 参数:`x`表示当前处理到第几个数(从1开始),`sum`表示当前前半部分所选数的和(这里计算的是A-B,其中A是前半部分选的正数,B是前半部分选的负数,但实际上在代码中,我们通过加或减来模拟),`now`是一个状态压缩的整数,表示当前选择了哪些数(二进制位为1表示选择)。 ```cpp if(x>N){ if(b[sum]==0) b[sum]=++tot; p[b[sum]].push_back(now); return; } ``` - 当处理完前半部分所有数(即x>N)时,将当前和sum离散化(如果该sum第一次出现,则分配一个新的索引),然后将当前状态(now)加入对应离散化索引的向量中。 - 然后返回。 ```cpp dfs1(x+1,sum+a[x],now|(1<<(x-1))); dfs1(x+1,sum-a[x],now|(1<<(x-1))); dfs1(x+1,sum,now); ``` - 分别递归三种选择:将第x个数加入正数(即加上a[x])、加入负数(即减去a[x])、不选。 - 注意:状态更新为`now|(1<<(x-1))`表示将第x位(从0开始)置1,即标记该数被选择。但是注意,在不选的情况下,状态不更新(保持now不变)。 ```cpp void dfs2(int x,int sum,int now){ ``` - 后半部分的DFS函数,参数含义同dfs1。 ```cpp if(x>n){ int t=b[sum]; if(t!=0) for(int i=0;i<p[t].size();i++) ans[p[t][i]|now]=1; return; } ``` - 当处理完后半部分所有数(x>n)时,查找当前和sum在前半部分离散化后的索引t(即b[sum])。如果存在(t不为0),则遍历前半部分中所有和等于-sum的状态(因为前半部分和是sum,后半部分和是sum,那么整个集合的和就是0?注意:实际上这里前半部分和与后半部分和的关系是:前半部分和+后半部分和=0,所以后半部分和应该是-sum,但代码中dfs2的sum是后半部分的和,因此我们查找前半部分和等于-sum的状态。但是,在dfs2中,我们传入的sum是后半部分的和,所以我们需要前半部分的和等于-sum,这样整个集合的和就是0。 - 注意:在dfs1中,我们记录了前半部分的和(可能是任意值),在dfs2中,对于后半部分的一个和S2,我们需要找前半部分和等于-S2的状态。因此,这里用`b[sum]`得到前半部分中等于当前后半部分和的相反数的离散化索引,然后遍历所有这样的前半部分状态,将前后状态合并(`p[t][i]|now`)标记在ans数组中为1(表示这个合并后的状态所代表的子集和为0)。 ```cpp dfs2(x+1,sum+a[x],now|(1<<(x-1))); dfs2(x+1,sum-a[x],now|(1<<(x-1))); dfs2(x+1,sum,now); ``` - 同样,对后半部分的每个数有三种选择:加入正数、加入负数、不选。 ```cpp int main(){ scanf("%d",&n); N=n/2; for(int i=1;i<=n;i++)scanf("%d",&a[i]); dfs1(1,0,0); dfs2(N+1,0,0); ``` - 主函数:读入n,然后计算前半部分的大小N(n/2)。读入数组a。 - 调用dfs1(1,0,0):从第1个数开始,处理前半部分(1到N),初始和为0,初始状态为0(没有选择任何数)。 - 调用dfs2(N+1,0,0):从第N+1个数开始,处理后半部分(N+1到n),初始和为0,初始状态为0。 ```cpp for(int i=1;i<=(1<<n);i++)s+=ans[i]; printf("%d",s); } ``` - 最后,遍历所有可能的状态(状态从1到(1<<n)),如果ans[i]为1,则计数(s加1)。这里注意,状态0(不选任何数)被忽略了吗?但是题目要求非空子集?然而,状态0(i=0)没有被遍历,因为循环从1开始。所以,我们只计算了至少选择一个数的方案。 - 输出方案数s。 注意:这个代码中,折半搜索的思想是将n个数分为两半,分别搜索所有可能的和,并记录前半部分每个和对应的状态。在后半部分搜索时,对于每个后半部分状态,查找前半部分中是否有和等于当前后半部分和的相反数,从而合并为整个子集和为0。 但是,这里有一个细节:在dfs1中,我们记录的是前半部分的和(可以是任意值)和对应的状态。在dfs2中,我们计算后半部分的和,然后查找前半部分和等于这个和的相反数。这样,合并后的子集和就是0。 另外,注意状态合并:`p[t][i]|now`,将前半部分的状态和后半部分的状态合并成一个完整的状态(二进制位表示整个集合的选择情况)。然后标记ans数组对应位置为1(避免重复计数,因为可能多个组合得到同一个状态,但同一个状态只计一次)。 最后,我们统计所有被标记为1的状态(即和为0的子集)的数量,但注意,这里包括了空集吗?循环从1开始到(1<<n)(即从状态1到状态(1<<n)-1),所以不包含空集(状态0)。因此,输出的是所有非空子集中和为0的方案数。 但是,注意:在dfs1和dfs2中,我们允许不选某个数,所以状态0(前半部分一个数都不选)也会被记录。同样,在dfs2中,后半部分也可能一个都不选。所以,合并后的状态可能是0(即整个集合都不选),但我们的循环从1开始,所以不会计入空集。因此,符合题目要求(非空子集)。 然而,题目要求的是和为0,所以空集(和为0)本不应该计入,所以这样处理正确。 但是,有一个问题:同一个子集可能会被不同的划分方式重复计数吗?不会,因为每个子集的状态是唯一的(由二进制位唯一确定)。所以,我们标记的是状态,每个状态只被标记一次(ans数组是标记数组,不是计数数组)。 因此,代码正确。 §§相关问题§§ 1. 折半搜索Meet in the Middle算法适用于解决哪些类型的问题? 2. 在代码中,为什么使用状态压缩(bitmask)来表示选择的子集? 3. 离散化和映射(map)在折半搜索中起到了什么作用? 4. 如何避免在折半搜索中重复计数相同的子集? 5. 如果问题要求输出所有和为0的子集(而不仅仅是计数),应该如何修改代码?</think>### 代码逐行解释 ```cpp #include<bits/stdc++.h> using namespace std; ``` - **解释**:包含所有标准库头文件,使用 C++ 标准命名空间。 ```cpp int n,N,a[21],ans[2000001],s,tot; ``` - **解释**:声明全局变量: - `n`:整数总数 - `N`:折半分界点(`n/2`) - `a[21]`:存储输入的整数(最大 20 个) - `ans[2000001]`:标记有效子集的数组(状态压缩) - `s`:最终结果(和为 0 的子集数量) - `tot`:离散化和的计数器 ```cpp vector<int> p[2000001]; ``` - **解释**:向量数组 `p`,索引为离散化后的和值,存储对应的状态压缩方案。 ```cpp map<int,int> b; ``` - **解释**:映射 `b`,用于将和值离散化为整数索引(优化查找效率)。 ```cpp void dfs1(int x,int sum,int now){ ``` - **解释**:前半部分 DFS 函数: - `x`:当前处理的数字索引 - `sum`:当前累计和(A 部分值) - `now`:状态压缩(二进制位表示数字选择) ```cpp if(x>N){ if(b[sum]==0) b[sum]=++tot; p[b[sum]].push_back(now); return; } ``` - **解释**:递归终止条件(处理完前半数字): - 若 `sum` 未记录,分配新索引 `tot` - 将当前状态 `now` 存入 `p[b[sum]]` - 返回 ```cpp dfs1(x+1,sum+a[x],now|(1<<(x-1))); dfs1(x+1,sum-a[x],now|(1<<(x-1))); dfs1(x+1,sum,now); ``` - **解释**:三种递归分支: 1. 加 `a[x]`(选为正) 2. 减 `a[x]`(选为负) 3. 跳过当前数字(不选) ```cpp void dfs2(int x,int sum,int now){ ``` - **解释**:后半部分 DFS 函数(参数同 `dfs1`) ```cpp if(x>n){ int t=b[sum]; if(t!=0) for(int i=0;i<p[t].size();i++) ans[p[t][i]|now]=1; return; } ``` - **解释**:递归终止条件(处理完后半数字): - `t = b[sum]` 获取当前和对应的离散化索引 - 若索引有效,遍历所有前半匹配方案 `p[t]` - 合并状态 `p[t][i]|now` 并标记 `ans[]=1` ```cpp dfs2(x+1,sum+a[x],now|(1<<(x-1))); dfs2(x+1,sum-a[x],now|(1<<(x-1))); dfs2(x+1,sum,now); ``` - **解释**:递归分支(同 `dfs1`) ```cpp int main(){ scanf("%d",&n); N=n/2; for(int i=1;i<=n;i++)scanf("%d",&a[i]); dfs1(1,0,0); dfs2(N+1,0,0); ``` - **解释**:主函数逻辑: 1. 读入 `n` 并计算 `N = n/2` 2. 读入整数数组 `a[]` 3. 执行前半 DFS(从第 1 个到第 `N` 个) 4. 执行后半 DFS(从第 `N+1` 个到第 `n` 个) ```cpp for(int i=1;i<=(1<<n);i++)s+=ans[i]; printf("%d",s); } ``` - **解释**:结果统计与输出: - 遍历所有状态(`1<<n` 表示所有子集) - 累加被标记的有效方案数 `s` - 输出最终结果 ### 算法核心思想 该代码使用 **折半搜索Meet-in-the-Middle)** 解决子集和为零问题: 1. **前半搜索**:枚举前 `N` 个数字的所有组合,记录和值 `sum` 对应的状态(离散化存储) 2. **后半搜索**:枚举后 `n-N` 个数字的组合,查找能使总和为零的互补和值(`-sum`) 3. **状态合并**:通过位运算合并前后状态,标记有效方案 4. **复杂度优化**:从 O(3ⁿ) 降为 O(3^(n/2)),适用于 n=20 的规模
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值