今天我想和大家分享一类让我今天想得脑壳疼的题型,就是给你一个规则,要你枚举出所有符合条件的方案。这里有两个要点,1.不重; 2.不漏。
比如说,给你一个数,要你枚举出所有和为该数的方案,方案中的数皆为正整数且至少有两个。
再比如,给你一个集合,要你枚举出它的所有子集。
没错,我们今天主要探讨一下这两道题的解题过程,和它们给我带来的思考。共勉!
——————————————————————
1.枚举所有方案/求方案数
题目:给定一个正整数𝑛,将它表示成至少两个正整数之和(即𝑛=𝑎1+𝑎2+…+𝑎𝑘,𝑘>1),问有多少种不同的方案。
这里我们分两步思考,1.如何实现枚举方案时不重不漏?也就是说我们采取什么枚举策略?
2.如何使用递归函数实现?
对于第一个思考问题,我们可以先联想一些简单的枚举例子,我们是如何做到不重不漏的呢?比如,我们要枚举1,2,3,4,..,n这n个元素值能组成多少数对,我们大概会这样列举:
1和所有其他配对;去掉1,2和所有其他配对;去掉2,3和所有其他数配对...直到去掉n-1时集合中已经没有两个不同的数无法配对时结束。
我们可以思考,为什么采取这种枚举策略能让我们实现不重不漏呢?首先,所有方案可以分为,含1的数对,不含1的数对。这就已经将所有方案分为了两类。然后,不含1的数对又可以分为不含1且含2的数对,不含1且不含2的数对。这就将类别又细分了。然后,不含1且不含2的数对又可以分为不含1且不含2且含3的数对和不含1且不含2且不含3的数对...依次类推直到无法再细分。
这里其实我们之所以细分下去,是因为不含1的数对或者说不含k的数对不便于我们进行枚举。比如说含1的数对,我们很好枚举所有,只有把1举出来,再从2枚举到n即可。因此我们将不含k的数对再细分出便于枚举的不含k但是含k+1的数对。
回到原题。要实现不重不漏,关键是要实现将所有枚举方案分类为几个不相交且并集为所有方案的便于枚举的类别。
如图,这一块包含所有方案的蛋糕,我们随便怎么切都可以,但是我们要明确我们的目的,就是切了之后,我们要能找到我们切开的每块小蛋糕,如图中的1,2,3,4,5,6,7。
那么所有能和为一个所给数n的方案有什么特点呢?举个例子,观察一下。
n=2 方案1种:1+1;
n=3 方案1种:1+2
n=4 方案4种:1+1+1+1;1+1+2;1+3;2+2;
n=5 方案几种:1+1+1+1+1; 1+1+1+2;1+1+3;1+4;2+3;1+2+2;
我们发现一个方案中不同数字出现的次数不一定相同,但是所有数字一定都在1~n-1这个范围内。那么我们可以将方案划分为含1的方案和不含1的方案吗?答案是正确的,但是这种正确对我们用编程来枚举意义不大。比如,我们现在举出1,那么剩下n-1要怎么枚举?不含1时我们又该怎么枚举呢?
这里我们既然已经知道了所有组成方案的数字范围一定是在1~n-1,那么我们就可以将所有方案分为<1>含1且由不大于1的数组成的方案;<2>含2且由不大于2的数组成的方案;<3>含3且由不大于3的数组成的方案;...<n-1>含n-1且由不大于n-1的数组成的方案。
我们考察一下,这样划分是否满足不重不漏呢?首先,不重,<1>类中可不可能包含<2>类中的相同方案?答案:不可能。因为<1>中所有数字都不大于1,而<2>中至少有一个2(它大于1); 反之,<2>中有没有可能包含<1>类中的相同方案呢?答案:不可能。因为<2>中含2,而<1>中所有数字都不大于1(也就没有2)。
其实上面的方案划分策略也可以换种表述,供我们体会:<1>含1且不含2~n-1;<2>含2且不含3~n-1;<3>含3且不含4~n-1;...<n-1>含n-1。
这样枚举有两个好处:1.能让我们轻松满足至少含两个整数的条件;2.递归间有序可以轻松使用循环实现。
现在,我们思考一下第二步,如何实现递归函数?OK,考虑两个要素:递归式和递归边界。
由上面的分类我们也容易发现,当我们将k分离出来举出时,我们还剩下一个需要构成和为n-k的方案,于是问题规模n减小了。当n-k也满足不大于k时,我们得到1种方案k+(n-k)。另外的方案就是k和问题规模为n-k的原问题方案组合,这就得出了递归式。那么什么时候停止递归呢?答:当问题规模减小到1时,问题规模为1的原问题方案没有,即方案数为0。
现在,我们可以尝试来写一下代码了:
#include <cstdio>
int f(int n,int bound){
if(n <= 1 || bound < 1){
return 0;
}
int ans = 0;
for(int k=1; k <= bound; k++){
if( n-k >= 1 && n-k <= k){
ans ++;
}
ans += f(n-k,k);
}
return ans;
}
int main(){
int n;
scanf("%d",&n);
printf("%d",f(n,n-1));
return 0;
}
2.枚举所有子集
题目:给定一个正整数𝑛,假设序列𝑆=[1,2,3,…,𝑛],求𝑆的所有子集。
输出要求:
每个子集一行,输出所有子集。
输出顺序为:
(1)元素个数少的子集优先输出;
(2)元素个数相等的两个子集𝐴和𝐵,若各自升序后满足前𝑘−1项对应相同,但有𝐴𝑘<𝐵𝑘,那么将子集A优先输出(例如[1,5,9]比[1,5,10]优先输出)。
在输出子集时,子集内部按升序输出,子集中的每个数之间用一个空格隔开,行末不允许有多余的空格;空集用空行表示。不允许出现相同的子集。
首先我们思考一下所有子集一共有几种?没错2^n种。原因很简单,每个元素每可能在或者不在子集中。把原集合看作n个位置的元素排列,每个位置放或不放元素都是一种,于是n个位置就有2^n种放法。
枚举输出时我们只需从前到后依次选择输出当前元素和不输出当前元素即可。再将待输出容器按要求排序再一起输出即可。
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
int n;
vector<vector<int>> result;
vector<int> temp;
bool cmp(vector<int> &a, vector<int> &b){
if(a.size() != b.size()){
return a.size()<b.size();
}else return a < b;
}
void DFS(int idx){
if(idx == n+1){
result.push_back(temp);
return;
}
temp.push_back(idx);
DFS(idx + 1);
temp.pop_back();
DFS(idx + 1);
}
int main(){
scanf("%d",&n);
DFS(1);
sort(result.begin(),result.end(),cmp);
for(int i=0; i < result.size(); i++){
if(i) printf("\n");
for(int j=0; j < result[i].size(); j++){
if(j) printf(" ");
printf("%d",result[i][j]);
}
}
return 0;
}
这里理解并书写代码的关键就在于理解递归函数中主操作代码相对于调用函数自身的前后位置。如该DFS递归函数中先push_back就是存储当前位置的元素。然后调用函数自身DFS向后实现后面位置是否存储。在pop_back,这与push_back相逆,原来存储推入了该元素,现在把它从容器中弹出来,就相当于没存储过该元素。这就对应了我们前面分析的当前位置不放元素。然后再调用自身函数,实现后面位置的选择。这里递归确实不太好理解,我们在函数中调用自身的时候,只需假设我们函数已经实现了即可,然后再假设已经实现的基础要,还需要添加什么操作语句(也就是递归式了)来完成外面这些参数的大函数以及实现递归边界即可。
总结:
1.递归解决枚举的关键是实现对所有枚举方案的便于枚举的分类。
2.递归函数中调用自身时可以假设该函数已经实现既定功能而直接调用,只需集中精力思考主要操作语句相对于递归调用函数的前后位置和确定递归边界即可。
积累:
对于正整数和方案的分类策略:含k且不大于k(1<=k<=n-1)