## 定義
所謂的枚舉,就是把可能的結果一一列出來,比如要找 $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 的概念畫成圖,就會像是下面這張"有向圖"

但同樣的,如果我們直接把全部情況都舉出來,會花費很多時間,所以需要剪枝,之後結合例題實作
## 二進制
用二進制去解決枚舉的問題,就是應用到位元運算的方式,剛好可以用 $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. 搭配前置作業讓枚舉過程變順利,之後會講到枚舉搭配其他演算法會讓枚舉更輕鬆