美食博覽會 題解|APCS 2021 年 9 月高級 第 4 題


前置知識

這題是「DP 的轉移寫出來之後還要再砍一刀」的經典,下面的講解直接把這些當成已知,沒讀過的先讀;同一個主題兩邊都有的,對照著讀會更快上手:

簡潔題意

\(n\) 個攤位排成一列,第 \(i\) 攤賣品項 \(a_i\)。\(k\) 位試吃員各自挑一個攤位開始,依編號往後一攤一攤吃;遇到自己已經吃過的品項就不吃、直接離開。每個攤位只算一次,求 \(k\) 個人合起來最多能造訪幾個攤位(\(1 \le n \le 10^6\),\(1 \le k \le 20\),\(n \times k \le 5 \times 10^6\),品項是小於 \(10^5\) 的非負整數)。

依正確通過的測試資料筆數給分,其中:

  • 子題組 1(\(50\) 分):\(k = 1\)。
  • 子題組 2(\(50\) 分):無額外限制。

換句話說:在這個序列上挑出至多 \(k\) 段互不重疊的連續區段,每一段裡的品項都不重複,總長度最大是多少。兩個人的路線重疊沒有好處——重疊的攤位只算一次,把後面那個人的起點往後挪,攤位數不會變少。

先拿下 50 分:一個人就是最長的不重複區段

只有一個人時,答案就是最長的、品項不重複的連續區段。固定右端點 \(j\),往左最多能延伸到哪裡?記 \(L[j]\) =「以第 \(j\) 攤結尾、品項都不重複」的最長區段的左端。右端從 \(j - 1\) 推到 \(j\) 時,左端只會往右走:新加入的品項 \(a_j\) 如果在區段裡出現過(上一次出現在 \(\text{last}[a_j]\)),左端就得推到它的下一攤;否則不動。

\[L[j] = \max\bigl(L[j-1],\ \text{last}[a_j] + 1\bigr)\]

攤位從 \(1\) 編號,last 全部初始化成 \(0\) 就代表「還沒出現過」;品項可以是 \(0\),所以 last 的下標是品項、不是「品項減一」。

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

const int MAX_N = 1000005;
const int MAX_A = 100005;
int a[MAX_N], L[MAX_N];        // L[j]:以第 j 攤結尾的最長不重複區段,左端在哪
int last[MAX_A];               // last[v]:品項 v 上一次出現在第幾攤(0 = 還沒出現)

int main() {
    int n, k;
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];

    int left = 1, ans = 0;
    for (int j = 1; j <= n; j++) {
        left = max(left, last[a[j]] + 1);  // a[j] 上次出現在區段裡 → 左端推到它後面
        last[a[j]] = j;
        L[j] = left;
        ans = max(ans, j - L[j] + 1);
    }
    cout << ans << '\n';
    return 0;
}

left 只會往右走,整個迴圈 \(O(n)\)。範例 1 的 \(L\) 是 \(1, 1, 2, 2, 4\),最長的是以第 \(4\) 攤結尾的 \(2 \sim 4\),答案 \(3\)。\(k > 1\) 時這份程式只讓一個人吃,範例 2 會 WA——這是正常的,逐筆給分之下子題組 1 的 \(50\) 分穩穩到手。\(L\) 這個陣列滿分解會直接拿來用。

從 50 分到 100 分:DP,但每個右端點只看最長的那一段

多人的時候要決定「誰從哪裡吃到哪裡」,用 DP:

\(dp[p][j]\) = 只考慮前 \(j\) 攤、至多用 \(p\) 個人,最多能造訪幾攤。

第 \(j\) 攤只有兩種情況:

  1. 沒有人造訪第 \(j\) 攤:\(dp[p][j] = dp[p][j - 1]\)。
  2. 某個人的路線在第 \(j\) 攤結束:他從第 \(l\) 攤開始,這一段要合法就得 \(L[j] \le l \le j\);前面 \(l - 1\) 攤交給剩下 \(p - 1\) 個人,得到 \(dp[p-1][l-1] + (j - l + 1)\)。

把情況 2 的 \(l\) 全部枚舉一遍,一格要花 \(O(j - L[j])\);品項幾乎不重複時每個區段長達 \(10^5\),總共 \(n \cdot k \cdot 10^5\),本站實測超過時限。

