Chapter II. 新手上路
基礎演算法
枚舉
作者WiwiHo

在解決一個問題的時候,常常最直接的作法就是「把所有解決方案都列出來看看」,像是決定午餐要吃哪家餐廳時,把附近所有的餐廳列出來,並且找到這些餐廳中現在最想吃的,這個「一一列出來」的作法就稱為枚舉。

「列出所有可能性」在可能性很多的時候,人不太可能做到,雖然電腦的計算能力比人類好上許多,但要枚舉的東西可能會長得奇形怪狀,或者亂枚舉之下仍然有過多可能性,電腦也不能快速解決,因此如何正確且快速的枚舉我們想要的東西是一個問題。

枚舉各式物件


最基本的枚舉,就是枚舉一些簡易的物件,像是題目輸入的東西們、某個範圍裡的整數之類的,只要一個簡單的迴圈就能舉出所有東西。不過枚舉的對象也可以很複雜,接下來我們會介紹兩種常見的枚舉對象:排列與集合。

排列

一個序列的排列(permutation)指的就是把這個序列中的元素亂換順序後,能得到的所有可能的序列,像是 $[1,2,2,4]$ 的排列就有
\[
\begin{array}{ccc}
[1,2,2,4] &
[1,2,4,2] &
[1,4,2,2] \\
[2,1,2,4] &
[2,1,4,2] &
[2,2,1,4] \\
[2,2,4,1] &
[2,4,1,2] &
[2,4,2,1] \\
[4,1,2,2] &
[4,2,1,2] &
[4,2,2,1]
\end{array}
\]

要枚舉所有的排列很簡單,<algorithm> 裡的 next_permutation() 都幫我們做好了:

int a[n] = { /* ... */ };
sort(a, a + n);
do {
    // do something...
}
while(next_permutation(a, a + n));

這樣一來每次 do 裡面的區塊執行的時候,a 都會是不同的 permutation,而且每個 permutation 都會被枚舉過一次。

next_permutation(l, r) 代表的是把 $[l,r)$ 範圍裡的部分,變成「下一個排列」,如果這個排列是「最後一個排列」的話,就會變成「第一個排列」並且回傳 false,反之如果成功變成下一個排列,就會回傳 true。也就是這個數列的所有排列們被排了一個順序,next_permutation() 會幫我們拿到傳進去的排列的下一個(或變回第一個),這個順序是字典序(lexicographical order),像上面列出的 $[1,2,2,4]$ 的排列,就是按照字典序順序列出的。

Definition 1
字典序比較

兩個數列 $a_1,a_2,\dots,a_n$ 和 $b_1,b_2,\dots,b_m$ 比較字典序的方法是,找到第一個滿足 $a_i \neq b_i$ 的位置 $i$,看看 $a_i$ 和 $b_i$ 哪個比較小,$a_1$ 比較小的話就是數列 $a$ 字典序比較小、反之則是 $b$ 的字典序比較小。如果沒有 $a_i \neq b_i$ 的位置,就是長度比較短的數列比較小。兩個長得一模一樣的話,字典序相等。

以下幾個例子,在上面的都比較小:

  • $
    \begin{array}{l}
    3\ 1\ {\color{red}4}\ 5\\
    3\ 1\ {\color{red}5}
    \end{array}
    $
  • $
    \begin{array}{l}
    \\
    2\ 7\ 1\\
    2\ 7\ 1\ 8\ 2
    \end{array}
    $

舉例來說,$[2,1,2,4]$ 傳進 next_permutation() 之後,就會變成 $[2,1,4,2]$ 並且回傳 true、傳進 $[4,2,2,1]$ 會變成 $[1,2,2,4]$ 並且回傳 false,因為它沒有下一個排列了。可以發現到第一個排列肯定是從小到大排序的那一個,這也是為什麼上面的程式碼一開始要先將 a 排序,這樣子才會從第一個排列開始枚舉。(所以如果 a 一開始就排序好,就不需要再排序了。)

一次 next_permutation() 的最差時間複雜度是 $O(N)$,聽起來如果枚舉所有的排列一次,複雜度最差會到 $O(N \times N!)$($1,2,\dots,N$ 的排列有 $N!$ 個),不過總複雜度其實會神奇地是 $O(N!)$。(只是指單純跑過上面那個迴圈,當然你在迴圈裡有做別的事就另當別論了。)

例題
彈珠配置

有一個隱藏的長度為 6 的排列 $p$,另外告訴你 6 個長度為 6 的排列以及它們各自和 $p$ 有幾個位置一樣,輸出字典序最小的可能的 $p$。

既然題目要的是「字典序最小的可能的 $p$」,那麼我們可以從最小字典序的排列(也就是 $[1,2,3,4,5,6]$)開始,按照字典序枚舉所有長度為 6 的排列,枚舉一個排列時檢查它可不可以是 $p$,只要找到一個可以當作 $p$ 的排列就輸出答案。

#include <bits/stdc++.h>
using namespace std;

int a[6][6];
int cnt[6];

int main() {
    
    for (int i = 0; i < 6; i++) {
        for (int j = 0; j < 6; j++) cin >> a[i][j];
        cin >> cnt[i];
    }

    int p[6] = {1, 2, 3, 4, 5, 6};

    do {
        bool ok = true;
        for (int i = 0; i < 6; i++) {
            int tmp = 0;
            for (int j = 0; j < 6; j++) {
                if(p[j] == a[i][j]) tmp++;
            }
            if (tmp != cnt[i]) {
                ok = false;
                break;
            }
        }
        if (ok) {
            for (int i = 0; i < 6; i++) cout << p[i] << " \n"[i == 5];
            break;
        }
    }
    while (next_permutation(p, p + 6));

}

集合

集合是另一個經常需要枚舉的東西。具體來說,通常是要枚舉像是 $\{0,1,\dots,N-1\}$ 這種集合的一個子集,也就是所有挑一些不重複東西出來的方法。

在處理集合相關的東西的時候,我們通常會使用一個非負整數來表示一個集合,這個數在二進位下的第 $i$ 個位數是 $1$ 就代表集合中包含 $i$。例如 $163_{(10)}=10100011_{(2)}$ 就代表 $\{0,1,5,7\}$ 這個集合。

在知道要用一個數字來表示集合之後,枚舉集合的方法就呼之欲出了!要枚舉 $\{0,1,\dots,N-1\}$ 的一個子集合只需要枚舉 $0$ 到 $2^{N-1}$ 的所有整數,像是:

for (int i = 0; i < (1 << n); i++) {
    for (int j = 0; j < n; j++) {
        if ((1 << j) & i) cout << j << " "
    }
    cout << "\n";
}

(1 << j) & i 可以檢查 $i$ 的第 $j$ 個 bit 是不是 1,1 << j 就是 $2^j$,也就是二進位下只有第 $j$ 位是 1 的數字。

(1 << j) & i 其實可以不用加括號,位元運算子的運算優先順序比較奇怪,像是 i & 1 << j 的意思其實是 i & (1 << j)3 << 1 + 2 的意思其實是 3 << (1 + 2) 等等,如果不太確定的話就要記得加上括號。

聰明的枚舉


我們學會怎麼枚舉奇怪的東西了,但也不能總是暴力地把所有東西都枚舉出來,而是要聰明地只枚舉其中一些東西。在枚舉的同時,也可以利用一些聰明的方式來加快。

例題
湊平方數
Source:自編題

給一個數 $N$,求所有的 $(a,b,c)$ 滿足:

  • $a,b,c$ 都是整數
  • $1 \leq a,b,c \leq N$
  • $a+b=c^2$
條件限制
  • $N \leq 10^4$

枚舉所有 $1$ 到 $N$ 之內的三個整數 $a,b,c$?顯然是有更好的作法,像是只要知道 $a,b$,就能知道 $c$ 一定只能是 $\sqrt{a+b}$ 了,然而 $\sqrt{a+b}$ 不見得會是整數,這樣我們不僅會浪費時間在不可能是答案的東西,這個浪費的時間還很多,$(a,b)$ 的數量可是多達 $O(N^2)$ 種。

但如果是枚舉 $a,c$ 的話,我們就會知道 $b=c^2-a$,這一定是個整數!所以這樣做的話,我們就不會浪費時間在枚舉不重要的東西了。$c$ 的最大值是 $\sqrt{N}$,所以答案的數量以及我們花的時間就只有 $O(N \sqrt{N})$ 而已。

例題
Maximum Subarray Sum
Source:CSES 1643

有一個數列 $x_1,x_2,\dots,x_n$,求所有非空區間之中,最大的總和是多少。

條件限制
  • $n \leq 2 \times 10^5$
  • $-10^9 \leq x_i \leq 10^9$

既然這題問的是「最大總和的區間」,最直覺的想法就是直接枚舉全部的區間 $[l,r]$,如果先枚舉所有的左右界,然後再暴力掃過一次整個區間計算總和,把所有總和取 max,這樣總時間複雜度是 $O(n^3)$,實在是太久了。

要把時間降到 $O(n^2)$ 的方法有很多,像是固定左界的時候,可以從左到右枚舉右界,一邊維護現在枚舉的到區間的總和,把右界往右移一格時,把總和多加上加進來的那個數字就好,這樣就不需要對每一個區間的重算一次總和了。另一種方法是利用前綴和:

