迷宮探訪 題解|APCS 2022 年 10 月中高級 第 3 題


前置知識

這題的核心(紀錄是怎麼寫出來的、怎麼照同樣的順序讀回來)下面會從頭講;要先會的是這幾件事,同一個主題兩邊都有的,對照著讀會更快上手:

簡潔題意

一座樹狀迷宮:偶數編號的石室有 \(2\) 個出口、奇數編號的有 \(3\) 個,每個出口要嘛通往下一層的一間石室、要嘛是死路。小明從起點出發,到每一間石室先記下編號,再依序走它的每個出口:死路就記一個 \(0\);通往石室就先把那間底下的全部探訪完、回來再看下一個出口。給你這份紀錄,求所有「相連的兩間石室」編號差的絕對值總和(石室不超過 \(10^5\) 間、編號是不超過 \(10^5\) 的相異正整數、最深 \(40\) 層;答案可能超過 \(2^{31}\))。

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

  • 子題組 1(\(20\) 分):\(n \le 20\),編號都是正偶數,起點的所有出口都通往石室、其他石室最多一個出口通往石室。
  • 子題組 2(\(20\) 分):\(n \le 10^3\)。
  • 子題組 3(\(60\) 分):無額外限制。

紀錄是「先自己、再逐個出口」寫出來的,就照同樣的順序讀回來

小明的走法是固定的:進到石室 \(u\) 先記 \(u\),然後第 1 個出口、第 2 個出口(奇數編號還有第 3 個),每個出口要嘛記一個 \(0\)、要嘛記下「那棵子樹的整份紀錄」。所以紀錄裡 \(u\) 後面緊接著的,就是 \(u\) 的第 1 個出口(一個數:\(0\) 或某間石室 \(v\) 的編號);如果是 \(v\),那 \(v\) 後面又緊接著 \(v\) 的出口……要等 \(v\) 底下整棵的紀錄全部結束,下一個數才是 \(u\) 的第 2 個出口。

關鍵在於每間石室有幾個出口,看編號的奇偶就知道,所以讀的人永遠知道「現在這個數是誰的出口」,同一份紀錄不會有第二種讀法(圖 1)。

圖 1:範例 1 的紀錄,括號標出每個 walk(u) 讀掉的區段,相連的石室與差值合計 26

寫成遞迴 walk(u):\(u\) 的編號已經讀過了,接著從紀錄讀 \(u\) 的每一個出口;讀到 \(0\) 就跳過,讀到 \(v\) 就把 \(|u - v|\) 加進答案,再呼叫 walk(v) 把 \(v\) 底下整棵讀完——回來的時候,讀取位置剛好停在 \(u\) 的下一個出口。整份紀錄只有一行、長度不固定,先用 while (cin >> x) 全部讀進陣列,再用一個全域的 cursor 記「下一個還沒讀的位置」。

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

const int MAX_N = 100000;
int record[3 * MAX_N + 5];        // 整份紀錄:石室編號與 0,最多 3n + 1 個數
int total = 0;                    // 紀錄裡有幾個數
int cursor = 0;                   // 下一個還沒讀的位置

long long walk(int u) {           // u 的編號已經讀過了,接著讀它的每一個出口
    int exits = (u % 2 == 0) ? 2 : 3;
    long long sum = 0;
    for (int k = 0; k < exits; k++) {
        int v = record[cursor];       // 這個出口通往哪一間:0 = 死路
        cursor++;
        if (v == 0) continue;
        sum += abs(u - v);            // u 與 v 相連
        sum += walk(v);               // 把 v 底下整棵走完,cursor 會停在 v 的下一個出口
    }
    return sum;
}

int main() {
    int x;
    while (cin >> x) {
        record[total] = x;
        total++;
    }
    cursor = 1;                       // record[0] 是起點,已經「讀過」了
    cout << walk(record[0]) << endl;
    return 0;
}

