算法基礎 —— 枚舉


枚舉

什么是枚舉

枚舉算法是一種經典的暴力算法,是通過遍歷所有候選答案以找到正確的解的問題解決策略;

枚舉的基本框架

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+12=22\\ 3+7+19=29\\ 7+12+19=38\\ 3+12+19=34\\ \]

現在,要求你計算出和為素數共有多少種。

例如上例,只有一種的和為素數:\(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的情況。

這個貪心算法的正確性,以后我會在貪心章節再提起。

枚舉總結

我們常說的枚舉指的是枚舉算法,但是算法體現的思想,更加影響深遠。

有很多算法本質上其實就是枚舉的思想,或者是在枚舉的基礎上通過改進演變出來。

而很多解題策略也將枚舉作為基本的操作出現。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM