完美彩帶 題解|APCS 2019 年 6 月中高級 第 4 題


前置知識

這題的核心(固定長度的視窗往右滑一格,只有一格進來、一格出去)下面會從頭講;要先會的是:

簡潔題意

彩帶有 \(n\) 格,每格一個顏色編號,整條彩帶恰好出現 \(m\) 種顏色。長度剛好 \(m\) 的連續一段,如果每種顏色都恰好出現一次,就是一段「完美彩帶」。求有幾段(不同起點分開算)(\(2 \le m \le n \le 200000\),顏色編號 \(0\) 到 \(10^9\))。(APCS 逐筆給分。)

關鍵的一句話:視窗長度是 m,所以「m 種顏色」就是「每種恰好一次」

一段長度 \(m\) 的視窗裡有 \(m\) 格,如果裡面出現了全部 \(m\) 種顏色,每種就只能各占一格——「每種顏色恰好出現一次」和「視窗裡有 \(m\) 種不同的顏色」是同一件事。所以只要能數出每個視窗裡「有幾種顏色」,數到 \(m\) 的就是完美彩帶。範例的七個視窗見圖 1。

圖 1:範例 m=4 的七個視窗,各有 3、4、4、3、2、3、4 種顏色,剛好 4 種的有 3 個

先照字面做:每個視窗各自數

最直覺的寫法:每個起點 \(l\) 各開一個 set,把 \(m\) 格丟進去,size() 是 \(m\) 就加一。

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

const int MAX_N = 200000;
int a[MAX_N + 1];                // a[i] = 第 i 格的顏色

int main() {
    int m, n;
    cin >> m >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    int answer = 0;
    for (int l = 1; l + m - 1 <= n; l++) {       // 每一段 [l, l + m - 1] 各自檢查
        set<int> colors;                         // 這一段出現過的顏色
        for (int i = l; i <= l + m - 1; i++) colors.insert(a[i]);
        if ((int)colors.size() == m) answer++;   // m 格裡有 m 種顏色 = 每種恰好一次
    }
    cout << answer << endl;
    return 0;
}

答案是對的,但每個視窗都要看 \(m\) 格,總共 \((n - m + 1) \times m\) 次:\(m = 100000\)、\(n = 200000\) 時是 \(10^{10}\) 次,本站實測 \(60\) 秒都跑不完。

不重數:視窗滑一格,只有一格進來、一格出去

視窗從 \([l, l + m - 1]\) 滑到 \([l + 1, l + m]\),裡面 \(m - 1\) 格都沒變,只有第 \(l + m\) 格進來、第 \(l\) 格出去。用 count[c] 記視窗裡顏色 \(c\) 出現幾次、kinds 記目前有幾種顏色:

  • 第 \(i\) 格進來:count[a[i]]++,如果加完變成 \(1\),表示這種顏色本來沒有,kinds++。
  • 第 \(i - m\) 格出去:count[a[i - m]]--,如果減完變成 \(0\),表示這種顏色沒了,kinds--。
  • 視窗湊滿 \(m\) 格之後(\(i \ge m\)),kinds == m 就加一。
#include <bits/stdc++.h>
using namespace std;

const int MAX_N = 200000;
int a[MAX_N + 1];                // a[i] = 第 i 格的顏色

int main() {
    int m, n;
    cin >> m >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    map<int, int> count;                         // count[c] = 視窗裡顏色 c 出現幾次
    int kinds = 0;                               // 視窗裡有幾種不同的顏色
    int answer = 0;
    for (int i = 1; i <= n; i++) {
        count[a[i]]++;                           // 右端:第 i 格進來
        if (count[a[i]] == 1) kinds++;           // 這種顏色本來沒有,現在有了
        if (i > m) {                             // 左端:第 i - m 格出去,視窗維持 m 格
            count[a[i - m]]--;
            if (count[a[i - m]] == 0) kinds--;   // 這種顏色沒了
        }
        if (i >= m && kinds == m) answer++;      // 視窗 [i - m + 1, i] 剛好 m 種顏色
    }
    cout << answer << endl;
    return 0;
}

