建造堡壘 題解|APCS 2026 年 11 月高級 第 1 題
前置知識
這題是樹上動態規劃的入門題,下面的講解直接把這些當成已知,沒讀過的先讀;同一個主題兩邊都有的,對照著讀會更快上手:
- 樹的儲存與 DFS:NTUCPC Guide〈圖論基礎〉的鄰接串列,以及 NTUCPC Guide〈樹〉的「來爬樹」「子樹大小」——
dfs(s, par)用參數記住從哪裡來、不走回頭路,走完一個孩子就把孩子的結果交回父節點;吳邦一《AP325》第 8 章 8.2 講同樣的東西(教材 PDF 第 269~284 頁;Python 版第 8 章(I))。 - DP 的狀態要記住會影響下一步的事(子題組 1 用):NTUCPC Guide〈狀態與轉移〉的例題一「小心狀態陷阱」——只記「跑完前 \(i\) 段的最高分」不夠,要把「第 \(i\) 段有沒有加速」也放進狀態。
- 樹上的 DP:NTUCPC Guide〈樹的應用〉「樹上匹配問題」的開頭——把每棵子樹當成一個小問題,想清楚孩子的答案怎麼和父節點合併;《AP325》第 8 章的 P-8-11「公司派對」把每個點分成「選它」與「不選它」兩種情況,各算一個最佳值,本題用的就是這個架構(教材 PDF 第 300~303 頁;Python 版第 8 章(III);站上題目 ap325p811)。同一章的習題 Q-8-10「竊聽間諜網」就是本題花費全部是 \(1\) 的版本(教材 PDF 第 299~300 頁;站上題目 ap325q810)。
簡潔題意
\(n\) 個城市(編號 \(1 \sim n\))由 \(n - 1\) 條雙向道路連成一棵樹,在城市 \(i\) 建堡壘的花費是 \(C_i\)。選一些城市建堡壘,讓每一條道路至少有一端建了堡壘,求最少的總花費(\(2 \le n \le 2 \times 10^5\),每個 \(C_i\) 都是 \(0\) 到 \(10^4\) 的整數;道路的先後順序、每行 \(u\) 和 \(v\) 誰先寫都沒有任何保證)。
依正確通過的測試資料筆數給分,其中:
- 子題組 1(\(40\) 分):每個城市至多連接兩條道路。
- 子題組 2(\(60\) 分):無額外限制。
先拿下 40 分:沿著鏈走一遍,每個城市記兩個數
子題組 1 的每個城市至多連兩條道路,\(n\) 個城市又靠 \(n - 1\) 條道路全部連通——畫出來就是一條鏈。可是鏈不一定照編號排(範例 2 的鏈依序是城市 \(3\)、\(6\)、\(1\)、\(5\)、\(2\)、\(4\)),要先沿著道路把順序排出來:只連一條道路的城市是鏈的一端,從它出發,每一步走向「不是剛剛來的那個」鄰居,走到另一端為止。
排好順序之後,由前往後一個一個決定建不建。限制是「相鄰的兩個城市不能都不建」,所以決定一個城市時,前面只有一件事會影響它:前一個城市有沒有建——沒建的話,這一個非建不可。每走到一個城市就記兩個數:
f0:這個城市不建時,走過的道路全部顧好的最少花費;f1:這個城市建時,走過的道路全部顧好的最少花費。
走到下一個城市 \(x\):
- \(x\) 不建:前一個城市一定要建,新的
f0就是舊的f1; - \(x\) 建:前一個建不建都可以、挑省的,新的
f1是舊的f0、f1比較小的那個再加上 \(C_x\)。
第一個城市的 f0 是 \(0\)、f1 是它的花費;走到最後一個城市,f0、f1 比較小的那個就是答案。範例 2 照這樣走一遍:
| 沿著鏈走到 | 花費 | 不建(f0) |
建(f1) |
|---|---|---|---|
| 城市 \(3\) | \(4\) | \(0\) | 4 |
| 城市 \(6\) | \(2\) | \(4\) | min(0, 4) + 2 = 2 |
| 城市 \(1\) | \(1\) | \(2\) | min(4, 2) + 1 = 3 |
| 城市 \(5\) | \(8\) | \(3\) | min(2, 3) + 8 = 10 |
| 城市 \(2\) | \(2\) | \(10\) | min(3, 10) + 2 = 5 |
| 城市 \(4\) | \(1\) | \(5\) | min(10, 5) + 1 = 6 |
「不建」那一欄永遠是上一列的「建」;最後一列取比較小的 \(5\),就是答案。
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 200005;
vector<int> adj[MAX_N]; // adj[u]:和城市 u 有道路相連的城市
int C[MAX_N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> C[i];
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v); // 道路沒有方向,兩端都記
adj[v].push_back(u);
}
int start = 1;
while (adj[start].size() != 1) start++; // 只連一條道路的城市就是鏈的一端
vector<int> order; // 沿著鏈排好的城市
int prev = 0, cur = start;
while (cur != 0) {
order.push_back(cur);
int next = 0; // 走到另一端就沒有下一站,留在 0
for (int w : adj[cur])
if (w != prev) next = w; // 不走回頭路
prev = cur;
cur = next;
}
int f0 = 0, f1 = C[order[0]]; // 第一個城市不建/建
for (int i = 1; i < (int)order.size(); i++) {
int x = order[i];
int g0 = f1; // x 不建:前一個城市一定要建
int g1 = min(f0, f1) + C[x]; // x 建:前一個建不建都可以,挑省的
f0 = g0;
f1 = g1;
}
cout << min(f0, f1) << '\n';
return 0;
}
沿著鏈走一遍、每個城市算一次,\(O(n)\)。範例 1 不是鏈,這份程式交上去範例 1 會 WA——這是正常的,逐筆給分之下子題組 1 的 \(40\) 分穩穩到手。
從 40 分到 100 分:每個城市一樣記兩個數,從葉子往根算
一般的樹排不成一排,可是「建、不建分開算」的想法照樣能用。先看看為什麼更簡單的貪心不行:每條道路各自挑便宜的一端,或是把城市分成兩組(相鄰的兩個城市一定不同組)、只建便宜的那一組——範例 1 兩種做法都挑到城市 \(1\)、\(3\)、\(5\)、\(6\)、\(7\),花費 \(12\),答案卻是 \(10\)。最佳解建的城市 \(2\) 比它的鄰居 \(5\)、\(1\) 都貴,可是建它一個就顧好道路 \(2\)–\(5\) 和 \(2\)–\(1\),比建城市 \(5\)、\(1\) 兩個加起來的 \(7\) 便宜;而且城市 \(2\)、\(6\) 相鄰,兩個都建了。一個城市划不划算,要看它的鄰居怎麼選,所以「建」與「不建」兩種情況都要留著,留給上一層再決定。
把城市 \(1\) 當成根(根挑哪個城市都可以,下面會驗證),每個城市 \(u\) 只看「\(u\) 和它底下的子樹」,算兩個數:
- \(dp_0[u]\):\(u\) 不建時,子樹裡的道路全部顧好的最少花費;
- \(dp_1[u]\):\(u\) 建時,子樹裡的道路全部顧好的最少花費。
\(u\) 和它的每個孩子 \(v\) 之間都有一條道路:
- \(u\) 不建:這條道路只剩 \(v\) 能顧,每個孩子都非建不可——\(dp_0[u]\) 是所有孩子的 \(dp_1\) 加起來。
- \(u\) 建:這條道路已經顧好了,孩子建不建都可以、各挑省的——\(dp_1[u]\) 是 \(C_u\) 加上每個孩子的 \(dp_0\)、\(dp_1\) 比較小的那個。
孩子們的子樹之間沒有道路,只靠 \(u\) 連在一起,所以 \(u\) 建不建定了之後,每棵子樹各挑各的最省,加起來就是最省。葉子沒有孩子:\(dp_0 = 0\)、\(dp_1 = C_u\)。根沒有父節點,建不建都可以,答案是根的 \(dp_0[1]\)、\(dp_1[1]\) 比較小的那個。
範例 1 從葉子往根算(圖 1):「不建」是孩子們的「建」加起來,「建」是自己的花費加上每個孩子挑小的那一個。
| 由下往上算到 | 花費 | 不建(dp0) |
建(dp1) |
|---|---|---|---|
| 葉子 \(5\)、\(7\)、\(3\) | \(3\)、\(2\)、\(3\) | 0 |
自己的花費 |
| 城市 \(4\) | \(9\) | 2 + 3 = 5 |
9 + 0 + 0 = 9 |
| 城市 \(6\) | \(0\) | 9 |
0 + min(5, 9) = 5 |
| 城市 \(2\) | \(5\) | 3 + 5 = 8 |
5 + min(0, 3) + min(9, 5) = 10 |
| 城市 \(1\)(根) | \(4\) | 10 |
4 + min(8, 10) = 12 |
答案是 \(\min(10, 12) = 10\)。
圖 1 的綠框是從根往下、照「比較小的那一格」走回去的結果(父節點不建時,孩子只能選「建」):建堡壘的是城市 \(2\)、\(3\)、\(6\)、\(7\),正是範例解釋的建法。根挑哪個城市都可以——根只決定從哪裡開始往下看,道路和規則都沒變:換城市 \(6\) 當根重算一次,根的兩個數是 \(14\) 和 \(10\),答案一樣是 \(10\)。
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 200005;
vector<int> adj[MAX_N]; // adj[u]:和城市 u 有道路相連的城市
int C[MAX_N];
int dp0[MAX_N], dp1[MAX_N]; // u 不建/建時,u 這棵子樹的最少花費
void dfs(int u, int parent) {
dp0[u] = 0;
dp1[u] = C[u];
for (int v : adj[u]) {
if (v == parent) continue; // 父節點不是孩子,不走回頭路
dfs(v, u); // 先把孩子 v 的子樹算完
dp0[u] += dp1[v]; // u 不建:v 一定要建
dp1[u] += min(dp0[v], dp1[v]); // u 建:v 挑省的
}
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> C[i];
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v); // 道路沒有方向,兩端都記
adj[v].push_back(u);
}
dfs(1, 0); // 城市 1 當根;0 不是任何城市,代表沒有父節點
cout << min(dp0[1], dp1[1]) << '\n';
return 0;
}
每個城市被 dfs 呼叫一次,每條道路從兩端各看一次,\(O(n)\);\(n = 2 \times 10^5\) 本站實測不到 \(0.4\) 秒。每個 \(dp_0\)、\(dp_1\) 都不超過全部花費的總和:\(2 \times 10^5\) 個城市、每個最多 \(10^4\),總和最多 \(2 \times 10^9\),比 int 的上限 \(2147483647\) 小,int 就夠。不用遞迴的寫法收在下面。
另一種寫法:不用遞迴
遞迴做的事只有一件:算一個城市之前,先把它的孩子都算完。不用遞迴也辦得到:從根開始用 BFS 把城市排成一排(父節點一定排在孩子前面),再倒著走這一排——輪到一個城市時,它的孩子都排在後面、早就算完了,直接把它的兩個數加到父節點身上。這就是《AP325》8.2.4「由下而上的走訪」講的順序(教材 PDF 第 279 頁)。
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 200005;
vector<int> adj[MAX_N]; // adj[u]:和城市 u 有道路相連的城市
int C[MAX_N];
int dp0[MAX_N], dp1[MAX_N]; // u 不建/建時,u 這棵子樹的最少花費
int parentOf[MAX_N]; // 以城市 1 為根時的父節點(根是 0)
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> C[i];
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
vector<int> order; // BFS 的拜訪順序:父節點一定排在孩子前面
order.push_back(1);
for (int i = 0; i < n; i++) { // 邊走邊把孩子接到後面
int u = order[i];
for (int v : adj[u]) {
if (v == parentOf[u]) continue;
parentOf[v] = u;
order.push_back(v);
}
}
for (int u = 1; u <= n; u++) { // 還沒加上任何孩子:不建是 0、建是自己的花費
dp0[u] = 0;
dp1[u] = C[u];
}
for (int i = n - 1; i >= 1; i--) { // 倒著走:孩子都算完了才輪到父節點(order[0] 是根)
int v = order[i];
int u = parentOf[v];
dp0[u] += dp1[v]; // u 不建:v 一定要建
dp1[u] += min(dp0[v], dp1[v]); // u 建:v 挑省的
}
cout << min(dp0[1], dp1[1]) << '\n';
return 0;
}
一樣是 \(O(n)\),沒有任何遞迴。
測過再交:有兩種錯法,兩個範例都擋不住
範例 1 是 \(7\) 個城市的樹、範例 2 是 \(6\) 個城市的鏈,答案都不大。沒蓋到的:只有一條道路的 \(n = 2\)、星狀的樹、答案是 \(0\) 或最大的情況,還有兩種兩個範例都會過的錯法(見常犯錯誤)——第 2 列就是專門抓它們的。
| 輸入 | 正確輸出 | 這一筆在測什麼 |
|---|---|---|
2 / 5 3 / 1 2 |
3 |
最小的 \(n\):只有一條道路,建便宜的那一端(子題組 1 版就能驗) |
3 / 3 1 3 / 2 1 / 1 3 |
3 |
鏈 \(2\)–\(1\)–\(3\),只建中間的城市 \(1\):把鏈當成照編號排的 \(1\)–\(2\)–\(3\) 會印 1;先建「平均每條道路最便宜」的城市 \(2\) 會印 4(子題組 1 版就能驗) |
5 / 4 0 0 0 0 / 1 2 / 1 3 / 1 4 / 1 5 |
0 |
星狀的樹、答案是 \(0\):四個葉子都不用錢,全部建、中心不建 |
| 用程式造:\(n = 2 \times 10^5\) 的一條鏈 \(1\)–\(2\)–\(\cdots\)–\(n\),花費全部是 \(10^4\) | 1000000000 |
答案最大:\(10^5\) 個城市各花 \(10^4\),共 \(10^9\),int 裝得下(子題組 1 版就能驗) |
四筆都親眼看過正確,這題就穩了。
常犯錯誤
- 以為鏈是照編號排的(子題組 1 不讀道路,直接拿 \(C_1, C_2, \ldots, C_n\) 做 DP):兩個範例都剛好過,自測表第 2 列印
1;子題組 1 的 \(8\) 筆只過 \(3\) 筆。 - 只存一個方向(只有
adj[u].push_back(v)):方向反過來的道路從城市 \(1\) 走不到——範例 1 印5、範例 2 印2。 - 城市不建時,也讓孩子挑省的(
dp0[u] += min(dp0[v], dp1[v]);把公司派對的轉移照抄、max換成min也是這樣):忘了那條道路只剩孩子能顧,每個 \(dp_0\) 都變成 \(0\)——兩個範例都印0。 - 最後只印 \(dp_0[1]\) 或只印 \(dp_1[1]\):根可能建、也可能不建——只印 \(dp_0[1]\) 範例 2 印
11,只印 \(dp_1[1]\) 範例 1 印12。 - 「平均每條道路最便宜」的城市先建(每次挑「花費 ÷ 還沒顧好的道路數」最小的城市):兩個範例都剛好過,自測表第 2 列印
4;本站 \(20\) 筆只過 \(2\) 筆。 - 把花費都一樣時的貪心搬過來(葉子的鄰居一定建——花費全是 \(1\) 的 Q-8-10 這樣做沒錯):花費不同就不對,範例 1 印
14。 - 每個城市都掃一遍全部的道路找鄰居:\(O(n^2)\),\(n = 2 \times 10^5\) 約 \(4 \times 10^{10}\) 次,本站 \(20\) 筆掛 \(16\) 筆(TLE)。