\[ \begin{array}{cl}
& \phantom{(}x_1+x_2+\dots+x_{l-1}+x_l+x_{l+1}+\dots+x_r \\
-& (x_1+x_2+\dots+x_{l-1}) \\ \hline
=& \phantom{(x_1+x_2+\dots+x_{l-1})+}x_l + x_{l+1} + \dots + x_r
\end{array}\]

所以我們只要先算出所有的 $s_i=x_1+\dots+x_i$($s_0$ 就是什麼都沒加,所以 $s_0=0$),就可以在知道左右界 $l,r$ 的時候,馬上得出來 $[l,r]$ 這個區間的總和就是 $s_r-s_{l-1}$。

如果我們希望複雜度是 $O(n^2)$,計算 $s_i$ 當然可以對每個 $i$ 都暴力計算 $x_1+\dots+x_i$,不過也有一個聰明一點的作法:$s_i=x_1+\dots+x_{i-1}+x_i$ 其實就是 $s_i=s_{i-1}+x_i$,這樣就只要枚舉 $i=1$ 到 $n$,依序計算 $s_i$ 就可以在 $O(n)$ 的時間算出來了。

不過 $O(n^2)$ 還是太久了,要降到更低的時間複雜度,我們就不可能枚舉全部的區間。我們剛剛枚舉了兩個東西:$l$ 和 $r$,那如果我們只枚舉其中一個呢?假設我們選擇枚舉 $r$,沿用前綴和的想法,在固定 $r$ 的時候,$s_r$ 就是已知的,我們的目標是找到最大的 $s_r-s_{l-1}$,既然 $s_r$ 是固定的,那麼我們的目標其實就是找一個最小的 $s_{l-1}$,並且要滿足 $l - 1 < r$。

發現了嗎?只要我們是從小到大枚舉 $r$,我們在處理一個 $r$ 之前,就已經看過所有 $<r$ 的位置了,所以我們只要記錄下之前看過的最小的 $s_i$,直接拿它來當 $s_{l-1}$ 就可以了。甚至我們還不需要把整個前綴和存下來,只要一邊計算前綴和,一邊記下看過的最小前綴和,再拿去和目前的前綴和相減,整個過程拿到的最大值就是答案了。如此一來我們就在 $O(n)$ 的時間解決了這個問題。

#include <bits/stdc++.h>
using namespace std;

using ll = long long;

int main() {
    int n;
    cin >> n;

    ll s = 0;
    ll mn = 0;
    ll ans = -LLONG_MAX;
    for (int i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        s += x;
        ans = max(ans, s - mn);
        mn = min(mn, s);
    }
    cout << ans << "\n";
}

總結


這裡幫讀者總結一些枚舉的思考策略:

習題


習題
MAX ! MAX ! MAX !

有 $n$ 個數 $a[1],a[2],\dots,a[n]$,輸出最大的 $a[i]-a[j]$($i<j$)。

條件限制
  • $n \leq 10^5$
習題
Special Elements

給一個序列 $a_1,a_2,\dots,a_n$,對於一個元素 $a_i$,如果有一個長度至少為 2的區間總和 $a_l+a_{l+1}+\dots+a_r$ 等於 $a_i$,我們就說 $a_i$ 是特別的。求這個序列裡有幾個特別的元素。

條件限制
  • $n \leq 8000$
  • $1 \leq a_i \leq n$
習題
多麼OwO

給一個字串 $S$,求這個字串有幾個子序列(抓出字串中的一些字元,不改變順序,可以不連續)是 OwO

條件限制
  • 有 $T$ 筆輸入,字串長度總和 $\leq 10^8$
習題
Rejudge!
Source:TIOJ 2060

有一場簡化規則的 ICPC 比賽,有 $N$ 支隊伍、$M$ 道題目,整場比賽中有 $S$ 筆 submission,這場比賽每一題的測資都是爛的,告訴你每筆 submission 的上傳時間、隊伍、題號、爛的測資的結果和好的測資的結果,求如果你可以挑最多 $K$ 題,用好的測資全部重測一遍,那麼隊伍 0 的名次最高可以是多少。

條件限制
  • 有 $T$ 筆輸入,$T \leq 10$
  • $N \leq 100$
  • $K \leq M \leq 15$
  • $S \leq 10^4$
習題
框架區間

給一個序列 $S$,對於一個區間 $[a,b]$,如果 $S_a,S_{a+1},\dots,S_{b}$ 之中出現的數字集合恰好是 $\{a,a+1,\dots,b\}$,我們就說 $[a,b]$ 是一個框架區間,求有幾個框架區間。

條件限制
  • 有 $T$ 筆輸入,$T \leq 20$
  • $n \leq 5000$