紅白彩帶 題解|APCS 2019 年 2 月中高級 第 2 題
前置知識
這題的核心(塗一格紅只會讓它左右兩段紅色區段合併成一段)下面會從頭講;要先會的是兩個工具,同一個主題兩邊都有的,對照著讀會更快上手:
- 計數陣列:語法書 6.7——要帶走的是「值的範圍不大時,開一個陣列
cnt[v]記每種值出現幾次,要找最小值就從小往大掃到第一個非零的格子」。這題「紅色區段長度不超過 \(10000\)」這句限制就是在暗示這條路。 multiset(最後的「另一種做法」會用到):NTUCPC Guide〈Set 與 Map〉——元素永遠排好序、允許重複,begin()是最小、rbegin()是最大,erase(find(x))只拿掉一個 \(x\)。吳邦一《AP325》把本題放在第 7 章 7.4.2 併查集一節當習題 Q-7-11(教材 PDF 第 270~271 頁;Python 版第 7 章(III)),同一節也說「這一題不使用併查集也可以通過」——下面教的就是不用併查集的做法。
簡潔題意
彩帶有 \(n\) 格,\(1\) 是紅、\(0\) 是白;連續的紅格算一段「紅色區段」。依序把 \(k\) 個指定的白格塗紅(位置從 \(1\) 數起、互不相同)。初始狀態和每次塗完之後,都把「最長的紅色區段長度」加進第一個總和、「最短的」加進第二個總和,沒有任何紅格時兩個都算 \(0\);最後輸出兩個總和(\(1 \le n \le 100000\),\(0 \le k \le \min(n, 20000)\),\(k = 0\) 時第三行是空行,任何時刻紅色區段長度都不超過 \(10000\))。(APCS 逐筆給分。)
先照字面做:每塗一格就重掃一次
最直覺的做法是把彩帶存進陣列,每塗完一格就從頭掃一遍:遇到紅格就往右數到白格為止,得到一段的長度,擂台法同時記最長和最短。初始狀態也要掃一次。
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 100000;
int color[MAX_N + 2]; // color[i] = 第 i 格是不是紅色(0 白、1 紅)
int n, k;
long long sumMax = 0, sumMin = 0;
void tally() { // 把目前的最長、最短紅色區段長度加進總和
int longest = 0, shortest = 0;
int i = 1;
while (i <= n) {
if (color[i] == 0) {
i++;
continue;
}
int j = i; // 從 i 開始往右數這一段有幾格紅
while (j <= n && color[j] == 1) j++;
int len = j - i;
if (len > longest) longest = len;
if (shortest == 0 || len < shortest) shortest = len;
i = j;
}
sumMax += longest;
sumMin += shortest;
}
int main() {
cin >> n >> k;
for (int i = 1; i <= n; i++) cin >> color[i];
tally(); // 初始狀態也要算
for (int t = 0; t < k; t++) {
int x;
cin >> x;
color[x] = 1;
tally();
}
cout << sumMax << endl << sumMin << endl;
return 0;
}
兩個細節:「最短」的擂台初值不能用 \(0\) 直接比小,所以用 shortest == 0 代表「還沒看過任何一段」;沒有紅格時 longest、shortest 都維持 \(0\),剛好就是題目要的。
這個寫法每次掃 \(n\) 格,一共掃 \(k + 1\) 次,最壞 \(20001 \times 100000 \approx 2 \times 10^9\) 次——本站時限 \(2\) 秒,\(k = 20000\) 的測資實測 \(1.3\) 到 \(3.2\) 秒,有的過、有的超時。答案是對的,只是慢。
不重掃:塗一格只會動到它左右的兩段
塗第 \(x\) 格時,整條彩帶只有一個地方變了:如果第 \(x - 1\) 格是紅的,它左邊貼著一段紅色區段;如果第 \(x + 1\) 格是紅的,右邊也貼著一段;塗完之後這兩段(有幾段算幾段)和第 \(x\) 格合併成一段,長度是「左邊那段的長度 \(+ 1 +\) 右邊那段的長度」。其他段完全不受影響。
所以要做的只有「查左右兩段多長」和「登記合併後的新長度」。查長度的方法:把每一段的長度記在它的左右兩端那兩格,len[l] = len[r] = 這一段的長度。第 \(x - 1\) 格如果是紅的,它一定是左邊那段的右端,len[x - 1] 就是左段的長度;同理 len[x + 1] 是右段的長度。合併後的新段左端是 \(x - \text{left}\)、右端是 \(x + \text{right}\),把新長度寫到這兩格就登記完了(中間那些格的 len 是舊值沒關係,不會再被讀到——會被讀到的永遠只有兩端)。範例 2 的四個狀態見圖 1。
接下來是「每個狀態的最長和最短」。最長很簡單:長度只會因為合併而變大、永遠不會變小,所以用擂台變數 longest 一路取最大值就對了。最短就沒這麼好——新塗的一格如果左右都是白的,就冒出一段長度 \(1\) 的新段,最短立刻掉到 \(1\);下一步它又可能被合併掉,最短又彈回去。這裡就用到題目的限制「紅色區段長度不超過 \(10000\)」:開一個計數陣列 cnt[L] 記「目前長度為 \(L\) 的段有幾段」,合併時把舊的兩段長度各減一、新長度加一;要找最短就從 \(L = 1\) 往上掃,第一個 cnt[L] > 0 的就是答案,掃完都是 \(0\) 就表示沒有紅格、答案 \(0\)。
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 100000;
const int MAX_LEN = 10000; // 題目保證紅色區段長度不超過 10000
int color[MAX_N + 2]; // color[i] = 第 i 格是不是紅色;第 0 格和第 n + 1 格永遠是白的
int len[MAX_N + 2]; // 只在每段紅色區段的「兩端」有效:記這一段有幾格
int cnt[MAX_LEN + 1]; // cnt[L] = 目前長度為 L 的紅色區段有幾段
int n, k;
int longest = 0; // 目前最長的紅色區段(只會變長,擂台就夠)
int sumMax = 0, sumMin = 0;
void tally() { // 把目前的最長、最短長度加進總和
int shortest = 0;
for (int L = 1; L <= MAX_LEN; L++) { // 從短的往長的找,第一個有段的長度就是最短
if (cnt[L] > 0) {
shortest = L;
break;
}
}
sumMax += longest;
sumMin += shortest;
}
int main() {
cin >> n >> k;
for (int i = 1; i <= n; i++) cin >> color[i];
int i = 1;
while (i <= n) { // 先把初始狀態的每一段紅色區段登記起來
if (color[i] == 0) {
i++;
continue;
}
int j = i;
while (j <= n && color[j] == 1) j++;
int L = j - i; // 第 i 格到第 j - 1 格是一段
len[i] = L;
len[j - 1] = L;
cnt[L]++;
if (L > longest) longest = L;
i = j;
}
tally(); // 初始狀態也要算
for (int t = 0; t < k; t++) {
int x;
cin >> x;
int left = 0, right = 0; // 左右兩邊貼著的紅色區段各有多長(沒有就是 0)
if (color[x - 1] == 1) left = len[x - 1];
if (color[x + 1] == 1) right = len[x + 1];
if (left > 0) cnt[left]--; // 舊的兩段消失
if (right > 0) cnt[right]--;
int L = left + 1 + right; // 合併成一段新的
cnt[L]++;
color[x] = 1;
len[x - left] = L; // 新的一段:左端是 x - left、右端是 x + right
len[x + right] = L;
if (L > longest) longest = L;
tally();
}
cout << sumMax << endl << sumMin << endl;
return 0;
}
幾個細節:
- 陣列多開兩格、從 \(1\) 用起(語法書 6.3),第 \(0\) 格和第 \(n + 1\) 格永遠是 \(0\),所以
color[x - 1]、color[x + 1]在 \(x = 1\) 或 \(x = n\) 時不用特判。 - 每次塗色是 \(O(1)\),找最短最多掃 \(10000\) 格,\(k + 1\) 個狀態最多 \(2 \times 10^8\) 次簡單比較,本站實測 \(0.06\) 秒。
- 總和用
int就夠:每個狀態的最長最多 \(10000\),\(k + 1 \le 20001\) 個狀態,總和不超過 \(20001 \times 10000 \approx 2 \times 10^8\),離int的 \(2147483647\) 還很遠(估算方法見語法書 2.9);不想算就用long long,兩種都對。
測過再交:範例沒有「沒紅格」和「兩邊都合併」
範例 1 塗一格把左右兩段接起來、範例 2 有單獨冒出一段和接在一段旁邊;沒蓋到的是:一開始一格紅都沒有(總和要算 \(0\)、而且 \(k = 0\) 時第三行是空行)、塗完才出現第一段、合併之後最短往上跳、以及最大的 \(k\)(驗速度和找最短的掃描)。
| 輸入 | 正確輸出 | 這一筆在測什麼 |
|---|---|---|
3 0/0 0 0/(空行) |
0/0 |
沒有紅格:兩個總和都是 \(0\),\(k = 0\) 的空行不能讓程式卡住 |
4 2/1 0 0 1/2 3 |
7/6 |
塗第 \(3\) 格時左邊一段長 \(2\)、右邊一段長 \(1\),合併成 \(4\):\(1 + 2 + 4\) 與 \(1 + 1 + 4\) |
5 2/0 0 0 0 0/3 1 |
2/2 |
一開始全白:初始狀態算 \(0\),塗了才冒出長度 \(1\) 的段 |
6 1/1 1 1 0 1 1/4 |
9/8 |
合併後最短從 \(2\) 跳到 \(6\):找最短不能只往下找 |
| 用程式造:\(n = 100000\)、\(k = 20000\),一開始第 \(1\) 到 \(9999\) 格是紅的,先依序塗第 \(10001\) 到 \(20000\) 格、再塗第 \(20002\) 到 \(30001\) 格 | 200000000/100019997 |
最大測資:兩段各長到 \(10000\),每一步都在找最短,驗速度 |
五筆都親眼看過正確,這題就穩了。
另一種做法:用 `multiset` 記所有段的長度
不想依賴「長度不超過 \(10000\)」這條限制,就把所有段的長度放進 multiset<int>:begin() 是最短、rbegin() 是最長,合併時 erase(find(left))、erase(find(right)) 各拿掉一個舊長度(直接 erase(left) 會把所有同長度的段一起刪光)、再 insert 新長度。查左右段多長的方法和上面一模一樣。
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 100000;
int color[MAX_N + 2]; // color[i] = 第 i 格是不是紅色;第 0 格和第 n + 1 格永遠是白的
int len[MAX_N + 2]; // 只在每段紅色區段的「兩端」有效:記這一段有幾格
multiset<int> lengths; // 目前每一段紅色區段的長度,同長度的段各算一個
int n, k;
int sumMax = 0, sumMin = 0;
void tally() { // 最短在最前面、最長在最後面;沒有紅格就都是 0
if (lengths.empty()) return;
sumMax += *lengths.rbegin();
sumMin += *lengths.begin();
}
int main() {
cin >> n >> k;
for (int i = 1; i <= n; i++) cin >> color[i];
int i = 1;
while (i <= n) { // 先把初始狀態的每一段紅色區段登記起來
if (color[i] == 0) {
i++;
continue;
}
int j = i;
while (j <= n && color[j] == 1) j++;
int L = j - i;
len[i] = L;
len[j - 1] = L;
lengths.insert(L);
i = j;
}
tally();
for (int t = 0; t < k; t++) {
int x;
cin >> x;
int left = 0, right = 0;
if (color[x - 1] == 1) left = len[x - 1];
if (color[x + 1] == 1) right = len[x + 1];
if (left > 0) lengths.erase(lengths.find(left)); // 只拿掉「一個」這種長度
if (right > 0) lengths.erase(lengths.find(right));
int L = left + 1 + right;
lengths.insert(L);
color[x] = 1;
len[x - left] = L;
len[x + right] = L;
tally();
}
cout << sumMax << endl << sumMin << endl;
return 0;
}
每次塗色是 \(O(\log n)\),本站實測 \(0.03\) 秒。兩種做法都是滿分;計數陣列版用到題目給的限制、什麼容器都不用,multiset 版則是長度沒有上限也能用。學過併查集的人也可以把「合併兩段」交給併查集做,不過這題相鄰的段只會在塗色那一格接起來,記兩端長度就夠了。
常犯錯誤
- 忘了初始狀態也要加進總和:範例 1 自己就會抓到——印
3、1,正確是4、2。 - 沒有紅格時直接取
multiset的begin()/rbegin()(或計數版找不到就亂用變數):兩個範例都過,本站計分測資有 \(4\) 筆一開始全白,程式直接當掉;自測表第 \(1\)、\(3\) 列現形。 - 段的長度只記在一端(例如只記左端):從右邊接上來時讀到的是舊值——範例 2 印
10、5,正確是11、6;計分測資掛 \(8\) 筆。 - 合併時忘了把舊的兩段長度從計數陣列扣掉:最短永遠停在被合併掉的舊長度——範例 2 印
11、4;自測表第 \(2\) 列印7、3;計分測資掛 \(5\) 筆。 - 每塗一格就整條重掃:答案全對,但 \(k = 20000\) 的測資本站實測 \(1.3\) 到 \(3.2\) 秒,時限 \(2\) 秒,會掉好幾筆。