---
# System prepended metadata

title: 枚舉基礎

---

## 定義
所謂的枚舉，就是把可能的結果一一列出來，比如要找 $1\sim100$ 中可以同時整除 $2$ 與 $3$ 的整數
這時候如果用枚舉去做，就是把 $1\sim100$ 的整數都列出來，然後判斷是否能整除
不過這是最簡單的一種枚舉，只要用 for 迴圈就可以實現了，常用的枚舉會比較複雜
在一般情況下，窮舉(Enumeration)和暴搜(暴力搜尋/Brute Force)都是枚舉的策略之一

相較於一般的遞迴(計算只是為了答案)，枚舉是不確定"當前可能"是否能達成目標

## 優缺點
優點是枚舉的概念很直觀，就是把所有可能的解找出，並判斷哪些正確，如果執行正確一定能找到解
缺點是效率差，如果題目給予的資料量過大，基本上是不可使用枚舉解題，因為速度太慢了

## 剪枝
定義為當判斷出當前解不能導向最終的有效解或最優解時，就停止繼續從這條路徑往後找可能
比如在排某天的會議時間時，如果第一場會議排到太晚的時間，會讓剩下的會議來不及舉行
所以枚舉會議的時間時，只要第一場會議的時間大於某個數字，就可以暫停枚舉
其他場會議也可以套用同樣的原理，因為剪枝理論上可以用在任何可能當中
剪枝細分還會分成兩種，可行性剪枝與最優性剪枝，但這個在實作上不是很重要
總之剪枝是優化的一種，本質上就是減少枚舉的數量，來讓最後花費的時間減少

## 集合(Set)
集合在枚舉中的概念通常是指題目從一個集合做選擇，可能選擇第一個元素，可能同時選擇好幾個
總之最後的解通常會是不重複的，而概念上來說，解都是原本集合的子集合，近似數學上的組合
實作的方式有兩種，遞迴跟二進制，遞迴通常使用 DFS 窮舉，兩者的概念是相似的，只是表達不同

## DFS 窮舉
DFS 的概念是當進入到一個新的可能情況 $C$ 時，窮舉 $C$ 之後的所有可能情況 $C_1 \sim C_n$
當 $C_1$ 之後的所有可能都走完，就會回朔到 $C$，然後繼續往 $C_2$ 走

