勇者修煉 題解|APCS 2020 年 10 月高級 第 3 題
前置知識
這題是方格上的動態規劃,難在同一列可以左右走,下面的講解直接把這些當成已知,沒讀過的先讀;同一個主題兩邊都有的,對照著讀會更快上手:
- 最大連續子陣列(Kadane):吳邦一《AP325》第 4 章的 P-4-13(教材 PDF 第 144~145 頁;站上題目 ap325p413)——「以第 \(i\) 格結尾的最大總和,不是接上前一段就是從自己重新開始」。本題子題組 1 就是它,但不能選空的。
- 方格上的 DP:AP325 第 6 章的 P-6-6「方格棋盤路線」(PDF 第 186~187 頁;站上題目 ap325p606)——只能往右、往下時,每一格的答案由左邊與上面的格子決定;NTUCPC Guide〈多個維度的 DP〉講「一個 dp 式不夠就開兩個」——本題每一列要分「從左邊走過來」與「從右邊走過來」兩張表。
- 前綴和:NTUCPC Guide〈前綴和與差分〉——子題組 2 要用它算一列裡任一段的總和。
簡潔題意
\(m\) 列 \(n\) 行的整數方格,從最上面一列任一格出發,每步往左、右或下走一格,不能重複經過格子,走到最下面一列任一格結束,求經過格子(含起點、終點)的數字總和最大是多少(\(1 \le m \le 50\),\(1 \le n \le 10^4\),每格介於 \(-100\) 與 \(100\))。
依正確通過的測試資料筆數給分,其中:
- 子題組 1(\(20\) 分):\(m = 1\),\(n \le 100\)。
- 子題組 2(\(30\) 分):\(n \le 100\)。
- 子題組 3(\(50\) 分):無額外限制。
先拿下 20 分:只有一列就是最大連續子陣列
只有一列時,路線就是這一列裡連續的一段(往左或往右走,不能回頭,不然會重複經過),而且起點本身一定要算,不能什麼都不選。令 endHere = 以目前這格結尾的最大總和:前面那段總和是正的就接上去,否則從這格重新開始;所有 endHere 的最大值就是答案。
#include <bits/stdc++.h>
using namespace std;
int main() {
int m, n;
cin >> m >> n; // 子題組 1:m = 1
int best = -1000000000, endHere = 0; // endHere:以目前這格結尾的最大總和
for (int j = 0; j < n; j++) {
int x;
cin >> x;
if (endHere > 0) endHere += x; // 前面那段是正的就接下去
else endHere = x; // 不然從這格重新開始(至少要走這一格)
best = max(best, endHere);
}
cout << best << '\n';
return 0;
}
範例就是一列:\(2 + 1 + 4 = 7\)。全是負數時答案是最大的那一格(負的),best 的初始值要夠小,不能用 \(0\)。這份程式只讀第一列,\(m > 1\) 的測資會錯,子題組 1 的 \(20\) 分穩穩到手。
路線長什麼樣:每一列只走一段,兩端分別是「進來」與「離開」的格子
不能往上走,所以路線由上往下一列一列經過;在同一列裡不能先往左再往右(會重複經過格子),所以每一列走的是連續的一段,而且只朝一個方向:從上面一列在第 \(enter\) 行走進來,橫著走到第 \(leave\) 行,再往下走到下一列的第 \(leave\) 行;第一列的 \(enter\) 是起點,最後一列的 \(leave\) 是終點。圖 1 的路線在第 1 列往右走、第 2 列不橫走、第 3 列往左走。
所以只要記住「走完某一列、停在第 \(j\) 行準備往下」的最大得分 \(best[j]\),下一列的每個 \(leave\) 就能從上一列的每個 \(enter\) 接過來。
從 20 分到 50 分:枚舉進來與離開的行,用前綴和算那一段
對每一列,算這一列的前綴和 prefix(prefix[k] = 前 \(k\) 格的和),那一段 \([\min(enter, leave), \max(enter, leave)]\) 的總和就是兩個前綴和相減。新的 \(cur[leave]\) = 所有 \(enter\) 當中 prev[enter] + 那一段的和 的最大值。第一列之前沒有任何得分,prev 全設 \(0\) 代表起點任選;之後每一列的 prev 都是真的走過來的分數,負的也要照實留著。
#include <bits/stdc++.h>
using namespace std;
int main() {
int m, n;
cin >> m >> n;
vector<int> prev(n, 0); // prev[j]:上一列在第 j 行往下走時的最大得分;第一列之前全是 0
for (int i = 0; i < m; i++) {
vector<int> a(n), prefix(n + 1, 0);
for (int j = 0; j < n; j++) {
cin >> a[j];
prefix[j + 1] = prefix[j] + a[j]; // prefix[k]:這一列前 k 格的和
}
vector<int> cur(n, -1000000000); // cur[j]:這一列在第 j 行往下走(或結束)時的最大得分
for (int enter = 0; enter < n; enter++) // 從上一列的第 enter 行進來
for (int leave = 0; leave < n; leave++) { // 橫著走到第 leave 行
int lo = min(enter, leave), hi = max(enter, leave);
cur[leave] = max(cur[leave], prev[enter] + prefix[hi + 1] - prefix[lo]);
}
prev = cur;
}
cout << *max_element(prev.begin(), prev.end()) << '\n';
return 0;
}
每列 \(O(n^2)\)、共 \(O(mn^2)\),\(n \le 100\) 時瞬間跑完;\(n = 10^4\) 時是 \(50 \times 10^8\) 次,本站實測 \(6.2\) 秒超時,子題組 1、2 的 \(50\) 分穩穩到手。
從 50 分到 100 分:左右各掃一遍,不用枚舉進來的行
\(cur[leave]\) 要看所有 \(enter\),太慢的原因是每一格都從頭算整段。換個角度:路線走到這一列的第 \(j\) 行時,最後一步只有三種——從上面進來(prev[j])、從左邊那格走過來、從右邊那格走過來。但「從左邊走過來」和「從右邊走過來」不能混在同一張表裡,否則會算出先往左再往右的路線(重複經過格子)。所以開兩張表:
fromLeft[j]= 走到第 \(j\) 行、而且這一列只往右走的最大得分 = \(\max(\text{prev}[j], \text{fromLeft}[j - 1]) + a[j]\),由左往右算。fromRight[j]= 走到第 \(j\) 行、而且這一列只往左走的最大得分 = \(\max(\text{prev}[j], \text{fromRight}[j + 1]) + a[j]\),由右往左算。
這一列在第 \(j\) 行往下走的最大得分就是兩者取大。fromLeft[j] 只接受「從上面進來」或「從左邊同方向走過來」,往右走的路線到第 \(j\) 行時的每一種進法都被算到,又不可能回頭。圖 1 下半的表就是這樣一列一列算出來的,最後一列的最大值 \(16\) 是答案。
#include <bits/stdc++.h>
using namespace std;
int main() {
int m, n;
cin >> m >> n;
vector<int> prev(n, 0); // prev[j]:上一列在第 j 行往下走時的最大得分;第一列之前全是 0
for (int i = 0; i < m; i++) {
vector<int> a(n), fromLeft(n), fromRight(n);
for (int j = 0; j < n; j++) cin >> a[j];
for (int j = 0; j < n; j++) { // fromLeft[j]:最後一步是「從上面進來」或「從左邊走過來」
fromLeft[j] = prev[j];
if (j > 0) fromLeft[j] = max(fromLeft[j], fromLeft[j - 1]);
fromLeft[j] += a[j];
}
for (int j = n - 1; j >= 0; j--) { // fromRight[j]:最後一步是「從上面進來」或「從右邊走過來」
fromRight[j] = prev[j];
if (j < n - 1) fromRight[j] = max(fromRight[j], fromRight[j + 1]);
fromRight[j] += a[j];
}
for (int j = 0; j < n; j++) prev[j] = max(fromLeft[j], fromRight[j]);
}
cout << *max_element(prev.begin(), prev.end()) << '\n';
return 0;
}
每列三趟 \(O(n)\),總共 \(O(mn)\),剛才 \(6.2\) 秒的測資本站實測 \(0.07\) 秒。得分最多 \(50 \times 10^4 \times 100 = 5 \times 10^7\)、最少 \(50 \times (-100) = -5000\)(\(n = 1\) 時被迫一路往下),int 都裝得下。
測過再交:範例只有一列、答案是正的
範例是 \(m = 1\),什麼陷阱都沒有。沒蓋到:答案是負的(不能什麼都不走)、要往左走才拿得到的分數、同一列不能來回、以及起點不一定在最左邊。
| 輸入 | 正確輸出 | 這一筆在測什麼 |
|---|---|---|
1 1 / -5 |
-5 |
只有一格:一定要走,答案是負的;允許「空路線」會印 0 |
2 1 / -100 / -100 |
-200 |
只有一行:被迫一路往下;每一列各自歸零會印 0 |
2 2 / 1 -5 / -5 1 |
-3 |
兩列都得走:從 \(1\) 往下再往右,\(1 - 5 + 1\) |
2 3 / 1 1 100 / 100 -1 -1 |
202 |
第 2 列要往左走才拿得到 \(100\):只允許往右走會印 101;同一列來回會印 303 |
2 3 / 0 0 0 / 5 -1 5 |
9 |
第 2 列只能走一段:兩個 \(5\) 要一起拿就得經過 \(-1\);同一列來回(兩個 \(5\) 都拿、\(-1\) 只算一次)會印 13 |
3 5 / 3 -2 4 -1 2 / -5 1 -3 6 -4 / 2 -6 5 1 -2 |
16 |
圖 1 的例子:第 1 列往右、第 3 列往左;每一列各自取最大連續和相加會印 18 |
2 3 / -1 -1 -1 / -1 -1 -1 |
-2 |
全是負數:直接往下走兩格最少賠 |
| 用程式造:\(50 \times 10000\) 全是 \(100\) | 50000000 |
答案最大:蛇行走完全部格子,int 裝得下 |
| 用程式造:\(50 \times 1\) 全是 \(-100\) | -5000 |
答案最小:允許「空路線」會印 0 |
九筆都親眼看過正確,這題就穩了。
常犯錯誤
- 允許什麼都不走(最後跟 \(0\) 取大):答案全是負數時印
0;範例全過,本站測資掛 \(2\) 筆。 - 每一列只允許往右走:範例剛好往右就是最好的;自測表第 4 列印
101,本站測資掛 \(10\) 筆。 - 兩個方向混在同一張表(先由左往右算,再由右往左用同一張表更新):會算出同一列先往左再往右的路線,重複拿分;範例印
10,本站測資掛 \(10\) 筆。 - 每一列各自做最大連續子陣列再相加:列與列之間必須在同一行接上;自測表第 6 列印
18,本站測資掛 \(7\) 筆。 - 換列時把負的得分歸零:只有第一列之前可以是 \(0\)(起點任選),之後的負分是真的賠掉;自測表第 2 列印
0,本站測資掛 \(3\) 筆。 - 起點只放在最左邊那一行:起點是最上面一列的任一格;本站測資掛 \(6\) 筆。