雷射測試 題解|APCS 2022 年 6 月中高級 第 3 題


前置知識

這題的核心(怎麼不用每次掃過所有鏡子就知道下一面是誰)下面會從頭講;要先會的是:

  • 結構體與自訂比較函式的排序:struct 搭配自定義比較函式——「先比 \(y\)、\(y\) 一樣再比 \(x\)」這種兩層的排序條件要自己寫比較函式;排序本身見 NTUCPC Guide〈排序演算法〉。
  • 用陣列當查表:下面會把「碰到哪種鏡子、往哪個方向、之後變成哪個方向」寫成兩個長度 \(4\) 的陣列直接查,不用八個 if。

簡潔題意

平面上有 \(n\) 面點狀的鏡子,第 \(i\) 面在 \((x_i, y_i)\),種類 \(t_i = 0\) 是 /、\(t_i = 1\) 是 \,位置互不相同。雷射從 \((0, 0)\) 往右射出,碰到前方最近的一面鏡子就依鏡面轉 \(90\) 度繼續走,前方沒有鏡子就結束。問總共反射幾次——同一面鏡子被碰到第二次也要再算一次(\(1 \le n \le 2.5 \times 10^5\),\(1 \le x_i \le 30000\),\(-30000 \le y_i \le 30000\))。

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

  • 子題組 1(\(20\) 分):\(n \le 1000\),\(x_i \le 100\),\(|y_i| \le 100\)。
  • 子題組 2(\(80\) 分):無額外限制。

方向與反射:先把規則寫成表

方向編成 \(0\) 右、\(1\) 上、\(2\) 左、\(3\) 下。鏡子 / 是左下到右上的一條線:往右走碰到它會轉成往上、往上走變往右、往左走變往下、往下走變往左。鏡子 \ 是左上到右下:往右變往下、往下變往右、往左變往上、往上變往左。把它們寫成兩個陣列,新方向就是「用舊方向查表」:

  • SLASH[4] = {1, 0, 3, 2}:碰到 /,右→上、上→右、左→下、下→左。
  • BACKSLASH[4] = {3, 2, 1, 0}:碰到 \,右→下、上→左、左→上、下→右。

另外兩件事先確定:(一)第一面鏡子是 \(y = 0\) 這一列上 \(x\) 最小的那面(雷射從 \(x = 0\) 往右走,所有鏡子的 \(x \ge 1\)),這一列沒有鏡子答案就是 \(0\);(二)光路不會無限循環——每一步倒過來走都是唯一的,從外面射進來的光如果走進一個循環,循環裡某一步就會有兩個「上一步」(從循環來、從外面來),矛盾。所以每面鏡子從每個方向最多被碰到一次,反射次數不超過 \(4n\)。

先拿下 20 分:每次掃過所有鏡子找最近的

照字面做:記住目前位置和方向,掃過所有鏡子,挑出「在同一條線上、在前方、距離最近」的那面,走過去、查表換方向、次數加一;找不到就結束。

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

const int MAX_N = 250000;
int x[MAX_N], y[MAX_N], type[MAX_N];
const int DX[4] = {1, 0, -1, 0};             // 方向 0 右、1 上、2 左、3 下
const int DY[4] = {0, 1, 0, -1};
const int SLASH[4] = {1, 0, 3, 2};           // 碰到 /:右變上、上變右、左變下、下變左
const int BACKSLASH[4] = {3, 2, 1, 0};       // 碰到 \:右變下、下變右、左變上、上變左

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) cin >> x[i] >> y[i] >> type[i];
    int cx = 0, cy = 0, dir = 0;             // 目前位置與方向
    int count = 0;
    while (true) {
        int best = -1, bestDist = 0;
        for (int i = 0; i < n; i++) {        // 掃過所有鏡子,找「在前方」且最近的
            int dist = (x[i] - cx) * DX[dir] + (y[i] - cy) * DY[dir];   // 沿前進方向的距離
            bool sameLine = (DX[dir] != 0) ? (y[i] == cy) : (x[i] == cx);
            if (sameLine && dist > 0 && (best == -1 || dist < bestDist)) {
                best = i;
                bestDist = dist;
            }
        }
        if (best == -1) break;               // 前方沒有鏡子,結束
        count++;
        cx = x[best];
        cy = y[best];
        if (type[best] == 0) dir = SLASH[dir];
        else dir = BACKSLASH[dir];
    }
    cout << count << endl;
    return 0;
}

每次反射掃 \(n\) 面鏡子,反射最多 \(4n\) 次,最壞 \(O(n^2)\):\(n = 1000\) 是 \(4 \times 10^6\),一瞬間;\(n = 2.5 \times 10^5\) 而且光路很長時,本站實測 \(18\) 秒,時限 \(2\) 秒。子題組 1 的 \(20\) 分穩穩到手。

從 20 分到 100 分:排序一次,鄰居就定了

慢的只有「找前方最近的鏡子」。光只會沿著水平線或鉛直線走,所以「從鏡子 \(i\) 往右碰到的下一面」就是同一列(\(y\) 相同)裡 \(x\) 比它大的最小那面——把所有鏡子照(\(y\), \(x\))排序,同一列的鏡子會排在一起、而且照 \(x\) 遞增,排序後相鄰的兩面就互為左右鄰居。再照(\(x\), \(y\))排一次,就得到上下鄰居。兩次排序把每面鏡子四個方向的下一面 nxt[i][d] 都填好,之後光怎麼走都是查表,一次 \(O(1)\)(圖 1)。

