磁軌移動序列 題解|APCS 2023 年 6 月中高級 第 3 題
前置知識
這題的核心推導(一段指令只要記三個數)下面會從頭講;要先會的是這幾件事,同一個主題兩邊都有的,對照著讀會更快上手:
- 堆疊:NTUCPC Guide〈Stack、Queue 與 Deque〉;吳邦一《AP325》第 3 章的堆疊一節與例題 P-3-2 括弧配對(教材 PDF 第 85~93 頁;Python 版第 3 章)——要帶走的是「遇到
L就推一層、遇到E就把最上面那一層收掉」,跟括弧配對是同一件事。 - 遞迴:NTUCPC Guide〈遞迴〉——\(40\) 分的「照字面執行」版會用到。
- 結構體:把「一段指令的摘要」三個數綁成一個
struct。
簡潔題意
指針一開始在位置 \(10\)。指令 Txx 把指針移到位置 \(xx\)(兩位數,\(10 \sim 99\)),移動距離是兩個位置的差的絕對值;Lx...E 把中間的指令依序執行 \(x\) 次(\(1 \sim 9\)),可以巢狀,每個 loop 裡至少有一個 T。輸入字串以 T10 開頭、長度不超過 \(10^5\),求總移動距離(保證不超過 \(2^{60}\))。
依正確通過的測試資料筆數給分,其中:
- 子題組 1(\(10\) 分):沒有 loop,長度不超過 \(50\)。
- 子題組 2(\(30\) 分):長度不超過 \(10^4\),loop 全部展開後的
T指令數不超過 \(3 \times 10^4\)。 - 子題組 3(\(60\) 分):無額外限制。
先拿下 10 分:沒有 loop
字串每三個字元就是一個 Txx:第 \(i\) 個字元是 T、後面兩個字元是兩位數。記住目前位置 cur,每讀到一個目標就把距離加進去、再把 cur 搬過去。
#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
cin >> s;
int cur = 10; // 指針目前的位置
long long total = 0;
for (int i = 0; i < (int)s.size(); i += 3) { // 每個 Txx 占三個字元
int target = (s[i + 1] - '0') * 10 + (s[i + 2] - '0');
total += abs(cur - target);
cur = target;
}
cout << total << endl;
return 0;
}
再拿 30 分:照字面把 loop 跑出來
子題組 2 保證展開後的 T 不超過 \(3 \times 10^4\) 個,那就真的把 loop 跑出來。先用堆疊做一次「括弧配對」:由左到右掃,遇到 L 把位置推進堆疊,遇到 E 就彈出最上面的 L——這一對就配好了,記在 matchE[]。然後寫一個遞迴函式 run(l, r) 執行 \(s[l..r]\) 這一段:碰到 T 就移動;碰到 L,把它到配對的 E 之間那一段用 run 跑 \(x\) 次,再跳到 E 後面繼續。
#include <bits/stdc++.h>
using namespace std;
const int MAX_LEN = 100000;
string s;
int matchE[MAX_LEN]; // matchE[i] = 位置 i 的 L 所配對的 E 在哪
int cur = 10; // 指針目前的位置
long long total = 0;
void run(int l, int r) { // 執行 s[l..r] 這一段指令
int i = l;
while (i <= r) {
if (s[i] == 'T') {
int target = (s[i + 1] - '0') * 10 + (s[i + 2] - '0');
total += abs(cur - target);
cur = target;
i += 3;
} else { // s[i] == 'L'
int times = s[i + 1] - '0';
for (int t = 0; t < times; t++) run(i + 2, matchE[i] - 1); // 迴圈內容真的跑 times 次
i = matchE[i] + 1; // 跳到 E 的下一個字元
}
}
}
int main() {
cin >> s;
stack<int> opened; // 括號配對:找出每個 L 的 E
for (int i = 0; i < (int)s.size(); i++) {
if (s[i] == 'L') {
opened.push(i);
} else if (s[i] == 'E') {
matchE[opened.top()] = i;
opened.pop();
}
}
run(0, (int)s.size() - 1);
cout << total << endl;
return 0;
}
這份程式沒有 loop 時就是上一節的程式,所以子題組 1、2 的 \(40\) 分穩穩到手。它的問題是巢狀:L9 裡面再包 L9,每多一層就多九倍,九層就是 \(9^9 \approx 3.9 \times 10^8\) 次移動;本站子題組 3 有三筆這種測資,這份程式跑不完(超時)。要拿滿分,不能真的把 loop 跑出來。
從 40 分到 100 分:一段指令只要記三個數
想想「loop 的內容跑 \(x\) 次」到底多走了什麼。內容跑一次,指針從內容的第一個目標一路走到最後一個目標,中間走的距離是固定的;跑第二次時,指針要先從上一輪停的位置回到內容的第一個目標,再走一次同樣的路。所以一段指令對外面來說,只有三個數有意義(圖 1):
first:這段的第一個目標(外面進來要先走到這裡)。last:這段跑完停在哪裡。inner:這段內部走的總距離。
兩個規則就能把任何指令串算完:
- 把兩段接起來(\(a\) 後面接 \(b\)):
first是 \(a\) 的、last是 \(b\) 的,inner多一段從 \(a\) 的終點走到 \(b\) 的起點:\(a.\mathrm{inner} + |a.\mathrm{last} - b.\mathrm{first}| + b.\mathrm{inner}\)。 - 一段重複 \(x\) 次:
first、last都不變,inner變成 \(x \times \mathrm{inner} + (x - 1) \times |\mathrm{last} - \mathrm{first}|\)——每一輪都走一次內部距離,相鄰兩輪之間還要從last回到first,共 \(x - 1\) 次。
一個 Txx 自己就是一段:first = last = xx、inner = 0。接下來只要由左到右掃字串,用堆疊記「每一層 loop 目前累積的摘要」:碰到 T 就接到最上層;碰到 L 推一個空的新層;碰到 E 把最上層拿出來、重複 \(x\) 次、接回上一層。最外層一開始放 (10, 10, 0)——指針本來就在 \(10\),當成已經有一個 T10——掃完後最外層的 inner 就是答案。
#include <bits/stdc++.h>
using namespace std;
struct Summary {
bool empty; // 這段裡還沒有任何 T
int first, last; // 這段的第一個目標、最後停的位置
long long inner; // 這段內部走的距離
};
Summary join(Summary a, Summary b) { // 把 b 接在 a 後面
if (a.empty) return b;
Summary c;
c.empty = false;
c.first = a.first;
c.last = b.last;
c.inner = a.inner + abs(a.last - b.first) + b.inner; // 多走一段:a 的終點到 b 的起點
return c;
}
Summary repeat(Summary a, int times) { // 把一段重複 times 次
a.inner = a.inner * times + (long long)(times - 1) * abs(a.last - a.first);
return a; // first、last 都不變
}
int main() {
string s;
cin >> s;
stack<Summary> seg; // 每一層 loop 目前累積的摘要
stack<int> times; // 每一層 loop 要重複幾次
seg.push({false, 10, 10, 0}); // 最外層:指針一開始就在 10,當成已經有一個 T10
times.push(1);
for (int i = 0; i < (int)s.size();) {
if (s[i] == 'T') {
int x = (s[i + 1] - '0') * 10 + (s[i + 2] - '0');
seg.top() = join(seg.top(), {false, x, x, 0});
i += 3;
} else if (s[i] == 'L') {
seg.push({true, 0, 0, 0}); // 新的一層,還是空的
times.push(s[i + 1] - '0');
i += 2;
} else { // 'E':這一層結束,整段重複後接回上一層
Summary body = repeat(seg.top(), times.top());
seg.pop();
times.pop();
seg.top() = join(seg.top(), body);
i += 1;
}
}
cout << seg.top().inner << endl;
return 0;
}
每個字元只看一次,每一層的摘要只進出堆疊一次,\(O(|s|)\)。inner 用 long long:每一層算出來的 inner 都是最後答案的一部分,題目保證答案不超過 \(2^{60}\),所以中間值也不會超過,long long 夠用。
測過再交:範例的 loop 都是「直接寫 T」開頭
範例 2 的兩個 loop,內容都是以直接寫在那一層的 T 開頭、也以 T 結尾。沒蓋到的:只跑一次的 L1、整個內容只有一個位置(相鄰兩輪之間不用走)、內容以另一個 loop 開頭(first 要從內層拿)、原地不動的 T,以及真的不能展開的深層巢狀。
| 輸入 | 正確輸出 | 這一筆在測什麼 |
|---|---|---|
T10L1T20E |
10 |
L1:跑一次就是沒有 loop,\(x - 1 = 0\) |
T10L3T20E |
10 |
內容只有一個位置:三輪之間 last 和 first 都是 \(20\),一步都不用走 |
T10L2T20T10E |
40 |
兩輪之間要從 \(10\) 回到 \(20\):\(2 \times 10 + 1 \times 10\),再加上一開始的 \(10 \to 20\) |
T10T30L2L2T20ET50E |
120 |
loop 內容以內層 loop 開頭:外層的 first 是內層給的 \(20\),不是後面那個 T50 |
T10T10T10 |
0 |
原地不動:距離 \(0\),不是負數也不會當掉 |
用程式造:T10 接 \(15\) 層 L9、裡面 T99T10、再 \(15\) 個 E |
36648621512847522 |
展開是 \(9^{15} \times 2\) 個 T,照字面跑不完;答案約 \(3.7 \times 10^{16}\),驗 long long |
六筆都親眼看過正確,這題就穩了。
常犯錯誤
- 真的把 loop 跑出來(\(40\) 分版硬交):兩個範例都過,子題組 3 有三筆超時——九層
L9要跑 \(9^9\) 次。 - 重複 \(x\) 次只把
inner乘 \(x\),忘了相鄰兩輪之間要從last回到first:範例 2 印70(少了 \(8\));自測表第 3 列印30。 first、last只看直接寫在這一層的T,沒把內層 loop 的算進來:兩個範例都過、計分測資掛 \(4\) 筆;自測表第 4 列印80。- loop 只執行一次(忘了乘次數):範例 2 印
48。 - 對 \(10^9 + 7\) 取餘數:題目要的是精確值——兩個範例都過,答案超過 \(10^9 + 7\) 的兩筆掛掉。
- 距離用
int:同樣那兩筆溢位;自測表第 6 列印583528610。