每個數只會被讀一次,\(O(n)\)。遞迴的深度就是迷宮的層數,題目保證不超過 \(40\)。答案最多 \((n - 1) \times 10^5 \approx 10^{10}\),超過 int,答案與 walk 的回傳值都用 long long(2.9)。紀錄的長度:\(n\) 間石室最多 \(3n\) 個出口,其中 \(n - 1\) 個通往石室、其餘是 \(0\),所以整份紀錄不超過 \(3n + 1\) 個數,陣列開 3 * MAX_N + 5 就夠。

三個子題組用的是同一份程式:子題組 1 的「全部偶數、其他石室最多一個出口」只是讓樹長成起點底下的兩條鏈,沒有比較簡單的讀法,直接寫滿分解,\(100\) 分穩穩到手。

測過再交:範例的答案都很小

兩個範例都有「中間的出口是死路、兩邊的出口通往石室」這種情況,沒蓋到的是:只有起點一間、起點的出口全是死路、起點的第 1 個出口是死路而第 2 個才通往石室,以及編號差很大、答案超過 int 的大迷宮。

輸入 正確輸出 這一筆在測什麼
100000 0 0 0 只有起點,兩個出口都是死路
1 0 0 0 0 奇數編號的起點,三個出口都是死路
3 0 4 0 0 0 1 起點的第 1 個出口是死路、第 2 個通往 \(4\):讀錯「這個數是誰的出口」就會錯
1 2 3 0 0 0 0 0 0 2 奇數底下接偶數、再接奇數:出口數 \(3\)、\(2\)、\(3\) 交替
用程式造:起點編號 \(1\),往下一層全用 \(100000\)、\(99999\)、…,再下一層用 \(2\)、\(3\)、…,一層小一層大交替排滿 \(10^5\) 間(每間的出口數照編號奇偶) 6370390546 答案超過 \(2^{31}\):用 int 會印出錯的數字;\(14\) 層深的遞迴

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

另一種做法:用堆疊,不用遞迴

遞迴版的呼叫堆疊記的其實就是「從起點到目前所在石室的路」。自己用一個堆疊記這條路,每個元素是(石室編號,還剩幾個出口沒看),讀進來的每個數都是堆疊頂端那間石室的下一個出口:讀到 \(v \ne 0\) 就加上 \(|u - v|\)、把 \(v\) 推進去;哪一間的出口都看完了就退出來(可能連退好幾層)。這樣可以邊讀邊算,不必先把整份紀錄存起來。

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

int main() {
    int root;
    cin >> root;
    stack<pair<int, int>> path;       // (石室編號, 還剩幾個出口沒看):從起點到目前所在石室的路
    path.push({root, (root % 2 == 0) ? 2 : 3});
    long long sum = 0;
    int v;
    while (cin >> v) {
        int u = path.top().first;     // 目前所在的石室,v 是它的下一個出口
        path.top().second--;
        if (v != 0) {
            sum += abs(u - v);
            path.push({v, (v % 2 == 0) ? 2 : 3});
        }
        while (!path.empty() && path.top().second == 0) path.pop();   // 出口都看完了,退回上一間
    }
    cout << sum << endl;
    return 0;
}

兩種都是滿分。遞迴版把「讀完一棵子樹回到上一層」交給函式呼叫自動處理,比較短;堆疊版不吃遞迴深度,迷宮再深也不怕(這題保證 \(40\) 層以內,所以沒差)。

常犯錯誤
  • 所有石室都當 \(2\) 個出口(子題組 1 全是偶數,寫完忘了奇數有 \(3\) 個):範例 1 過、範例 2 印 14;計分測資掛 \(14\) 筆。
  • 答案或 walk 的回傳值用 int:兩個範例都過,計分測資掛 \(6\) 筆;自測表第 5 列印 2075423250。
  • 把「上一間」當成紀錄裡前一個非零的數:範例 1 印 22——\(14\) 後面的 \(10\) 不是 \(14\) 的出口,是回到起點之後才看的第 2 個出口。
  • 把 \(0\) 也當成一間編號 \(0\) 的石室(加了 \(|u - 0|\)):範例 1 印 86。
  • 堆疊版出口看完時只退一層(while 寫成 if):範例 1 印 22——\(8\) 的最後一個死路看完要連退兩層回到起點,只退一層會把 \(10\) 當成 \(6\) 的出口。