邏輯電路 題解|APCS 2024 年 1 月中高級 第 3 題
前置知識
這題的核心推導(一個元件要等它的輸入都算好才能算)下面會從頭講;要先會的是這幾件事,同一個主題兩邊都有的,對照著讀會更快上手:
- 圖的儲存:NTUCPC Guide〈圖論基礎〉——鄰接串列
vector<int> out[MAX_V],每條有向邊 \(a \to b\) 記在out[a];吳邦一《AP325》第 7 章 7.1、7.2 講同樣的事(Python 版第 7 章(I))。 - 拓撲排序:NTUCPC Guide〈拓撲排序〉——「沒有環的有向圖,一定能排出一個順序讓每條邊都由前指向後」,用「入度歸零就進佇列」的方法找出來;《AP325》7.2.4「DAG 與 topological sort」(教材 PDF 第 246~248 頁;Python 版第 7 章(II)),習題 Q-7-7 AOV 最早完工時間 和本題的「最大延遲」幾乎是同一件事。
簡潔題意
\(p\) 個輸入端口(編號 \(1 \sim p\),值 \(0\) 或 \(1\))、\(q\) 個邏輯閘(編號 \(p + 1 \sim p + q\);AND、OR、XOR 各接兩條輸入線,NOT 接一條)、\(r\) 個輸出端口(編號 \(p + q + 1 \sim p + q + r\),各接一條輸入線),\(m\) 條有方向的連接線 \(a \to b\)。電路沒有迴路。求每個輸出端口的值,以及最大延遲:所有「輸入端口到輸出端口」的路徑中,經過邏輯閘數量的最大值(\(p, r \le 10^3\),\(q \le 5 \times 10^4\),每個端口或閘的輸出最多接 \(20\) 條線,最大延遲不超過 \(100\))。
依正確通過的測試資料筆數給分,其中:
- 子題組 1(\(40\) 分):\(p + q + r \le 10^3\)。
- 子題組 2(\(60\) 分):無額外限制。
每個元件的值與延遲:輸入都算好了才能算
一個邏輯閘的值由它的輸入決定,所以要先算好它的輸入;輸入可能是端口(值直接給),也可能是另一個閘(又要先算它的輸入)。延遲也一樣:到某個閘的最長路徑,就是「它每個輸入的延遲取最大,再加一」(經過它自己);輸出端口不是閘,延遲照抄它那條輸入線來源的延遲、不加一;輸入端口延遲 \(0\)(圖 1)。
所以整題只有一個問題:用什麼順序算,才能保證算到一個元件時它的輸入都已經算好。題目沒說編號小的一定在前面——範例 2 就有 \(7 \to 6\) 這條線,閘 \(7\) 得先算。
讀入時每條線 \(a \to b\) 記兩件事:in[b] 裡多一個 \(a\)(算 \(b\) 時要用到的輸入),out[a] 裡多一個 \(b\)(\(a\) 算好要通知誰)。閘的運算用 if 寫就好:AND 是「兩個都是 \(1\)」、OR 是「至少一個是 \(1\)」、XOR 是「兩個不一樣」、NOT 是 \(1 - a\)——NOT 只有一條輸入線,in[u][1] 不存在,別去讀它。
先拿下 40 分:反覆找「輸入都算好」的元件
最直接的做法:一輪一輪來,每一輪從頭掃所有還沒算的閘和輸出端口,找到第一個「所有輸入都算好了」的就算它、結束這一輪。電路沒有迴路,所以只要還有東西沒算,就一定找得到一個可以算的,每一輪至少算完一個。
#include <bits/stdc++.h>
using namespace std;
const int MAX_V = 52000;
int p, q, r, m;
int val[MAX_V + 1], typ[MAX_V + 1], delay[MAX_V + 1];
bool done[MAX_V + 1]; // 這個元件的值算好了沒
vector<int> in[MAX_V + 1]; // in[b] = 接進 b 的所有元件
void compute(int u) { // u 的輸入都算好了,算出 u 的值與延遲
int a = val[in[u][0]];
if (u <= p + q) { // 邏輯閘
if (typ[u] == 1) val[u] = (a == 1 && val[in[u][1]] == 1);
else if (typ[u] == 2) val[u] = (a == 1 || val[in[u][1]] == 1);
else if (typ[u] == 3) val[u] = (a != val[in[u][1]]);
else val[u] = 1 - a; // NOT 只有一個輸入
delay[u] = 0;
for (int x : in[u]) delay[u] = max(delay[u], delay[x]);
delay[u]++; // 經過這個閘,多一層
} else { // 輸出端口:照抄,不算延遲
val[u] = a;
delay[u] = delay[in[u][0]];
}
done[u] = true;
}
int main() {
cin >> p >> q >> r >> m;
int total = p + q + r;
for (int i = 1; i <= p; i++) {
cin >> val[i];
done[i] = true; // 輸入端口的值是給定的
}
for (int i = p + 1; i <= p + q; i++) cin >> typ[i];
for (int k = 0; k < m; k++) {
int a, b;
cin >> a >> b;
in[b].push_back(a);
}
for (int round = 0; round < q + r; round++) { // 每一輪找一個「輸入都算好了」的元件
for (int u = p + 1; u <= total; u++) {
if (done[u]) continue;
bool ready = true;
for (int x : in[u]) {
if (!done[x]) ready = false;
}
if (ready) {
compute(u);
break;
}
}
}
int answer = 0;
for (int u = p + q + 1; u <= total; u++) answer = max(answer, delay[u]);
cout << answer << endl;
for (int u = p + q + 1; u <= total; u++) {
cout << val[u];
if (u < total) cout << " ";
}
cout << endl;
return 0;
}
\(q + r\) 輪、每輪最多掃 \(q + r\) 個元件,\(O((q + r)^2)\):\(p + q + r \le 1000\) 時不到 \(10^6\),子題組 1 的 \(40\) 分穩穩到手。\(q = 5 \times 10^4\) 時是 \(2.5 \times 10^9\),編號故意倒著給的測資本站實測 \(3.6\) 秒,超過時限。
從 40 分到 100 分:拓撲排序
慢在每一輪都重頭掃。換個角度:幫每個元件記「還有幾個輸入沒算好」(waiting[u],讀邊時每條線 \(a \to b\) 讓 waiting[b] 加一)。輸入端口不用等任何人,先全部進佇列;每算好一個元件 \(u\),就通知 out[u] 裡的每個 \(v\) 「你又少等一個」,waiting[v] 歸零的那一刻把 \(v\) 推進佇列。從佇列拿出來的元件保證輸入都算好了,直接算。這就是拓撲排序:每個元件恰好進出佇列一次、每條線恰好被通知一次。
#include <bits/stdc++.h>
using namespace std;
const int MAX_V = 52000;
int p, q, r, m;
int val[MAX_V + 1], typ[MAX_V + 1], delay[MAX_V + 1];
int waiting[MAX_V + 1]; // 還有幾個輸入沒算好
vector<int> in[MAX_V + 1]; // in[b] = 接進 b 的所有元件
vector<int> out[MAX_V + 1]; // out[a] = a 的輸出接到哪些元件
void compute(int u) { // u 的輸入都算好了,算出 u 的值與延遲
int a = val[in[u][0]];
if (u <= p + q) { // 邏輯閘
if (typ[u] == 1) val[u] = (a == 1 && val[in[u][1]] == 1);
else if (typ[u] == 2) val[u] = (a == 1 || val[in[u][1]] == 1);
else if (typ[u] == 3) val[u] = (a != val[in[u][1]]);
else val[u] = 1 - a; // NOT 只有一個輸入
delay[u] = 0;
for (int x : in[u]) delay[u] = max(delay[u], delay[x]);
delay[u]++; // 經過這個閘,多一層
} else { // 輸出端口:照抄,不算延遲
val[u] = a;
delay[u] = delay[in[u][0]];
}
}
int main() {
cin >> p >> q >> r >> m;
int total = p + q + r;
for (int i = 1; i <= p; i++) cin >> val[i];
for (int i = p + 1; i <= p + q; i++) cin >> typ[i];
for (int k = 0; k < m; k++) {
int a, b;
cin >> a >> b;
in[b].push_back(a);
out[a].push_back(b);
waiting[b]++;
}
queue<int> ready; // 輸入都算好、可以處理的元件
for (int i = 1; i <= p; i++) ready.push(i); // 輸入端口不用等任何人
while (!ready.empty()) {
int u = ready.front();
ready.pop();
if (u > p) compute(u);
for (int v : out[u]) { // 通知下游:你又少等一個輸入
waiting[v]--;
if (waiting[v] == 0) ready.push(v);
}
}
int answer = 0;
for (int u = p + q + 1; u <= total; u++) answer = max(answer, delay[u]);
cout << answer << endl;
for (int u = p + q + 1; u <= total; u++) {
cout << val[u];
if (u < total) cout << " ";
}
cout << endl;
return 0;
}
\(O(p + q + r + m)\),\(m\) 最多約 \(2q + r\),一瞬間。compute() 和 \(40\) 分版一模一樣(只少了 done[u] = true),換掉的只有「決定順序」的那一段。
測過再交:範例的閘都「編號小的在前面」
兩個範例的延遲都來自兩輸入的閘,而且輸出端口都接在閘後面。沒蓋到的:輸出端口直接接輸入端口(延遲 \(0\))、NOT 閘也算一層延遲、閘的編號順序和計算順序相反、三種兩輸入閘各自的真值,以及最大的 \(q\) 配上故意倒著編號(驗速度)。
| 輸入 | 正確輸出 | 這一筆在測什麼 |
|---|---|---|
1 1 2 3/1/4/1 2/1 3/2 4 |
1/1 0 |
輸出端口 \(3\) 直接接輸入端口:延遲 \(0\);經過 NOT 的輸出端口 \(4\) 延遲 \(1\) |
2 2 1 5/1 0/1 2/1 4/2 4/4 3/1 3/3 5 |
2/1 |
閘 \(3\) 的輸入來自編號比它大的閘 \(4\):照編號順序算會先算 \(3\),用到還沒算的 \(4\) |
1 5 1 6/1/4 4 4 4 4/1 2/2 3/3 4/4 5/5 6/6 7 |
5/0 |
五個 NOT 串成一排:延遲 \(5\),值翻五次是 \(0\) |
2 3 3 9/1 1/3 1 2/1 3/2 3/1 4/2 4/1 5/2 5/3 6/4 7/5 8 |
1/0 1 1 |
兩個 \(1\) 進 XOR 是 \(0\)、進 AND 是 \(1\)、進 OR 是 \(1\) |
| 用程式造:\(p = 1000\)、\(q = 50000\)、\(r = 1000\),\(500\) 條各 \(100\) 個閘的鏈,閘的編號從輸出那一端往回編 | 100(第二行 \(1000\) 個值,略) |
最大延遲 \(100\);每一輪重頭掃的 \(40\) 分版要掃 \(2.5 \times 10^9\) 次,超時 |
五筆都親眼看過正確,這題就穩了。
另一種做法:遞迴+記憶化
「要算 \(u\),先把它的輸入都算出來」直接寫成遞迴也行:compute(u) 先對 in[u] 裡每個元件呼叫 compute,再算自己;算過的用 done[] 記住,第二次被問到直接回傳。對每個輸出端口呼叫一次就全算完了,每個元件只真正算一次,一樣是 \(O(p + q + r + m)\)。遞迴深度最多是最長路徑的長度,題目保證最大延遲不超過 \(100\),所以很安全。
#include <bits/stdc++.h>
using namespace std;
const int MAX_V = 52000;
int p, q, r, m;
int val[MAX_V + 1], typ[MAX_V + 1], delay[MAX_V + 1];
bool done[MAX_V + 1];
vector<int> in[MAX_V + 1];
void compute(int u) { // 要知道 u 的值,先把它的輸入都算出來
if (done[u]) return; // 算過的直接用,別重算
for (int x : in[u]) compute(x);
int a = val[in[u][0]];
if (u <= p + q) {
if (typ[u] == 1) val[u] = (a == 1 && val[in[u][1]] == 1);
else if (typ[u] == 2) val[u] = (a == 1 || val[in[u][1]] == 1);
else if (typ[u] == 3) val[u] = (a != val[in[u][1]]);
else val[u] = 1 - a;
delay[u] = 0;
for (int x : in[u]) delay[u] = max(delay[u], delay[x]);
delay[u]++;
} else {
val[u] = a;
delay[u] = delay[in[u][0]];
}
done[u] = true;
}
int main() {
cin >> p >> q >> r >> m;
int total = p + q + r;
for (int i = 1; i <= p; i++) {
cin >> val[i];
done[i] = true;
}
for (int i = p + 1; i <= p + q; i++) cin >> typ[i];
for (int k = 0; k < m; k++) {
int a, b;
cin >> a >> b;
in[b].push_back(a);
}
int answer = 0;
for (int u = p + q + 1; u <= total; u++) {
compute(u);
answer = max(answer, delay[u]);
}
cout << answer << endl;
for (int u = p + q + 1; u <= total; u++) {
cout << val[u];
if (u < total) cout << " ";
}
cout << endl;
return 0;
}
兩種做法都是滿分。遞迴版不用 out[] 和 waiting[],程式更短;佇列版不吃遞迴深度,圖很深的題目(這題保證不深)也不用擔心。
常犯錯誤
- 輸出端口的延遲也加一:範例 1 印
3、範例 2 印4——題目說端口不計入。 - 照編號順序算閘:兩個範例都過(範例 2 的 \(7 \to 6\) 剛好沒影響答案),計分測資掛 \(14\) 筆;自測表第 2 列印
1/0。 - NOT 閘不算延遲:兩個範例都過,計分測資掛 \(12\) 筆;自測表第 3 列印
0/0。 - 延遲取輸入的最小值而不是最大值:範例 2 印
2。 - 邊的方向反了(把 \(a \to b\) 記成 \(b \to a\)):兩個範例都印
0、所有輸出都是0。 - 每一輪重頭掃的 \(40\) 分版硬交:\(q = 5 \times 10^4\) 的測資本站實測 \(3.6\) 秒,超時。