雷射測試 題解|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)。
#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\) 秒),其餘光路短的測資都過——分數看起來很高,但不是滿分。