ปูพื้นฐาน
ท่าที่ทุกคนเขียนคือฟังก์ชันเรียกตัวเอง ซึ่งตกรอบเงียบ ๆ เมื่อต้นไม้ลึกเป็นแสนชั้น ที่นี่เปลี่ยนไปใช้โครงสิบบรรทัดที่ลอกไปใช้ได้ทุกข้อ แล้วต่อด้วยคำถามที่ต้องตอบก่อนเขียนสูตรว่ากิ่งหนึ่งกิ่งต้องส่งค่าขึ้นมากี่ค่า พร้อมโจทย์ฝึก 3 ข้อ
โจทย์บนต้นไม้ส่วนใหญ่มีหน้าตาเหมือนกันหมด คือคำถามที่ถามถึงโหนดหนึ่งโหนดจะตอบไม่ได้เลย จนกว่าจะรู้คำตอบของทุกกิ่งที่ห้อยอยู่ใต้มัน เช่นกิ่งนี้มีกี่โหนด กิ่งนี้ทางลงยาวสุดเท่าไร หรืออย่างข้อ โมบาย ที่ถามว่ากิ่งนี้เป็นทรงไหน
ท่ามาตรฐานที่ทุกคนเขียนคือฟังก์ชันที่เรียกตัวเอง ลงไปหาลูกก่อน ลูกตอบกลับมาแล้วค่อยรวมคำตอบ ถูกต้องและอ่านง่ายที่สุด และตกในสนามแข่งบ่อยกว่าที่คิด ด้วยเหตุผลที่ไม่เกี่ยวกับความถูกต้องเลย
กับดักที่ทำให้โค้ดถูกแต่ตกรอบ
ต้นไม้ที่มี 100,000 โหนดไม่ได้แปลว่ามันเตี้ย อินพุตที่โหนดทุกตัวมีลูกตัวเดียว
เรียงกันเป็นโซ่ยาว 100,000 ชั้น เป็นต้นไม้ที่ถูกกติกาทุกประการ การเรียกซ้ำจะซ้อนกัน 100,000 ชั้น
และเฟรมของฟังก์ชันหนึ่งชั้นกินหน่วยความจำราว 64 ไบต์ขึ้นไป รวมแล้วราว
6.1 เมกะไบต์ ขณะที่สแต็กที่ระบบตัดสินมักตั้งไว้ให้อยู่ที่ราว 8 เมกะไบต์
หรือน้อยกว่านั้น ผลคือโปรแกรมตายกลางทางโดยไม่มีข้อความบอกอะไรเลย
บทนี้จึงว่าด้วยสองเรื่องที่มาคู่กันเสมอ หนึ่งคือรูปแบบการคิดของ DP บนต้นไม้ และสองคือวิธีเดินต้นไม้โดยไม่เรียกซ้ำ ซึ่งไม่ได้ยากกว่ากันเลย แต่ปลอดภัยกว่ามาก
เจ็ดปม แตกกิ่งกว้าง ลองนับดูก่อน
คำถามตอนนี้ ยังไม่ได้เลือกปม
กดที่ปมเพื่อถามว่ากิ่งของมันมีกี่ปม แล้วพิมพ์คำตอบ
พิมพ์ไว้ - · ตอบถูกแล้ว 0 / 0 ปม
แถวบนสุดคือราก ยิ่งลงล่างยิ่งลึก ปมในกิ่งของปมที่เลือกจะถูกทำเครื่องหมายไว้ให้
ลองไล่ตอบจากปมล่างขึ้นบนดู แล้วสังเกตว่าคำตอบของปมบนใช้คำตอบของปมล่างที่เพิ่งตอบไปได้พอดี
สิ่งเดียวที่การเรียกซ้ำให้เรา คือการรับประกันว่าตอนที่เราคิดค่าของโหนด v ลูกของมันคิดเสร็จหมดแล้ว
ถ้าเราหาลำดับแบบนั้นมาได้ด้วยวิธีอื่น ก็ไม่ต้องเรียกซ้ำอีกต่อไป
วิธีหาง่ายกว่าที่คิด เดินต้นไม้ด้วยสแต็กที่ประกาศเอง แล้วจดว่าเราหยิบโหนดไหนออกมาก่อนหลัง ลำดับที่ได้มีคุณสมบัติว่าพ่ออยู่ก่อนลูกเสมอ เพราะลูกจะถูกใส่เข้าสแต็กก็ต่อเมื่อพ่อถูกหยิบออกมาแล้ว ดังนั้นอ่านลำดับนั้นย้อนกลับ จะได้ลูกก่อนพ่อ ซึ่งคือสิ่งที่ DP ต้องการพอดี
// เดินต้นไม้ด้วยสแต็กที่ประกาศเอง ไม่มีการเรียกซ้ำเลยสักบรรทัด
vector<int> par(n + 1, 0), order;
order.reserve(n);
vector<char> seen(n + 1, 0);
vector<int> st{1};
seen[1] = 1;
while (!st.empty()) {
int v = st.back();
st.pop_back();
order.push_back(v); // พ่อถูกใส่ก่อนลูกเสมอ
for (int u : adj[v])
if (!seen[u]) { seen[u] = 1; par[u] = v; st.push_back(u); }
}
// อ่าน order ย้อนกลับ จะได้ลูกก่อนพ่อ ซึ่งคือลำดับที่ DP บนต้นไม้ต้องการ
for (int i = n - 1; i >= 0; i--) {
int v = order[i];
// ตรงนี้ลูกทุกตัวของ v คิดเสร็จแล้วแน่นอน
} | อ่านทางไหน | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| order (พ่อก่อนลูก) | 1 | 4 | 9 | 12 | 8 | 3 | 7 | 11 | 10 | 2 | 6 | 5 |
| ชั้นของโหนดนั้น | 0 | 1 | 2 | 3 | 2 | 1 | 2 | 3 | 3 | 1 | 2 | 2 |
| อ่านย้อน (ลูกก่อนพ่อ) | 5 | 6 | 2 | 10 | 11 | 7 | 3 | 8 | 12 | 9 | 4 | 1 |
ค่าที่ง่ายที่สุดบนต้นไม้คือขนาดของกิ่ง นิยามคือตัวมันเองหนึ่ง บวกขนาดของกิ่งลูกทุกตัว
เขียนเป็นโค้ดแล้วสั้นจนน่าตกใจ เพราะแทนที่จะวนหาลูกของแต่ละโหนด เราให้ลูกเป็นคนบวกขึ้นไปหาพ่อ ระหว่างที่ไล่ลำดับย้อนกลับ ทุกโหนดจึงถูกแตะแค่ครั้งเดียว
กดถัดไปเพื่อไล่โหนดตามลำดับย้อนกลับ ตัวเลขในวงคือขนาดกิ่งที่ตอบได้แล้ว
ทุกครั้งที่ถึงคิวโหนดที่มีลูก ลูกของมันมีตัวเลขอยู่แล้วทุกตัว นั่นคือคำรับประกันที่การเรียกซ้ำเคยให้เรา และตอนนี้เราได้มันมาจากลำดับแทน โดยไม่ต้องเสียสแต็กเลยสักไบต์
คิดก่อนอ่านต่อ
ขนาดกิ่งเป็นค่าที่รวมกันได้ตรง ๆ ลูกทุกตัวส่งค่าเดียวขึ้นมาแล้วจบ แต่โจทย์ส่วนใหญ่ไม่ใจดีขนาดนั้น ลองนึกถึงโจทย์ที่การตัดสินใจที่โหนดแม่ขึ้นกับว่าลูกตัดสินใจยังไง เช่นถ้าเลือกโหนดนี้แล้วห้ามเลือกลูก ค่าเดียวที่ลูกส่งขึ้นมาจะพอไหม หรือมันต้องส่งขึ้นมากี่ค่า?
สามข้อนี้ตอบคนละคำถาม ข้อแรกถามว่า "โครงที่ให้ไปเขียนจริงหน้าตายังไง" ข้อสองถามว่า "ถ้าคำตอบไม่ได้อยู่ที่ราก แต่ซ่อนอยู่กลางต้นไม้" ข้อสามถามว่า "ถ้าลูกต้องส่งขึ้นมามากกว่าหนึ่งค่า" ลองคิดเองก่อนแล้วค่อยแตะดูเฉลยครับ
โจทย์กำหนด
ให้ต้นไม้ n ≤ 200 000 โหนด รากคือโหนด 1 ตามด้วยเส้นเชื่อม n − 1 เส้น
ให้พิมพ์ขนาดของกิ่งที่ห้อยอยู่ใต้ทุกโหนด (นับตัวมันเองด้วย) เรียงตามหมายเลขโหนด
| Input | Output |
|---|---|
| 7 1 2 1 3 2 4 2 5 3 6 5 7 | 7 4 2 1 2 1 1 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือจำนวนโหนด จากนั้นคือเส้นเชื่อม n − 1 บรรทัด บรรทัดละคู่
เส้นเชื่อมไม่มีทิศทาง คู่ 2 4 กับ 4 2 จึงมีความหมายเดียวกัน
ความเป็นพ่อลูกเกิดจากการที่โจทย์บอกว่ารากคือโหนด 1 ไม่ได้มาจากลำดับที่พิมพ์ในอินพุต
เอาต์พุตคือขนาดของกิ่งของโหนดที่ 1 ถึง 7 เรียงตามหมายเลข ใบทุกใบได้ 1 เพราะนับตัวเองด้วย และรากได้ 7 เสมอ เพราะกิ่งของรากคือทั้งต้น ตัวเลขพวกนี้จึงเป็นที่ตรวจงานง่าย ๆ ว่าโค้ดถูกหรือยัง
คำใบ้
ลอกโครงข้างบนมาแล้วเติมบรรทัดเดียว แต่ระวังขอบของลูปให้ดี ว่ามันควรหยุดที่ดัชนีเท่าไร
ที่มาของท่านี้ · ตัวเลขที่ผมวัดเอง
ตัวเลขในกล่องเตือนข้างบนเป็นการประเมิน ผมอยากรู้ว่าของจริงอยู่ตรงไหน เลยเขียนโปรแกรมสั้น ๆ
ที่สร้างต้นไม้เป็นโซ่ยาวแล้วเรียกซ้ำลงไปนับขนาดกิ่งตรง ๆ คอมไพล์ด้วย g++ 14.2 ที่ -O2
แล้วไล่หาความลึกที่มันเริ่มพังด้วยการค้นแบบไบนารี
ผลคือ ลึก 25,937 ชั้นยังรอด ลึก 26,093 ชั้นตาย บนเครื่องที่ผมใช้ แปลว่ามันไม่ได้ไปตายที่แสนชั้นตามที่โจทย์อนุญาต มันตายตั้งแต่ราวหนึ่งในสี่ของทาง
และสิ่งที่น่ากลัวกว่าตัวเลขคือหน้าตาของความตาย โปรแกรมไม่พิมพ์อะไรออกมาเลยสักตัวอักษร ไม่มีข้อความว่าสแต็กล้น มีแต่โปรเซสหายไปเฉย ๆ ซึ่งบนเว็บตรวจข้อสอบจะกลายเป็นผลลัพธ์ผิดหรือโปรแกรมตาย โดยไม่มีอะไรชี้เลยว่าปัญหาอยู่ที่ความลึก
ตัวเลขนี้เปลี่ยนไปตามเครื่อง ตามคอมไพเลอร์ และตามจำนวนตัวแปรที่ประกาศไว้ในฟังก์ชัน ซึ่งเป็นเหตุผลที่ไม่ควรเดาว่า "ข้อนี้คงไม่ลึกขนาดนั้น" แล้วปล่อยผ่าน ราคาของการเดาผิดคือศูนย์คะแนนโดยไม่รู้สาเหตุ ส่วนราคาของการเขียนแบบไม่เรียกซ้ำตั้งแต่แรกคือโค้ดยาวขึ้นไม่กี่บรรทัด
ลูปต้องหยุดที่ดัชนี 1 ไม่ใช่ 0 เพราะโหนดที่ดัชนี 0 ของลำดับคือราก ซึ่งไม่มีพ่อให้บวกขึ้นไป
ถ้าปล่อยให้วนถึง 0 มันจะไปบวกใส่ sz[0] ซึ่งเป็นช่องที่ไม่มีความหมาย โปรแกรมไม่พัง
แต่เป็นสัญญาณว่าเราไม่ได้คิดขอบให้จบ
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
scanf("%d", &n);
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
int u, v;
scanf("%d %d", &u, &v);
adj[u].push_back(v);
adj[v].push_back(u);
}
vector<int> par(n + 1, 0), order;
order.reserve(n);
vector<char> seen(n + 1, 0);
vector<int> st{1};
seen[1] = 1;
while (!st.empty()) {
int v = st.back(); st.pop_back();
order.push_back(v);
for (int u : adj[v])
if (!seen[u]) { seen[u] = 1; par[u] = v; st.push_back(u); }
}
vector<int> sz(n + 1, 1);
// เริ่มที่ i = n-1 แล้วหยุดที่ 1 เพราะรากไม่มีพ่อให้บวกขึ้นไป
for (int i = n - 1; i >= 1; i--) sz[par[order[i]]] += sz[order[i]];
for (int i = 1; i <= n; i++) printf("%d%c", sz[i], i == n ? '\n' : ' ');
return 0;
} สุ่มเทียบกับตัวที่เขียนแบบเรียกซ้ำ 300 เทส โดยตัวสร้างเทสสุ่มปนกันระหว่างต้นไม้ทรงกระจายกับโซ่ยาว เพื่อให้แน่ใจว่าเจอเคสที่ทำให้ตัวเรียกซ้ำลำบาก ตรงกันทั้งหมด
โจทย์กำหนด
ให้ต้นไม้ n ≤ 200 000 โหนด หาว่าทางเดินที่ยาวที่สุดระหว่างสองโหนดใด ๆ ผ่านเส้นเชื่อมกี่เส้น
ค่านี้เรียกว่าเส้นผ่านศูนย์กลางของต้นไม้ (diameter)
| Input | Output |
|---|---|
| 7 1 2 1 3 2 4 2 5 3 6 5 7 | 5 |
อ่านตัวอย่างนี้ยังไง
อินพุตหน้าตาเดียวกับข้อที่แล้วเป๊ะ เอาต์พุตคือจำนวนเส้นเชื่อมบนทางที่ยาวที่สุด ไม่ใช่จำนวนโหนด ซึ่งต่างกันหนึ่งเสมอ
ต้นไม้ต้นนี้ทางที่ยาวที่สุดคือจากโหนด 6 ไปโหนด 7 ผ่าน 6 โหนด จึงนับได้ 5 เส้น สังเกตว่าทางนี้ บังเอิญผ่านราก ซึ่งเป็นเหตุผลที่คิดแค่ "ความลึกที่สุด" อย่างเดียวไม่พอ
คำใบ้
ทางที่ยาวที่สุดไม่จำเป็นต้องผ่านราก แต่ไม่ว่ามันจะอยู่ตรงไหน มันต้องมีโหนดที่สูงที่สุดบนทางนั้น อยู่หนึ่งโหนดเสมอ แล้วทางนั้นหน้าตาเป็นยังไงเมื่อมองจากโหนดตัวนั้น
เมื่อมองจากโหนดที่สูงที่สุดบนทาง ทางนั้นคือการดิ่งลงสองกิ่งคนละข้างแล้วมาบรรจบกันที่โหนดนี้ (หรือดิ่งลงกิ่งเดียว ถ้าโหนดนี้เป็นปลายทางเอง) ดังนั้นสำหรับทุกโหนด ให้เก็บความยาวของสองกิ่งที่ลึกที่สุด ของมัน แล้วบวกกัน คำตอบคือค่ามากที่สุดที่เจอตลอดทาง
ค่าที่ลูกส่งขึ้นมาคือ down[u] ความลึกของทางที่ยาวที่สุดจาก u ลงไปหาใบ
พ่อรับมาบวกหนึ่ง แล้วเก็บไว้สองอันดับแรก เพราะการมาบรรจบกันต้องใช้กิ่งคนละกิ่ง
จะหยิบกิ่งที่ลึกที่สุดมาสองรอบไม่ได้ ทั้งข้อเป็น O(n)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
scanf("%d", &n);
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
int u, v;
scanf("%d %d", &u, &v);
adj[u].push_back(v);
adj[v].push_back(u);
}
vector<int> par(n + 1, 0), order;
order.reserve(n);
vector<char> seen(n + 1, 0);
vector<int> st{1};
seen[1] = 1;
while (!st.empty()) {
int v = st.back(); st.pop_back();
order.push_back(v);
for (int u : adj[v])
if (!seen[u]) { seen[u] = 1; par[u] = v; st.push_back(u); }
}
vector<int> down(n + 1, 0); // ทางที่ยาวที่สุดจาก v ลงไปหาใบในกิ่งของตัวเอง
int ans = 0;
for (int i = n - 1; i >= 0; i--) {
int v = order[i];
int b1 = 0, b2 = 0; // สองกิ่งที่ยาวที่สุดของ v
for (int u : adj[v]) {
if (u == par[v]) continue;
int d = down[u] + 1;
if (d > b1) { b2 = b1; b1 = d; }
else if (d > b2) b2 = d;
}
down[v] = b1;
ans = max(ans, b1 + b2); // ทางที่หักมุมที่ v พอดี
}
printf("%d\n", ans);
return 0;
} สุ่มเทียบกับตัวที่ทำ BFS จากทุกโหนดแล้วเอาระยะไกลสุด 300 เทส ตรงกันทั้งหมด รวมกรณีต้นไม้โหนดเดียว ซึ่งคำตอบคือ 0
โจทย์กำหนด
ให้ต้นไม้ n ≤ 200 000 โหนด แต่ละโหนดมีน้ำหนัก w[i] ซึ่งติดลบได้
เลือกกลุ่มโหนดที่ไม่มีสองตัวไหนเชื่อมกันด้วยเส้นเดียว ให้ผลรวมน้ำหนักมากที่สุด เลือกกลุ่มว่างก็ได้
| Input | Output |
|---|---|
| 7 4 -3 5 2 6 -1 7 1 2 1 3 2 4 2 5 3 6 5 7 | 14 |
อ่านตัวอย่างนี้ยังไง
อินพุตเพิ่มมาหนึ่งบรรทัดคือน้ำหนักของโหนดที่ 1 ถึง 7 เรียงตามหมายเลข แล้วตามด้วยเส้นเชื่อมเหมือนเดิม เอาต์พุตคือผลรวมน้ำหนัก ไม่ใช่รายชื่อโหนดที่เลือก
คำว่า "ห้ามติดกัน" หมายถึงห้ามเลือกสองโหนดที่มีเส้นเชื่อมถึงกันโดยตรง โหนดที่ห่างกันสองเส้นเลือกพร้อมกันได้ ชุดนี้เลือกโหนด 3, 4, 7 ได้ผลรวม 14
กฎง่าย ๆ ที่คนคิดออกก่อนคือ "เอาทุกตัวที่น้ำหนักเป็นบวก" ซึ่งชุดนี้จะได้ 24 แต่ใช้ไม่ได้จริง เพราะโหนดบวกบางคู่ติดกันอยู่ เลือกพร้อมกันไม่ได้ และน้ำหนักติดลบก็ทำให้ บางครั้งการเลือกโหนดที่ติดลบยังคุ้ม ถ้ามันเปิดทางให้เลือกลูกที่บวกกว่าได้
คำใบ้
กลับไปที่คำถามในกล่อง "คิดก่อนอ่านต่อ" ข้างบน ถ้าลูกส่งค่าเดียวขึ้นมาว่า "กิ่งฉันได้มากที่สุดเท่านี้" พ่อจะเอาไปใช้ไม่ได้ เพราะพ่อต้องรู้ด้วยว่าค่านั้นได้มาจากการที่ลูกถูกเลือกหรือเปล่า
ลูกต้องส่งขึ้นมาสองค่า คือ take[u] ค่าดีที่สุดของกิ่งนั้นเมื่อ u ถูกเลือก
และ skip[u] ค่าดีที่สุดเมื่อ u ไม่ถูกเลือก แล้วพ่อเลือกใช้ตัวไหนก็ตามสถานการณ์ของตัวเอง
บรรทัดบนอ่านว่า ถ้าเลือก v ก็ได้น้ำหนักของมันมา แต่ลูกทุกตัวถูกห้ามไปด้วย จึงใช้ได้แต่ skip
บรรทัดล่างอ่านว่า ถ้าไม่เลือก v ลูกแต่ละตัวเป็นอิสระ จะเลือกหรือไม่เลือกก็ได้ หยิบตัวที่มากกว่ามา
คำตอบสุดท้ายคือค่าที่มากกว่าระหว่างสองตัวที่ราก
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
scanf("%d", &n);
vector<long long> w(n + 1);
for (int i = 1; i <= n; i++) scanf("%lld", &w[i]);
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
int u, v;
scanf("%d %d", &u, &v);
adj[u].push_back(v);
adj[v].push_back(u);
}
vector<int> par(n + 1, 0), order;
order.reserve(n);
vector<char> seen(n + 1, 0);
vector<int> st{1};
seen[1] = 1;
while (!st.empty()) {
int v = st.back(); st.pop_back();
order.push_back(v);
for (int u : adj[v])
if (!seen[u]) { seen[u] = 1; par[u] = v; st.push_back(u); }
}
vector<long long> take(n + 1, 0), skip(n + 1, 0);
for (int i = n - 1; i >= 0; i--) {
int v = order[i];
take[v] += w[v];
for (int u : adj[v]) {
if (u == par[v]) continue;
take[v] += skip[u]; // เลือก v แล้ว ลูกทุกตัวห้ามถูกเลือก
skip[v] += max(take[u], skip[u]); // ไม่เลือก v ลูกจะเลือกหรือไม่ก็ได้
}
}
printf("%lld\n", max(take[1], skip[1]));
return 0;
} สุ่มเทียบกับตัวที่ลองทุกกลุ่มย่อยด้วยบิตมาสก์ 300 เทส โดยจงใจให้น้ำหนักมีทั้งบวกและลบ ตรงกันทั้งหมด
ระวัง
น้ำหนักติดลบคือจุดที่โค้ดหลายเวอร์ชันพัง เพราะการตั้งค่าเริ่มต้นของ take ไว้ที่ 0
แล้วบวกน้ำหนักเข้าไปตรง ๆ ทำให้โหนดที่น้ำหนักติดลบยัง "ถูกเลือก" อยู่ในตาราง ที่มันยังตอบถูกเพราะ
skip จะชนะมันเองในบรรทัดที่หยิบตัวมากกว่า แต่ถ้าเผลอเขียนว่า take[v] = max(w[v], 0) + ...
เพื่อ "กันค่าติดลบ" เมื่อไร คำตอบจะผิดทันที เพราะมันปนสองความหมายเข้าด้วยกัน
ท่าทั้งบทนี้ตอบคำถามที่มองจากรากลงล่าง คือค่าของกิ่งที่ห้อยใต้ปมหนึ่ง
ซึ่งพอเลือกรากแล้วก็คิดได้ในรอบเดียว แต่มีโจทย์อีกตระกูลที่ถามหนักกว่านั้น คือถาม
คำตอบของทุกปมในฐานะที่ปมนั้นเป็นราก ตัวอย่างที่เข้าใจง่ายสุดคือ
"ผลรวมระยะทางจากปมนี้ไปทุกปมที่เหลือ" ซึ่งต้องตอบให้ครบทั้ง n ปม
ทางตรงคือย้ายรากแล้วคิดใหม่ทีละปม ซึ่งเป็น n รอบ รอบละ O(n)
รวมเป็น O(n²) ที่ n = 200,000 คือสี่หมื่นล้านครั้ง แตะไม่ได้
ท่าที่แก้เรื่องนี้เรียกว่า การย้ายราก (rerooting) และแนวคิดของมันสั้นมาก คำตอบของปมข้างเคียงต่างจากคำตอบของปมนี้แค่นิดเดียว จึงคำนวณจากกันได้ ไม่ต้องคิดใหม่ทั้งใบ
ลองดูกับโจทย์ผลรวมระยะทาง สมมติเรารู้คำตอบของปม p แล้ว และจะย้ายรากไปที่ลูก
v ระยะทางของทุกปมเปลี่ยนไปแค่สองแบบ ปมที่อยู่ในกิ่งของ v
ทั้ง sz[v] ปม ใกล้ขึ้นหนึ่งก้าวทุกปม ส่วนปมที่เหลือทั้ง n - sz[v] ปม
ไกลขึ้นหนึ่งก้าวทุกปม คำตอบใหม่จึงเป็น
ans[v] = ans[p] − sz[v] + (n − sz[v])
ซึ่งเป็นงานคงที่ต่อหนึ่งปม รวมทั้งต้นไม้จึงเป็น O(n)
โครงของท่านี้คือสองรอบ รอบแรกไล่จากใบขึ้นหาราก เก็บของที่มองลงได้อย่างเดียว
(ขนาดกิ่ง และผลรวมระยะทางที่มองลง) รอบที่สองไล่จากรากลงหาใบ ย้ายรากทีละก้าวด้วยสูตรข้างบน
รอบแรกใช้ลำดับย้อนกลับที่บทนี้สอนไว้แล้ว รอบที่สองใช้ลำดับเดิมแบบไม่ย้อน
// Rerooting: ผลรวมระยะทางจากทุกปมไปยังปมอื่นทั้งหมด คิดครบทุกปมในสองรอบ
// อินพุต: n แล้ว n-1 บรรทัด u v (ต้นไม้ ปมนับจาก 0)
// เอาต์พุต: ans[0..n-1] คือผลรวมระยะทางจากปมนั้นไปทุกปม
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
vector<vector<int>> g(n);
for (int i = 0; i + 1 < n; i++) {
int u, v;
scanf("%d %d", &u, &v);
g[u].push_back(v);
g[v].push_back(u);
}
// ลำดับ BFS จากราก 0 ใช้แทนการเรียกซ้ำ กันสแต็กล้นบนต้นไม้ลึก
vector<int> order, par(n, -1);
order.reserve(n);
order.push_back(0);
for (int h = 0; h < (int)order.size(); h++) {
int u = order[h];
for (int v : g[u])
if (v != par[u]) {
par[v] = u;
order.push_back(v);
}
}
vector<long long> sz(n, 1), down(n, 0), ans(n, 0);
// รอบที่หนึ่ง จากใบขึ้นหาราก เก็บขนาดกิ่งและผลรวมระยะทางที่มองลงเท่านั้น
for (int i = n - 1; i >= 1; i--) {
int v = order[i], p = par[v];
sz[p] += sz[v];
down[p] += down[v] + sz[v];
}
// รอบที่สอง จากรากลงหาใบ ย้ายรากทีละก้าว
// ก้าวจาก p ไป v มีปม sz[v] ปมที่ใกล้ขึ้นหนึ่ง และ n - sz[v] ปมที่ไกลขึ้นหนึ่ง
ans[0] = down[0];
for (int i = 1; i < n; i++) {
int v = order[i], p = par[v];
ans[v] = ans[p] - sz[v] + (long long)(n - sz[v]);
}
for (int i = 0; i < n; i++) printf("%lld%c", ans[i], i + 1 == n ? '\n' : ' ');
return 0;
} รูปทั่วไปของท่านี้ยกไปใช้ได้กับค่าอื่นด้วย ไม่ใช่แค่ผลรวมระยะทาง คำถามที่ต้องตอบให้ได้ก่อนใช้คือ ถ้าย้ายรากไปหนึ่งก้าว ค่าที่เราสนใจเปลี่ยนไปเท่าไร และคำนวณส่วนต่างนั้นจากของที่มีอยู่แล้วได้ไหม ถ้าตอบได้ ท่านี้ใช้ได้ทันที ถ้าตอบไม่ได้ ให้กลับไปดูว่าต้องเก็บอะไรเพิ่มในรอบแรก
สิ่งเดียวที่ DP บนต้นไม้ต้องการคือลำดับที่ลูกเสร็จก่อนพ่อ ซึ่งได้มาจากการเดินด้วยสแต็กแล้วอ่านย้อนกลับ ไม่ต้องพึ่งสแต็กของระบบเลย ส่วนคำถามที่ต้องตอบก่อนเขียนสูตรทุกครั้งคือลูกต้องส่งขึ้นมากี่ค่า ถ้าการตัดสินใจของพ่อไปจำกัดลูก ค่าเดียวไม่เคยพอ
ข้อที่ใช้ของในบทนี้ตรง ๆ คือ โมบาย ซึ่งค่าที่กิ่งส่งขึ้นมาไม่ใช่ตัวเลข แต่เป็นป้ายบอกทรงของกิ่งสามแบบ และเป็นข้อที่โซ่ยาวหนึ่งแสนชั้นเป็นอินพุตที่ถูกกติกา
ในหน้านี้