最佳選擇 題解|APCS 2024 年 6 月高級 第 4 題
前置知識
這題是「前綴和+雙指標」的組合,下面的講解直接把這些當成已知,沒讀過的先讀;同一個主題兩邊都有的,對照著讀會更快上手:
- 前綴和:NTUCPC Guide〈前綴和與差分〉——先算好「前 \(i\) 個的總和」,任何一段的和就是兩個前綴和相減;吳邦一《AP325》第 1 章 P-1-6「最接近的區間和」用同一招把區間和的計算從 \(O(n^3)\) 降到 \(O(n^2)\)(教材 PDF 第 25~27 頁)。本題除了「總和」,還把奇數記 \(+1\)、偶數記 \(-1\) 做成另一組前綴和。
- 雙指標:NTUCPC Guide〈雙指標〉——一個指標往右走的時候,另一個指標只會往同一個方向走,兩個指標合起來最多動 \(O(n)\) 次;《AP325》第 2 章 P-2-6「Two-Number problem」的第二種做法就是它(教材 PDF 第 55~56 頁,站上題目 ap325p206)。
- vector 的
push_back/pop_back:本題替每一種「奇偶差」各開一個 vector 當桶子,尾端放進去、尾端拿出來。
簡潔題意
\(n\) 個正整數排成一列。從最左邊取走一段前綴、從最右邊取走一段後綴(兩段不能重疊;也可以只取一邊、或兩邊都不取),取出來的數裡奇數與偶數的個數要一樣多,而且總和不能超過 \(k\)。求能取到的最大總和;取不到任何東西就輸出 \(0\)(\(1 \le n \le 3 \times 10^5\),\(1 \le k \le 10^9\),\(1 \le a_i \le 5000\);題目另外保證每個前綴裡「奇數個數減偶數個數」的絕對值不超過 \(2000\))。
依正確通過的測試資料筆數給分,其中:
- 子題組 1(\(20\) 分):\(n = 1000\)。
- 子題組 2(\(80\) 分):無額外限制。
先拿下 20 分:枚舉前綴長度和後綴長度
「奇數與偶數一樣多」不好直接數,換個記法:奇數記 \(+1\)、偶數記 \(-1\),一堆數的「奇偶差」就是這些 \(\pm 1\) 的總和,奇偶差是 \(0\) 就代表奇數和偶數一樣多。於是每個候選選法只要看兩個數字——總和與奇偶差——而這兩個都能用前綴和在 \(O(1)\) 算出來:
pre[i]=前 \(i\) 個的總和,bal[i]=前 \(i\) 個的奇偶差。前綴取前 \(i\) 個、後綴取第 \(j+1\) 個到最後(\(j \ge i\) 才不會重疊),總和就是pre[i] + (pre[n] - pre[j]),奇偶差就是bal[i] + (bal[n] - bal[j])。
把所有 \((i, j)\) 都試一遍(\(i = 0\) 是不取前綴、\(j = n\) 是不取後綴,都要算進去),奇偶差是 \(0\)、總和不超過 \(k\) 的裡面取最大:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
long long k;
cin >> n >> k;
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
// pre[i]:a[1..i] 的總和;bal[i]:a[1..i] 裡「奇數個數 − 偶數個數」
vector<long long> pre(n + 1, 0);
vector<int> bal(n + 1, 0);
for (int i = 1; i <= n; i++) {
pre[i] = pre[i - 1] + a[i];
bal[i] = bal[i - 1] + (a[i] % 2 == 1 ? 1 : -1);
}
long long best = 0; // 什麼都不取=0,也是合法的
for (int i = 0; i <= n; i++) // 前綴取 a[1..i]
for (int j = i; j <= n; j++) { // 後綴取 a[j+1..n];j >= i 才不會重疊
long long sum = pre[i] + (pre[n] - pre[j]);
int balance = bal[i] + (bal[n] - bal[j]);
if (balance == 0 && sum <= k) best = max(best, sum);
}
cout << best << '\n';
return 0;
}
\(n = 1000\) 時 \((i, j)\) 大約 \(5 \times 10^5\) 對,子題組 1 的 \(20\) 分穩穩到手。\(n = 3 \times 10^5\) 就是 \(4.5 \times 10^{10}\) 對,本站實測一筆要 \(36\) 秒。
從 20 分到 100 分:固定前綴,後綴只有三個條件
把前綴固定下來(總和 \(P\)、奇偶差 \(p\)),什麼樣的後綴配得上它?只有三件事:
- 奇偶差要是 \(-p\):兩段加起來才是 \(0\)(圖 1)。
- 長度不能超過 \(n - i\):才不會跟前綴重疊。
- 總和不能超過 \(k - P\):剩下的預算。
符合這三條的後綴裡,哪一個最好?數字全是正的,所以後綴越長總和越大——要的就是最長的那一個。
條件 1 用桶子解決:把每個後綴長度 \(j\) 依奇偶差 sbal[j] 丟進對應的桶 bucket[sbal[j]](長度 \(0\) 的空後綴也丟進差 \(0\) 的桶),由短到長依序放,所以每個桶裡的長度是遞增的、桶尾就是最長的。要配前綴時直接去看 bucket[-p]。
條件 2、3 都是「長度不能超過某個上限」——條件 3 也是,因為後綴總和隨長度遞增,付得起的長度自然有個上限。關鍵在於:前綴每往右長一格,這兩個上限只會變小(\(n - i\) 變小、剩下的預算變少)。所以用一個變數 allowed 記「現在最長可以用到哪個長度」,前綴每長一格就檢查 allowed 還合不合法,不合法就一直減、每減一次把那個長度從它的桶裡拿掉——而它一定是該桶的最後一個,因為桶裡的長度遞增、我們又是從最長的開始拿。這就是雙指標:\(i\) 往右走、allowed 往左走,各自最多走 \(n\) 步。
桶要開多大?前綴的奇偶差保證在 \(\pm 2000\) 內,但後綴沒有這個保證:後綴的奇偶差=整列的奇偶差減掉前面那段前綴的奇偶差,範圍是 \(\pm 4000\),所以加 \(4000\) 當下標、開 \(8001\) 個桶。總和最大 \(3 \times 10^5 \times 5000 = 1.5 \times 10^9\),int 的上限 \(2147483647\) 剛好放得下,但太貼近了,用 long long 不用擔心。
#include <bits/stdc++.h>
using namespace std;
const int OFFSET = 4000; // 後綴的奇偶差在 -4000 到 4000 之間,加 4000 當下標
int main() {
int n;
long long k;
cin >> n >> k;
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
// suf[j]:最右邊 j 個的總和;sbal[j]:最右邊 j 個的奇偶差
vector<long long> suf(n + 1, 0);
vector<int> sbal(n + 1, 0);
vector<vector<int>> bucket(2 * OFFSET + 1); // bucket[d + OFFSET]:奇偶差是 d 的後綴長度,由短到長
bucket[OFFSET].push_back(0); // 長度 0 的空後綴:奇偶差 0、總和 0
for (int j = 1; j <= n; j++) {
suf[j] = suf[j - 1] + a[n - j + 1];
sbal[j] = sbal[j - 1] + (a[n - j + 1] % 2 == 1 ? 1 : -1);
bucket[sbal[j] + OFFSET].push_back(j);
}
long long best = 0, pre = 0; // pre:目前前綴的總和
int pbal = 0; // 目前前綴的奇偶差
int allowed = n; // 後綴長度最多只能到 allowed,只會越來越小
for (int i = 0; i <= n; i++) { // 前綴取 a[1..i]
if (pre > k) break; // 前綴自己就超過預算,再長只會更糟
// 太長(會跟前綴重疊)或太貴(超過剩下的預算)的後綴,從各自的桶尾拿掉
while (allowed > 0 && (allowed > n - i || suf[allowed] > k - pre)) {
bucket[sbal[allowed] + OFFSET].pop_back();
allowed--;
}
vector<int> &cand = bucket[OFFSET - pbal]; // 奇偶差要是 -pbal 才抵銷得掉
if (!cand.empty()) best = max(best, pre + suf[cand.back()]); // 桶尾最長=總和最大
if (i < n) {
pre += a[i + 1];
pbal += (a[i + 1] % 2 == 1 ? 1 : -1);
}
}
cout << best << '\n';
return 0;
}
每個長度進桶一次、出桶最多一次,整體 \(O(n)\);\(n = 3 \times 10^5\) 本站實測不到 \(0.1\) 秒。
另一種做法:不用雙指標,直接二分搜
看不出「上限只會變小」也沒關係:桶裡的長度是排序好的、suf 也是遞增的,兩個上限各用一次 upper_bound 找就好(對答案二分搜的工具用法見 NTUCPC Guide〈搜尋〉的「二分搜 in STL」)。先在 suf 裡找「付得起的最長長度」,和 \(n - i\) 取小的當上限,再到桶裡找不超過上限的最後一個。\(O(n \log n)\),一樣是滿分。
#include <bits/stdc++.h>
using namespace std;
const int OFFSET = 4000;
int main() {
int n;
long long k;
cin >> n >> k;
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
vector<long long> suf(n + 1, 0);
vector<int> sbal(n + 1, 0);
vector<vector<int>> bucket(2 * OFFSET + 1);
bucket[OFFSET].push_back(0);
for (int j = 1; j <= n; j++) {
suf[j] = suf[j - 1] + a[n - j + 1];
sbal[j] = sbal[j - 1] + (a[n - j + 1] % 2 == 1 ? 1 : -1);
bucket[sbal[j] + OFFSET].push_back(j);
}
long long best = 0, pre = 0;
int pbal = 0;
for (int i = 0; i <= n; i++) {
if (pre > k) break;
// suf 遞增:第一個「超過剩餘預算」的長度之前,都付得起
int byBudget = upper_bound(suf.begin(), suf.end(), k - pre) - suf.begin() - 1;
int limit = min(n - i, byBudget); // 不重疊、付得起,兩個上限取小的
vector<int> &cand = bucket[OFFSET - pbal];
int pos = upper_bound(cand.begin(), cand.end(), limit) - cand.begin() - 1; // 桶裡 <= limit 的最後一個
if (pos >= 0) best = max(best, pre + suf[cand[pos]]);
if (i < n) {
pre += a[i + 1];
pbal += (a[i + 1] % 2 == 1 ? 1 : -1);
}
}
cout << best << '\n';
return 0;
}
測過再交:範例沒有「只取一邊」的局面
範例 1 的答案兩邊都有取、範例 2 什麼都取不到。沒蓋到的:只取一邊就是最好的、總和剛好等於 \(k\)、前後綴搶同一個數字、同一個桶裡有好幾個候選、後綴的奇偶差超過 \(\pm 2000\)、值域極端。
| 輸入 | 正確輸出 | 這一筆在測什麼 |
|---|---|---|
1 10 / 3 |
0 |
\(n = 1\):一個數不可能奇偶一樣多 |
2 3 / 1 2 |
3 |
總和剛好等於 \(k\),等號要放行 |
3 100 / 1 2 1 |
3 |
前綴 1 2 和後綴 2 1 都想要中間那個 2:重疊了只能挑一邊 |
4 100 / 1 2 7 7 |
3 |
只取前綴最好,後綴一個都不拿 |
4 100 / 7 7 2 1 |
3 |
只取後綴最好 |
3 7 / 2 4 1 |
5 |
差 \(0\) 的桶裡同時有空後綴和 4 1:要拿最長的那個 |
用程式造:\(2000\) 個 1 後面接 \(4000\) 個 2,\(k = 10^9\) |
6000 |
後綴 2 … 2 的奇偶差是 \(-4000\):桶子只開 \(\pm 2000\) 會越界 |
用程式造:\(n = 300000\),4999 與 5000 交錯,\(k = 10^9\) |
999999990 |
值域極端:總和貼近 \(10^9\),而且要在時限內跑完 |
八筆都親眼看過正確,這題就穩了。
常犯錯誤
- 沒檢查重疊(後綴從哪裡開始都可以):兩個範例都剛好過,自測表第 3 列印
6;本站 \(22\) 筆測資掛 \(7\) 筆。 - 總和要「小於」\(k\)(寫成
<):範例過,自測表第 2 列印0;本站掛 \(7\) 筆。 - 桶裡拿第一個(最短的)後綴,不是最後一個:範例過,自測表第 6 列印
3;本站掛 \(6\) 筆。 - 只取前綴(或只取後綴):範例 1 印
10;本站掛 \(9\) 筆。 - 以為兩段都一定要取:自測表第 4、5 列都印
0。 - 桶只開 \(\pm 2000\):自測表第 7 列直接當掉(下標變負數,記憶體越界)。
- 忘了奇偶條件:範例 1 印
22、範例 2 印19。 - 拿 \(20\) 分的雙迴圈跑滿分測資:\(n = 3 \times 10^5\) 一筆 \(36\) 秒,本站 \(11\) 筆超時。