幾個細節:

  • 兩個邊界要分開想:第一次「有格子出去」是 \(i = m + 1\)(第 \(1\) 格出去),所以是 i > m;第一次「視窗湊滿」是 \(i = m\),所以是 i >= m。少一個等號就會漏掉第一個視窗、多一個等號會把不存在的第 \(0\) 格拿去扣。
  • 顏色編號到 \(10^9\),不能開那麼大的陣列當計數表(\(4 \times 10^9\) 位元組,遠超記憶體限制),所以用 map。
  • 每格進來一次、出去一次,\(O(n \log m)\),本站實測 \(0.11\) 秒。

測過再交:範例只有一種 m

範例 \(m = 4\) 的三段完美彩帶都在中間;沒蓋到的是:\(n = m\) 只有一個視窗、每個視窗都完美(答案 \(n - m + 1\))、重複的顏色出去之後 kinds 要正確減回來、很大的顏色編號、以及 \(m\) 很大的測資(驗速度)。

輸入 正確輸出 這一筆在測什麼
2 2/5 9 1 \(n = m\):只有一個視窗,而且就是第一個(i >= m 那個等號)
3 6/1 2 3 1 2 3 4 每個視窗都完美:答案是 \(n - m + 1\)
2 5/7 7 8 8 7 2 7 7 之後 7 8 完美:左邊那個 \(7\) 出去時 count 從 \(2\) 變 \(1\),kinds 不能減
3 5/1000000000 0 1000000000 0 5 1 顏色編號 \(10^9\):拿來當陣列索引會爆
用程式造:\(m = 100000\)、\(n = 200000\),顏色 \(1\) 到 \(100000\) 重複兩輪 100001 最大的視窗:每個視窗都完美,\(O(nm)\) 的做法跑不完

五筆都親眼看過正確,這題就穩了。

另一種做法:記每種顏色上一次出現在哪

不數視窗也行:對每個 \(i\) 問「以第 \(i\) 格結尾、沒有重複顏色的最長一段是從哪一格開始」,記成 start。顏色 \(c\) 上一次出現的位置是 lastSeen[c],第 \(i\) 格的顏色如果在 start 之後出現過,start 就要跳到 lastSeen[a[i]] + 1(start 只會往右,永遠不用往回)。這一段的長度 i - start + 1 至少 \(m\),就表示 \([i - m + 1, i]\) 這 \(m\) 格顏色都不同——剛好 \(m\) 格、\(m\) 種顏色,是一段完美彩帶。這正是《AP325》習題 Q-3-11 最長的相異色彩帶 的做法直接拿來用。

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

const int MAX_N = 200000;
int a[MAX_N + 1];                // a[i] = 第 i 格的顏色

int main() {
    int m, n;
    cin >> m >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    map<int, int> lastSeen;                      // lastSeen[c] = 顏色 c 上一次出現在第幾格(沒出現過就是 0)
    int start = 1;                               // 以第 i 格結尾、沒有重複顏色的最長一段,從第 start 格開始
    int answer = 0;
    for (int i = 1; i <= n; i++) {
        if (lastSeen[a[i]] + 1 > start) start = lastSeen[a[i]] + 1;   // 上次出現的位置之前都不能要
        lastSeen[a[i]] = i;
        if (i - start + 1 >= m) answer++;        // 不重複的一段至少 m 格 ⇒ 最後 m 格恰好是一段完美彩帶
    }
    cout << answer << endl;
    return 0;
}

一樣 \(O(n \log m)\)、本站實測 \(0.11\) 秒。兩種做法都是滿分;數視窗的版本最直接,記上次位置的版本程式更短、也是「最長無重複子陣列」這類題的通用寫法。

常犯錯誤
  • 只有格子進來、沒有格子出去:視窗越滑越長——範例自己就會抓到,印 6,正確是 3;自測表第 \(3\) 列印 3。
  • i >= m 寫成 i > m:第一個視窗沒算到——範例剛好過(第一個視窗本來就不完美),本站計分測資掛 \(18\) 筆;自測表第 \(1\) 列印 0、第 \(2\) 列印 3。
  • i > m 寫成 i >= m:\(i = m\) 時把不存在的第 \(0\) 格拿去扣——範例過,計分測資掛 \(18\) 筆;自測表第 \(4\) 列印 0。
  • 格子出去時 kinds 無條件減一:同色還有別格留在視窗裡也被當成沒了——範例印 0。
  • 每個視窗各自數的直覺版直接交:\(m = 100000\) 時本站實測 \(60\) 秒跑不完。