圖 1:範例 1 的光路,從 (0, 0) 向右射出,共反射 9 次,(4, 2) 那面被碰到兩次
#include <bits/stdc++.h>
using namespace std;

const int MAX_N = 250000;
const int SLASH[4] = {1, 0, 3, 2};           // 碰到 /:右變上、上變右、左變下、下變左
const int BACKSLASH[4] = {3, 2, 1, 0};       // 碰到 \:右變下、下變右、左變上、上變左

struct Mirror {
    int x, y, type;
};
Mirror mir[MAX_N];
int nxt[MAX_N][4];                           // nxt[i][d] = 從第 i 面鏡子往方向 d 走,碰到的下一面鏡子(-1 = 沒有)
int order[MAX_N];

bool byRow(int a, int b) {                   // 同一列(y 相同)的鏡子照 x 排在一起
    if (mir[a].y != mir[b].y) return mir[a].y < mir[b].y;
    return mir[a].x < mir[b].x;
}
bool byColumn(int a, int b) {                // 同一行(x 相同)的鏡子照 y 排在一起
    if (mir[a].x != mir[b].x) return mir[a].x < mir[b].x;
    return mir[a].y < mir[b].y;
}

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        cin >> mir[i].x >> mir[i].y >> mir[i].type;
        order[i] = i;
        for (int d = 0; d < 4; d++) nxt[i][d] = -1;
    }
    sort(order, order + n, byRow);
    for (int k = 0; k + 1 < n; k++) {
        int a = order[k], b = order[k + 1];
        if (mir[a].y == mir[b].y) {          // 同一列裡相鄰的兩面:互為左右鄰居
            nxt[a][0] = b;                   // a 往右碰到 b
            nxt[b][2] = a;                   // b 往左碰到 a
        }
    }
    sort(order, order + n, byColumn);
    for (int k = 0; k + 1 < n; k++) {
        int a = order[k], b = order[k + 1];
        if (mir[a].x == mir[b].x) {          // 同一行裡相鄰的兩面:互為上下鄰居
            nxt[a][1] = b;                   // a 往上碰到 b
            nxt[b][3] = a;                   // b 往下碰到 a
        }
    }
    int cur = -1;                            // 第一面:y = 0 上 x 最小的那面
    for (int i = 0; i < n; i++) {
        if (mir[i].y == 0 && (cur == -1 || mir[i].x < mir[cur].x)) cur = i;
    }
    int dir = 0, count = 0;
    while (cur != -1) {
        count++;
        if (mir[cur].type == 0) dir = SLASH[dir];
        else dir = BACKSLASH[dir];
        cur = nxt[cur][dir];
    }
    cout << count << endl;
    return 0;
}

排序兩次 \(O(n \log n)\),走光路最多 \(4n\) 步,最大測資本站實測 \(0.2\) 秒。排序的是鏡子的編號(order[]),比較函式去 mir[] 裡看座標,這樣排完才知道「相鄰的是第幾面」。

測過再交:範例的 \(y = 0\) 上只有一面鏡子

範例 1 已經蓋到「同一面鏡子被碰兩次」(\((4, 2)\) 碰了兩次),範例 2 蓋到「\(y = 0\) 上沒有鏡子」;沒蓋到的是:只有一面鏡子、\(y = 0\) 上有好幾面(要取 \(x\) 最小的)、光路跑到 \(y < 0\) 再繞回來碰同一面鏡子、同一列的鏡子輸入順序和 \(x\) 順序相反(驗排序有照 \(x\))、以及最大的 \(n\) 配很長的光路(驗速度)。

輸入 正確輸出 這一筆在測什麼
1/1 0 0 1 只有一面鏡子:碰一次就離開
3/5 0 1/2 0 0/2 3 1 2 \(y = 0\) 上有兩面:先碰 \(x\) 最小的 \((2, 0)\),往上碰 \((2, 3)\) 再往左離開;先碰 \((5, 0)\) 的話只有 \(1\) 次
5/3 0 0/3 2 1/1 2 0/1 -1 1/3 -1 0 6 光路繞到 \(y = -1\) 再從下方回到 \((3, 0)\):同一面鏡子從不同方向碰兩次
3/4 0 0/4 2 1/1 2 0 3 同一列的 \((4, 2)\) 先讀、\((1, 2)\) 後讀:往左走要碰到 \(x\) 比較小的 \((1, 2)\)
用程式造:\(n = 250000\),\(x = 1\) 到 \(30000\) 每行在 \(y = 0\)、\(y = 1\) 各放一面排成蛇行,其餘鏡子放在 \(y \ge 100\) 的地方 60000 最大測資:光路經過 \(60000\) 面鏡子,掃描版會超時

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

常犯錯誤
  • / 和 \ 的轉向規則對調:範例 1 自己就會抓到——印 1,正確是 9。
  • 第一面鏡子取 \(y = 0\) 上「第一個讀到的」而不是 \(x\) 最小的:兩個範例都過,計分測資掛 \(10\) 筆;自測表第 2 列印 1。
  • 同一面鏡子只算一次(碰過就停):範例 1 印 8,正確是 9。
  • 同一列只照 \(y\) 排、沒有再照 \(x\) 排:鄰居會照輸入順序亂接——範例 1 印 4,正確是 9;自測表第 4 列印 2。
  • 每次掃過所有鏡子的 \(20\) 分版硬交:光路長的那筆超時(\(18\) 秒),其餘光路短的測資都過——分數看起來很高,但不是滿分。