砍掉這一刀的觀察:情況 2 只需要看 \(l = L[j]\),也就是最長的那一段。 假設最佳解裡最後一個人吃的是較短的 \([l', j]\)(\(l' > L[j]\)),把他的起點往左拉到 \(L[j]\),多吃了 \(l' - L[j]\) 攤;前一個人的路線 \([a, b]\) 若因此和他重疊(\(b \ge L[j]\)),就把前一個人截短到 \([a, L[j] - 1]\)——合法區段的前半段還是合法的,截到空掉也沒關係(那個人乾脆不吃)。前一個人最多少掉 \(b - L[j] + 1\) 攤,而 \(b < l'\),所以少掉的不會超過多拿的(圖 1)。換句話說,拉長最後一段永遠不會變差,最佳解一定可以長成「最後一段是最長的那段」的樣子。

圖 1:以第 j 攤結尾時,把最後一個人的路線從較短的 [l', j] 拉長到最長的 [L(j), j],前一個人最多被截掉同樣多的攤位,總數不會變少 \[dp[p][j] = \max\bigl(dp[p][j-1],\ dp[p-1][L[j]-1] + (j - L[j] + 1)\bigr)\]

每一格 \(O(1)\),整張表 \(O(nk) \le 5 \times 10^6\)。表格 \((k+1)(n+1)\) 個 int 最多約 \(6 \times 10^6\) 個、\(24\) MB,直接開得下。

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

const int MAX_N = 1000005;
const int MAX_A = 100005;
int a[MAX_N], L[MAX_N];
int last[MAX_A];

int main() {
    int n, k;
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];

    int left = 1;
    for (int j = 1; j <= n; j++) {         // 和 50 分版一樣,先算每個 j 的 L[j]
        left = max(left, last[a[j]] + 1);
        last[a[j]] = j;
        L[j] = left;
    }

    // dp[p][j]:前 j 攤、至多 p 個人,最多造訪幾攤(第 0 列、第 0 行都是 0)
    vector<vector<int>> dp(k + 1, vector<int>(n + 1, 0));
    for (int p = 1; p <= k; p++) {
        for (int j = 1; j <= n; j++) {
            int skip = dp[p][j - 1];                          // 第 j 攤沒人吃
            int take = dp[p - 1][L[j] - 1] + (j - L[j] + 1);  // 有人從 L[j] 吃到 j
            dp[p][j] = max(skip, take);
        }
    }
    cout << dp[k][n] << '\n';
    return 0;
}

\(k = 1\) 時第 \(1\) 列就是「以 \(j\) 結尾的最長區段」取前綴最大值,和 50 分版算的是同一件事;範例 2 的 \(dp[3][10] = 8\)。

測過再交:範例沒有「拉長最後一段」的場面

範例 1 是 \(k = 1\),範例 2 的最佳解三個人剛好首尾相接。沒蓋到的:人比需要的多、品項全部相同、品項 \(0\) 與最大值、以及「每個人都貪最長的一段」會吃虧的局面。

輸入 正確輸出 這一筆在測什麼
1 1 / 5 1 最小的輸入
5 3 / 7 7 7 7 7 3 品項全部相同:每個人只能吃一攤,答案被人數卡住
4 5 / 1 2 3 4 4 人比需要的多:一個人就吃完,其他人不出門也沒關係
3 1 / 0 99999 0 2 品項 \(0\) 與最大值 \(99999\):last 的下標直接用品項,兩端都要放得下
4 2 / 1 1 1 2 3 第一個人若貪「從第 \(1\) 攤起的最長一段」只吃到一攤,兩人共 \(2\);正解是一個人吃第 \(3 \sim 4\) 攤、另一人吃第 \(1\) 攤
6 2 / 1 2 3 1 2 3 6 兩個人剛好各吃一段,把全部攤位吃完

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

常犯錯誤
  • 「第 \(j\) 攤沒人吃」寫成 \(dp[p-1][j]\)(少一個人),不是 \(dp[p][j-1]\)(少一攤):範例 1 印 2、範例 2 印 7;本站 \(22\) 筆測資掛 \(9\) 筆。
  • 遇到重複的品項就把視窗整個清空重來:左端應該只推到「重複品項上次出現」的下一攤,不是推到 \(j\)。範例 1 印 2;本站掛 \(11\) 筆。
  • 由左到右,每個人都貪最長的一段:範例 1 印 2、範例 2 印 6;自測表第 5 列也抓得到。
  • 挑 \(k\) 段最長的不重複視窗、不管重疊:重疊的攤位會被算兩次,範例 2 印 9;本站掛 \(5\) 筆。
  • 轉移把每個起點 \(l\) 都枚舉一遍:\(n = 10^6\)、品項幾乎不重複時每個區段長達 \(10^5\),本站實測超過時限;本站掛 \(1\) 筆。