哈喽大家好!今天胖鹅来继续给大家讲一讲C++的算法——搜索与回溯。
目录:
一、今日干货?
1.了解子集问题的思想及代码框架✅
2.增量构造法与位向量法✅
3.子集问题在拆分数字的应用✅
二、正文
1.引入
先看个小问题:
给定一个N,表示集合S={1,2,3,4,……,N},输出其所有子集。其中{1,2}和{2,1}被认为是同一个解。
例如当N=3时,子集为{1},{2},{3},{1,2},{2,3},{1,3},{1,2,3}。
首先子集是什么?
如果集合A的任意一个元素都是集合B的元素,那么集合A称为集合B的子集。
我们回到这个问题,很多人第一反应是for循环枚举,但子集问题有这两个关键:
1.不可重复。如{1,2}和{2,1}就要去掉一个。
2.枚举长度不固定。这个子集问题不同于全排列问题,因为全排列问题长度固定为N,但子集问题长度不定。无法for循环枚举。
for循环都不行,那咋办🥵?
我们不着急,一个一个的解决。先解决第一个问题——去重。
把问题稍微变一下:
给定一个N,表示集合S={1,2,3,4,……,N},输出其长度为3的所有子集。其中{1,2}和{2,1}被认为是同一个解。
例如当N=4时,子集为{1,2,3},{2,3,4},{3,4,1},{4,1,2}。
新问题中每个解所包含的元素数量是确定的,解中每个元素各不相同,搜索过程中如果能保证后面的元素比前面的元素大,就可以保证一定不会重复。
所以用for循环写就是:
for(int i=1;i<=n;i++){//枚举第一位
for(int j=i+1;j<=n;j++){//枚举第二位
for(int k=j+1;k<=n;k++){//枚举第三位
cout<<i<<" "<<j<<" "<<k<<endl;//输出
}
}
}
现在问题升级:
给定一个N,表示集合S={1,2,3,4,……,N},输出其长度为M(1≤M≤N)的所有子集。其中{1,2}和{2,1}被认为是同一个解。
例如当N=4,M=3时,子集为{1,2,3},{2,3,4},{3,4,1},{4,1,2}。
这时候问题就转变成了全排列问题。(还没学习全排列的戳这:搜索与回溯算法之全排列问题)
长度固定为M,所以就是看第一个位置放什么,第二个位置放什么,一直放到第M个位置。
分析递归内容:
定义一个叫cur的变量表示当前放置数字的位置,定义一个ans数组存储全排列。
1.递归边界
当cur>m时,说明已经构造好了一个全排列。
2.内容
由于要枚举第cur个位置上的数字,而且保证不与前一个数重复,所以要对ans[cur-1]+1到n进行for循环。
for循环里要存储枚举的数:ans[cur]=i;
随后遍历下一位:solve(cur+1);
实现代码:
const int N=20; int n,m,ans[N]; void solve(int cur){ if(cur>m){//说明已经填完一个排列 for(int i=1;i<=m;i++)cout<<ans[i]<<' '; printf("\n"); return ; } for(int i=ans[cur-1]+1;i<=n;i++){//否则遍历 ans[cur]=i; solve(cur+1); } }
那么刚开始的问题就可以解决了。
2.增量构造法
观察这棵树,我们发现每到一个节点,就构成一个解。这时就可以输出。
这就是增量构造法。
实现代码:
const int N=20;
int n,ans[N];
void solve(int cur){
if(cur>1){//说明已经填完一个排列
for(int i=1;i<cur;i++)cout<<ans[i]<<' ';//输出
printf("\n");
}
for(int i=ans[cur-1]+1;i<=n;i++){//否则遍历ans[cur-1]+1------>n
ans[cur]=i;//存储
solve(cur+1);//下一位
}
}
运行结果:
2.位向量法
我们拓展另一个解法。还是刚刚的问题。
下面是一张表:(我没有骂人的意思)
S(集合) | 1 | 2 | 3 |
---|---|---|---|
B(数组) | 0/1 | 0/1 | 0/1 |
在生成集合S的子集时,对于集合中的任一元素都可选可不选,定义bool类型数组B,B[i]==1表示选择了,B[i]==0表示未选择。
例如当N==3时,B的解空间有:(0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1), (1, 0, 0), (1, 0, 1), (1, 1, 0), (1,1,1)
所以问题就完全被转换成了全排列问题:对0,1进行全排列。
实现代码:
const int N=20;
int n,b[N];
void solve(int cur){
if(cur>n){//说明已经填完一个排列
for(int i=1;i<cur;i++){
if(b[i])cout<<i<<' ';//输出
}
printf("\n");
return ;
}
for(int i=0;i<=1;i++){//否则遍历0->1
b[cur]=i;//存储
solve(cur+1);//下一位
}
}
运行结果:
悄悄问一下:你知道为什么第一行是空白么?
答:第一种情况为0,0,0。
3.拆分自然数
题目:
给定一个自然数N,则任何一个大于1的N,总可以拆分成若干个小于N的正整数之和,例如当N=3时,有两种划分,即3=1+2和3=1+1+1。试求出N的所有拆分。
输入格式
一个整数即N,N≤45。
输出格式
输出每一种划分方案,每种划分方案占一行,最后一行为方案总数。
分析题目,由于拆分长度不固定,且不能重复(不能同时出现3=1+2和3=2+1),所以这是一个典型的子集问题。
由于是正整数,所以每个数的可选范围为[1,n-1]。且单个数可以重复使用。如3=1+1+1。
分析递归:
定义一个叫cur的变量表示当前放置数字的位置,一个cnt的变量记录方案数,定义一个ans数组存储排列。
1.参数
虽然子集问题没有固定长度,但我们知道当和为N时就找到了一个解。
所以除了cur,我们还需要一个参数sum,表示当前的和。
2.递归边界
当sum==n,输出排列并cnt++。
3.内容
由于不能重复,但可以与前一个数相同,所以要对ans[cur-1]到N-1进行for循环。
接下来可以进行剪枝优化。若sum+i<=n,就存储并solve(cur+1,sum+i)。
实现代码:
void solve(int cur,int sum){
if(sum==n){//若和=N
cnt++;
print(cur);//输出
return ;
}
for(int i=ans[cur-1];i<n;i++){//否则遍历
if(sum+i<=n){//剪枝
ans[cur]=i;
solve(cur+1,sum+i);
}
}
}
这时运行发现:
啥也没有?
通过分析代码,ans定义为全局数组,初始值为0。当第一次进入solve是,for循环从ans[0]->n-1,所以第一次尝试为0。那后面就会一直是0。所以sum不可能为N。总结:死循环了。
所以ans[0]初始值应为1。
总体代码:
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<vector>
#include<cstring>
#include<queue>
using namespace std;
const int N=50;
int n,ans[N],cnt;
void print(int cur){
printf("%d=%d",n,ans[1]);
for(int i=2;i<cur;i++){
printf("+%d",ans[i]);
}
printf("\n");
}
void dfs(int cur,int sum){
if(sum==n){
cnt++;
print(cur);
return ;
}
for(int i=ans[cur-1];i<n;i++){
if(sum+i<=n){
ans[cur]=i;
dfs(cur+1,sum+i);
}
}
}
int main()
{
cin>>n;
ans[0]=1;
dfs(1,0);
cout<<cnt;
return 0;
}
小彩蛋:N=45时有89311种拆分方案。可以试试哦!