枚舉
什么是枚舉
枚舉算法是一種經典的暴力算法,是通過遍歷所有候選答案以找到正確的解的問題解決策略;
枚舉的基本框架
1.給出解空間
建立數學模型,確立候選答案的范圍,從數學的角度說:就是給出可能解的集合
這是最關鍵的一步,確立正確的解空間是應用枚舉算法的前提
2.找到枚舉的具體方法
在確立了正確的解空間后,還要知道怎么枚舉才能找到正確的答案。
對於不同的問題,枚舉的具體方法很可能是不同的。
枚舉的種類
1.循環枚舉
通過數層循環來達到窮舉解空間里的解,找到正確的答案,是最基本的枚舉算法
例:
求小於 N 的最大素數
純暴力:
int n,i,j;
scanf("%d",&n);
for(i=n-1;i>=2;i--)
{
for(j=2;j<=i-1;j++)
if(i%j==0) break;
if(j==i) {printf("%d",i);break;}
}
優化版
int n,i,j;
scanf("%d",&n);
for(i=n-1;i>=2;i--)
{
for(j=2;j*j<=i;j++)
if(i%j==0) break;
if(j*j>i) {printf("%d",i);break;}
}
2.子集枚舉
解決可化歸為集合的子集問題的題目;
原理分析:
當題目中出現的數據體現出子集的性質后,我們將子集中的中出現的元素用\(1\)代替,補集中的元素用\(0\)代替;
舉個栗子:給定一個集合\(A\{1,2,3,4,5\}\),其中子集\(A_1\{1,3,4,5\}\),\(A_2\{1,4,5\}\),\(A_3\{3\}\),\(A_4\{2,3\}\)就可以這樣表示
A中元素 | 1\(*^{(1)}\) | 2 | 3 | 4 | 5 | 二進制\(*^{(2)}\) | 十進制 |
---|---|---|---|---|---|---|---|
在\(A_1\)中的情況 | 1 | 0 | 1 | 1 | 1 | 11101 | 29 |
在\(A_2\)中的情況 | 1 | 0 | 0 | 1 | 1 | 11001 | 25 |
在\(A_3\)中的情況 | 0 | 0 | 1 | 0 | 0 | 00100 | 4 |
在\(A_4\)中的情況 | 0 | 1 | 1 | 0 | 0 | 00110 | 6 |
\(*^{(1)}\)其實集合是有無序性的,但是按順序來“編碼”,不會破壞一般性;
\(*^{(2)}\)這里的“編碼”規則是,二進制第\(n\)位與\(array[n]\)對應,盡管順序相反程序也會“不重復”,“不遺漏”遍歷解答樹,但是會破壞二進制原有的順序。
集合與二進制:
1.並集:
從元素選擇角度,\(A_2\)和\(A_3\)包含的元素合並起來就能得到\(A_1\)。而分析\(A_1\)的二進制值就是\(A_2\)\(和A_3\)的二進制,對應的每一位都按\(Or\)運算計算就得到的——這不就是C++中的按位或運算嗎?即\(A_1=A_2{\cup}A_3\)等價於\(a_1=a_2|a_3\)。
2.交集:
交集就是兩個集合共有的元素組成的。在邏輯上交集上就含有"與"的意思。類比並集,求交集就等價於按位與運算,\(A_1=A_2{\cap}A_3{\iff}a_1=a_2\&a_3\)。
3.包含:
集合\(A_2\)的元素都在\(A_1\)中出現,說明\(A_1\)包含\(A_2\)。而在高中階段我們知A_1道了,若\(A_2{\subset}A_1\),則有\(A_1{\cup}A_2=A_1\)以及\(A_1{\cap}A_2=A_2\),所以判斷\(A_2是否{\subset}A_1\),可以構造表達式\((a_1|a_2==a_1)\&\&(a_1\&a_2==a_2)\),值為真,命題成立。
4.屬於:
屬於是指某個元素是否在集合內,可以看作包含的特殊情況——只需檢查單獨某項元素構成的集合是否是另一個集合的子集,則先用左移位運算構造出只有某一個元素的集合,然后和原集合取交,如果是空集則命題為真,在這個例子里,如果要判斷第三個元素是否屬於\(A_1\),就可以構造表達式\(1<<(3-1)\&a_1\),表達式值為真,則命題為真。
5.補集:
補集是指全集去除某個集合后剩下的元素組成的集合。由上啟發,我們可以使用按位異或運算來表示一個集合對於全集的補集,在這個例子里\(A_2\)的補集\(A_3=A{\oplus}A_2\),即\(a_3=a\)^\(a_2\)。而根據二進制的運算規則也可以這么計算\(a_3=a-a_2\)。
總結:1.從上可以發現二進制和集合的密切關系。2.但是如果要用二進制來模擬集合運算,一定要確定一個全集,在子集間做運算,而全集一般可以從題目中提煉出。
例:
已知 \(n\)個整數 \(x_1,x_2,…,x_n\),以及1個整數\(k(k<n)\)。從\(n\)個整數中任選\(k\)個整數相加,可分別得到一系列的和。例如當 \(n=4,k=3\),4個整數分別為\(3,7,12,19\)時,可得全部的組合與它們的和為:
現在,要求你計算出和為素數共有多少種。
例如上例,只有一種的和為素數:\(3+7+19=29。\)
#include <iostream>
#include <cstdio>
#include <cmath>//pow函數的所屬頭文件
using namespace std;
typedef long long LL;
const int N = 1000001;
int sum2[N],ans2=0;
LL powme(LL sum){
LL res=1,a=2;
while(sum>0){
if(sum&1) res*=a;
a*=a;
sum>>=1;
}
return res;
}//手打的快速冪,這段代碼其實可以直接用pow(2,n)或(1<<n)代替;
bool pd(int sum){
if(sum==1) return 0;
if(sum==2) return 1;
int i,s=sqrt(double(sum));
for(i=2;i<=s;i++){
if(sum%i==0) break;
}
if(i>s) return 1;
else return 0;
}//判斷是否為素數函數
void tobarr (int n,int k){
for(int i=1;i<=powme(n)-1;i++){
int b=i,m=1,ans=0,f=0;
while(b>0){
if(b&1) {
ans+=sum2[m];f++;//轉換進制的同時,計算和,並且統計子集中的數有幾個
b>>=1;m++;
}
if(f==k&&pd(ans)) ans2++;
}
}
int main(){
int n,k;
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++)
scanf("%d",&sum2[i]);
tobarr(n,k);
printf("%d",ans2);
return 0;
}
例2:
排列與組合是常用的數學方法,其中組合就是從\(n\)個元素中抽出\(r\)個元素(不分順序且\(r ≤n\)),我們可以簡單地將\(n\)個元素理解為自然數\(1,2,…,n\)從中任取\(r\)個數。
現要求你輸出所有組合。
例如\(n=5,r=3\),所有組合為:
\(123,124,125,134,135,145,234,235,245,345\)
分析:
這種題目背景我稱之為全組合問題,這一題的難點就是字典序輸出,
比如,從\(0\)枚舉到\(2^5-1\)的二進制,有三個\(1\)的二進制所對應的組合按照出現順序,則是如下:
\(1 2 3,1 2 4,1 3 4,2 3 4,1 2 5,1 3 5,2 3 5,1 4 5,2 4 5,3 4 5\)
並沒有按照字典序。
但將樣例數據和樣例答案的二進制分別列舉出來:
\(00111,01011,01101,01110,10011,10101,10110,11001,11010,11100\)
\(00111,01011,10011,01101,10101,11001,01110,10110,11010,11100\)
就驚喜地發現這兩組數剛好左右對稱相反。而要將上面的數變為下面的數可以分兩步進行:
1.將二進制從小到大枚舉變為從大到小枚舉。
2.將每個二進制都左右對稱翻轉。
//翻譯成代碼則如下
for(int S=(1<<n)-1;S>=0;S--)//二進制從大到小枚舉
{
int cnt = 0;
for(int i=n-1;i>=0;i--)//順序點1
if(S & (1<<i))
a[cnt++]=n-i;//順序點2
if(cnt==r)
{
for(int i=0;i<r;i++)//順序點3
printf("%3d",a[i]);
puts("");
}
}
*而這到題的程序實現也很有趣,根據順序點的不同組合會出現\(6\)種實現。
時間復雜度 \(O(2^n)\),很大,已經不屬於多項式復雜度了,\(1\)秒的\(timelimit\),\(n\)的范圍大概 \(20 - 30\)。
3.排列枚舉
在這里只介紹STL
的簡便方法,使用algorithm
標准庫中的內建函數 next_permutation(start,end)
,它可以生成在\([start,end)\)內存的數組中產生嚴格的下一個字典序排列,並返回true
,如果沒有下一個排列,就返回 flase
。
例
題目描述:
將 \(1, 2,\ldots ,9\) 共 \(9\) 個數分成三組,分別組成三個三位數,且使這三個三位數的比例是 \(A:B:C\),試求出所有滿足條件的三個三位數,若無解,輸出 No!!!。
分析:循環next_permutation(start,end)
,直至生成 \(1-9\) 的全排列,每次構造數的時候,將排列切割成三段就行。
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long LL;
int a[10];
int main(){
for(int i=1;i<=9;i++)
a[i] = i;
LL A,B,C,x,y,z,cnt=0;
scanf("%lld%lld%lld",&A,&B,&C);
do{
x = a[1]*100 + a[2]*10 + a[3];
y = a[4]*100 + a[5]*10 + a[6];
z = a[7]*100 + a[8]*10 + a[9];
if (x*B == y*A && y*C == z*B) printf("%lld %lld %lld\n",x,y,z),cnt++;
}while (next_permutation(a+1,a+10));
if(!cnt) puts("No!!!");
return 0;
}
時間復雜度:是\(O(n!)\),比\(O(2^n)\)更大,在\(1\)秒的\(timelimit\)下,n的范圍大概是\(11\)以下。
枚舉的優化
例1:
一個數組中的數互不相同,求其中和為\(0\)的有序數對的個數
1.純暴力:
int n,ans=0;
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&sum[i]);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(sum[i]+sum[j]==0 && i!=j) ans++;
printf("%d",ans);
2.優化一:
在算法一中中用\(for(i)\)和\(for(j)\)枚舉了數對,如果數據中有\((a_i,a_j)\)符合答案,那么\((a_j,a_i)\)也是答案,總答案個數就是\((a_i,a_j)\)個數的兩倍,所以在枚舉的過程中只要確定\((a_i,a_j)\)的個數,乘2就行了,
int i,j,n,ans=0;
scanf("%d",&n);
for(i=1;i<=n;i++)
scanf("%d",&sum[i]);
for(i=1;i<=n;i++)
for(j=i+1;j<=n;j++)//j>i,確保(sum[i],sum[j])是正序的
if(sum[i]+sum[j]==0)
ans++;
printf("%d",ans*2);
3.優化二
再進一步挖解題目內部的條件,可以繼續優化:
1.題目中說互不相同的數相加為零,那么只有可能為互為相反數了。
2.根據1的推斷,進一步的就是想到用桶來標記:
先是想到如果sum
出現,那么就將a[-sum]
標記上。當-sum
出現時,判定a[-sum]
,發現-sum
有配對,那么計數器就可以加1了。
但是C++
中數組不能有負數下標,那么就將桶的大小擴大為a[MAXN*2]
(MAXN
為數的絕對值上界),那么原來的0
就映射為MAXN
。
程序實現就如下:
bool a[MAXN*2]//乘2是為了避免負數下標
memset(a,0,sizeof(a));
for(int i=1;i<=n;i++)
{
if(a[MAXN+sum[i]]) ans++;
a[MAXN-sum[i]]=1;
}
方法的核心原理就是利用問題的對稱性,使用標記的方法,減少了不必要的枚舉。
例2:
給定\(N\)個整數的序列,\(\{A_1,A_2,A_3,A_4,···\}\),求函數\(Max\{f(i,j)\}=Max(0,\sum_{k=i}^ja_k)\)
1.簡單暴力法:
int i,j,k,MAXsum=-9999999,n;
scanf("%d",&n);
for(i=1;i<=n;i++)
scanf("%d",&a[i]);
for(i=1;i<=n;++i)
for(j=i;j<=n;++j){
int Thissum=0;
for(k=i;k<=j;k++)
Thissum+=a[k];
if(MAXsum<Thissum) MAXsum=Thissum;
}
printf("%d",MAXsum);
2.優化一:
在這里,我們可以發現原算法的第二重循環的下面,用了一重循環計算\(f(i,j)=\sum_{k=i}^ja_k\),但其實計算序列是同一個的,那么當\(i\)都是一樣的話,\(f(i,j)=f(i,j-1)+a_j,f(i,j-1)\)的部分就不用重復計算了,直接用上一層循環的結果加上\(a_j\)就行,可以自己舉個栗子看看。
int i,j,k,Thissum=0,MAXsum=-9999999,n;
scanf("%d",&n);
for(i=1;i<=n;i++)
scanf("%d",&a[i]);
for(i=1;i<=n;++i){
Thissum=0;
for(j=i;j<=n;++j){
Thissum+=a[j];
if(MAXsum<Thissum) MAXsum=Thissum;
}
}
printf("%d",MAXsum);
優化二
int i,j,MAXsum=-9999999,n;
scanf("%d",&n);
for(i=1;i<=n;i++)
{scanf("%d",&a[i]);per[i]=per[i-1]+a[i];}
for(i=1;i<=n;++i)
for(j=i;j<=n;++j){
if(MAXsum<per[j]-per[i-1]) MAXsum=per[j]-per[i-1];
}
printf("%d",MAXsum);
優化1的方法是將重復的環節繼續利用,而優化2是一維前綴和,就是預處理,將一部分原來枚舉循環中重復的操作提到循環外面來。兩者都是以減少枚舉量,達到優化效果。
3.其實更優秀的算法的時間復雜度\(O(nlogn)\)算法\(3\)的核心思想是分而治之,即分治法,在分治法章節會詳細講解這個例子。
4.最優算法比算法3更好理解,但證明就比較繁瑣。算法叫做在線處理,核心是貪心算法。時間復雜度甚至達到了線性,\(O(n)\)。
int max=0,ans=0;
for (int i=1;i<=n;i++)
{
ans+=a[i];//累加器
if(max<ans) max=ans; //更新答案
else if (ans<0)//如果當前子列和小於0,這一段,只會使后面的答案更小
ans=0;//所以就丟棄
}
關於上面代碼的實現,一定要寫成if(max<ans)else if(ans<0)
的形式,不能寫成if(max<ans) if(ans<0)
或者if(ans<0) if(max<ans)
,原因是未考慮最大子列和小於0的情況。
這個貪心算法的正確性,以后我會在貪心章節再提起。
枚舉總結
我們常說的枚舉指的是枚舉算法,但是算法體現的思想,更加影響深遠。
有很多算法本質上其實就是枚舉的思想,或者是在枚舉的基礎上通過改進演變出來。
而很多解題策略也將枚舉作為基本的操作出現。