如果我們把 DFS 的概念畫成圖，就會像是下面這張"有向圖"
![image alt](https://i.imgur.com/s6kxjSq.png)
但同樣的，如果我們直接把全部情況都舉出來，會花費很多時間，所以需要剪枝，之後結合例題實作

## 二進制
用二進制去解決枚舉的問題，就是應用到位元運算的方式，剛好可以用 $1$ 和 $0$ 表達選或不選
透過之前的結論可以知道最後總共有 $2^n$ 種可能，而這些可能剛好會是連續的數字，舉個例子
假設我們要在 $1,2,3$ 中做選擇，那我們只選擇 $1$ 就是二進制中的 $100$，代表選、不選、不選
把所有選擇可能列出會像這樣，$000、001、010、011、100、101、110、111$ 共 $8$ 種
其實應該有人發現了，只需要枚舉 $0~2^n-1$ 的數字就可以找出所有排序
```cpp=
#include<bits/stdc++.h>
using namespace std ;

int main() {
    ios::sync_with_stdio(0), cin.tie(0) ;
    
    int A[5] = {1,2,3,4,5} ;
    int n = 5 ;
    vector<int> ans(n) ;
    for (int i=0;i<(1<<n);i++) { // 枚舉 0 ~ 2^n -1
        int len = 0 ;
        for (int j=0;j<n;j++)
            if ((i >> j) & 1) // 右邊數來第 j 位是 1
                ans[len++] = A[j] ;
        for (int j=0;j<len;j++)
            cout << ans[j] << ' ' ;
        if (len) cout << '\n' ;
    }

    return 0 ;
}
```
這個方法不太能用剪枝，因為這裡是直接通往結果
而遞迴跟樹一樣，當前的結果如果不滿足題目要求，那後方所有可能都可以去除
所以能像是平常修剪樹木的人一樣，一次就把一個分岔後方的所有剪掉
但二進制最多就是一次剪一個葉節點，那效率自然就會比較差

## 例題-ZJ a981. 求和問題
[題目連結](https://zerojudge.tw/ShowProblem?problemid=a981)

題目解析 :
這題問哪幾個數字和剛好為 $M$，所以我們可以列出所有可能，舉個例子
假設數字只有 $1,2,3$，那其中一種可能的總和是 $1+2=3$，這時選了 $1,2$ 但沒選 $3$
所以計算所有可能就是把所有數字選跟不選的可能都考慮進去，簡單來說可以畫成之前那樣的圖
方法就是把所有數字排成一排，用 DFS 去跑過所有數字，每到一個新數字都有兩個選擇，選或不選
這樣最後就可以把所有可能都列出來，也就是圖上最右方那層的節點，這時候再判斷總和大小
不過要注意，這裡的選擇順序要先選數字小的，所以提前將資料排序好才能正確選擇
選擇的問題解決了，剩下就是如何記錄選擇的數字，由於我們剛剛的推論可以得知
最後的可能高達 $2^n$ 個，如果陣列一直複製就會讓空間爆炸
同一個陣列不會產生錯誤的概念，因為 DFS 就是一次走到底，只要在回頭的時候處理陣列就好
```cpp=
void dfs(int idx, int sum, int len, vector<int> &ans) {
    if (idx >= n) { // 已經沒有數字可選擇
        if (sum == m) { // 總和一樣
            for (int i=0;i<len;i++) {
                cout << ans[i] << ' ' ;
            }
            cout << '\n' ;
        }
        return ;
    }

    ans[len] = num[idx] ;
    dfs(idx+1, sum+num[idx], len+1, ans) ; // 選
    dfs(idx+1, sum, len, ans) ;          // 不選
    return ;
}
```
不過很明顯，這裡的時間複雜度太大了，枚舉+輸出的複雜度就是 $O(n\times 2^n)$
所以要用上前面介紹的剪枝優化，其實也很簡單，如果當前總和已經大於 $M$
那之後不論如何選擇都不可能會是 $M$，所以就直接停止枚舉這個可能後續的所有可能
```cpp=
#include<bits/stdc++.h>
using namespace std ;

int n, m, can = 0 ;
int num[31] ;

void dfs(int idx, int sum, int len, vector<int> &ans) {
    if (idx >= n || sum == m) { // 已經沒有選擇/不用選擇
        if (sum == m) { // 總和一樣
            for (int i=0;i<len;i++) {
                cout << ans[i] << ' ' ;
            }
            cout << '\n' ;
            can = 1 ;
        }
        return ;
    }
    if (sum > m) // 已經不可能
        return ;

    ans[len] = num[idx] ;
    dfs(idx+1, sum+num[idx], len+1, ans) ; // 選
    dfs(idx+1, sum, len, ans) ;          // 不選
    return ;
}

int main() {
    ios::sync_with_stdio(0), cin.tie(0) ;

    cin >> n >> m ;

    for (int i=0;i<n;i++)
        cin >> num[i] ;
    sort(num, num+n) ;

    vector<int> ans(n) ;
    dfs(0, 0, 0, ans) ;

    if (!can)
        cout << "-1\n" ;

    return 0 ;
}
```
最後注意處理未輸出的情況就好，這邊是直接用一個 global var 去處理

## 排列(permutation)
在高中或多或少有被排列組合折磨過，當時有學過定義，將元素打亂後所有可能的序列稱為排序
沒有重複元素的排序是比較簡單的，因為不用考慮重複元素順序的問題，直接排就可以
如果有重複元素的排序就需要考慮相同元素互換位置時等於沒換，排序比較困難
不過一開始我們都不需要考慮這麼多，因為這裡不會講到要怎麼實作排序，而是教函式應用
```cpp=
// 假設 v 是個已排序的 vector，a 是已排序的陣列
do {
    // 程式碼片段
} while(next_permutation(a, a+n))
    
do {
    // 程式碼片段
} while(next_permutation(v.begin(), v.end()))
```
這樣整個陣列就可以依照字典序的排列一個個出來，舉個例子，假設矩陣為 $\{1,2,3\}$
$\{1,2,3\} \rightarrow \{1,3,2\} \rightarrow \{2,1,3\} \rightarrow \{2,3,1\} \rightarrow \{3,1,2\} \rightarrow \{3,2,1\}$
## 例題-ZJ d913. 1. 彈珠配置
[題目連結](https://zerojudge.tw/ShowProblem?problemid=d913)
解題思路 :
如果使用枚舉的方式，每次都有新的排列，找到符合條件的第一個答案就可以了
```cpp=
#include<bits/stdc++.h>
using namespace std ;

int main() {
    ios::sync_with_stdio(0), cin.tie(0) ;

    vector<int> cnt(6), ans(6) ; // ans 是要做枚舉的陣列
    vector<vector<int>> num(6, cnt) ;

    for (int i=0;i<6;i++) { // 輸入資料
        ans[i] = i+1 ;
        for (int j=0;j<6;j++)
            cin >> num[i][j] ;
        cin >> cnt[i] ;
    }

    do {
        bool now = true ;
        for (int i=0;i<6;i++) {
            int dis = 0 ; // 與正確答案差幾個
            for (int j=0;j<6;j++)
                if (ans[j] == num[i][j]) // 相同
                    dis++ ;
            if (dis != cnt[i]) { // 不是答案
                now = false ;
                break ;
            }
        }
        if (now) { // 答案
            for (int i=0;i<6;i++)
                cout << ans[i] << ' ' ;
            cout << '\n' ;
            return 0 ;
        }
    } while (next_permutation(ans.begin(), ans.end())) ;

    return 0 ;
}
```
## 例題-質數
題目敘述: 
給定一個整數 $n$，請找出 $n$ 的因數，若 $n$ 的因數只有 $1、n$ 表示 $n$ 為質數
若 $n$ 為質數，請輸出 "prime"，若 $n$ 不是質數，請輸出 "no"，$n \le 10^{14}$
解題思路 :
一般在一秒內通常能運算 $10^7\sim10^8$ 次，所以要保證時間複雜度在這之下
如果這題用暴力枚舉的方式的話，時間複雜度是 $O(n)$，很明顯跑不完
這時候就需要優化枚舉，在數學中學過每個數字的因數都是兩兩一組，比如 $16$
$16$ 可以拆成 $(1,16)、(2,8)、(4,4)$，如果我們只看比較小的那個數字
那範圍甚至可以縮減到 $\sqrt{n}$，這時候時間複雜度就變成 $O(\sqrt{n})$了
```cpp=
#include<bits/stdc++.h>
using namespace std ;
typedef long long LL ;

bool factor(LL n) {
    int sn = (int)sqrt(n) ; // 根號 n
    for (int i=2;i<sn;i++) // 找是否有其他因數
        if (n % i == 0) // 找到非 1、n 的因數
           return false ; // 不是質數
    return true ; // 沒找到其他因數 -> 是質數
}
int main() {
    ios::sync_with_stdio(0), cin.tie(0) ;

    LL n ;
    cin >> n ;

    if (factor(n))
        cout << "prime\n" ;
    else
        cout << "no\n" ;

    return 0 ;
}
```

## 例題-ZJ c435. MAX ! MAX ! MAX !
[題目連結](https://zerojudge.tw/ShowProblem?problemid=c435)
解題思路 :
一般來說會以為需要同時枚舉兩個元素，因為要找 `max(a[i]-a[j])`，$i < j$
但其實如果仔細想想，`a[i]` 其實是位置 $j$ 前面的所有元素中值最大的
所以我們不需要在枚舉 `a[i]`，只要枚舉 `a[j]` 的同時更新 `a[i]` 的可能
```cpp=
#include<bits/stdc++.h>
using namespace std ;

int main() {
    ios::sync_with_stdio(0), cin.tie(0) ;

    int ans = 0, max_n = -100005, now, n ; // max_n 預設小於最大可能答案
    cin >> n ;

    for (int i=0;i<n;i++) { // 輸入同時在枚舉 a[j]
        cin >> now ;
        ans = max(ans, max_n-now) ; // 更新結果
        max_n = max(max_n, now) ; // 更新 a[i]
    }

    cout << ans << '\n' ;
    return 0 ;
}
```

## 例題-ZJ h027. 202001_2 矩陣總和
[題目連結](https://zerojudge.tw/ShowProblem?problemid=h027)
解題思路 :
這題是枚舉二維矩陣 $B$ 內的子矩陣，子矩陣大小是固定的，都跟要比較的矩陣 $A$ 相同
所以這裡的枚舉對象應該可以換成一個簡單一點的，也就是矩陣的左上角座標
只要知道矩陣的左上角座標，矩陣的其他位置就可以透過 for-loop 配合矩陣大小找到
然後就是一些運算，確保 $B$ 子矩陣跟 $A$ 同位置不同元素要在 $r$ 個以下
還有計算所以符合條件的子矩陣總和與 $A$ 的絕對差，這裡可以投機取巧一下
因為只有同位置不同元素才會造成總和差異，所以可以只計算它們就好，最後記得取絕對值
```cpp=
#include<bits/stdc++.h>
using namespace std ;

int s, t, n, m, r ;
int A[11][101], B[11][101] ;

int f(int x, int y) {
    int dis = 0, cnt = 0 ; // 距離、總和差
    for (int i=0;i<s;i++) {
        for (int j=0;j<t;j++) {
            if (B[x+i][y+j] != A[i][j]) {
                cnt += B[x+i][y+j] - A[i][j] ;
                if (++dis > r) // 距離太大了
                    return -1 ; // 這個子矩陣不行
            }
        }
    }
    return abs(cnt) ; // 總和差取絕對值
}

int main() {
    ios::sync_with_stdio(0), cin.tie(0) ;

    cin >> s >> t >> n >> m >> r ;

    for (int i=0;i<s;i++) // 輸入
        for (int j=0;j<t;j++)
            cin >> A[i][j] ;

    for (int i=0;i<n;i++) // 輸入
        for (int j=0;j<m;j++)
            cin >> B[i][j] ;

    int ans = INT_MAX, cnt = 0 ; // ans 最小總和絕對差 cnt 有幾個滿足條件
    for (int i=0;i+s<=n;i++) {
        for (int j=0;j+t<=m;j++) {
            int now = f(i, j) ; 
            if (now != -1) { // 有找到符合條件的
                ans = min(ans, now) ;
                cnt++ ;
            }
        }
    }

    if (cnt == 0) // 沒找到滿足條件的子矩陣 
        cout << "0\n-1\n" ;
    else           // 有找到
        cout << cnt << '\n' << ans << '\n' ;

    return 0 ;
}
```

## 總結
現在介紹的枚舉大部分都是比較簡單的，因為枚舉本身就是比較暴力的解法，只要注意三點
1. 找到適合的枚舉對象，比如之前例題用枚舉矩陣左上角的方式取代枚舉矩陣
2. 讓枚舉的次數減少，通常可以透過固定某些元素、用已知訊息推理元素間關係或剪枝
3. 搭配前置作業讓枚舉過程變順利，之後會講到枚舉搭配其他演算法會讓枚舉更輕鬆