28.4 น้ำหนักติดลบและ Bellman-Ford
บางโจทย์มีน้ำหนักติดลบ เช่น รถขนของที่วิ่งเปล่าบางเส้นต้องจ่ายค่าน้ำมัน แต่บางเส้นได้ค่าจ้างขนสินค้ากลับ ค่าใช้จ่ายของเส้นนั้นจึงติดลบ หรือเกมที่บางห้องหักพลัง บางห้องเติมพลัง หน้า 28.1 พิสูจน์ว่า Dijkstra ถูกโดยอาศัยว่าน้ำหนักไม่ติดลบ หน้านี้แสดงว่าพอเงื่อนไขนั้นหายไป Dijkstra ผิดจริง แล้วใช้วิธีที่ช้ากว่าแต่รับน้ำหนักติดลบได้ ชื่อ Bellman-Ford ซึ่งยังตรวจได้ด้วยว่ามีวงที่วนแล้วค่าใช้จ่ายลดลงเรื่อยๆ ไม่สิ้นสุดหรือไม่
ทุกตัวอย่างในหน้านี้เป็นกราฟมีทิศทาง (directed) ถนน u v w แปลว่าวิ่งจาก u ไป v ได้ทางเดียว เหตุผลอยู่ในส่วนลองทำให้พัง
Dijkstra ผิดเมื่อมีน้ำหนักติดลบ
ใช้ Dijkstra แบบอาเรย์จากหน้า 28.1 เปลี่ยนแค่ให้ถนนเป็นทางเดียว แผนที่มีสี่จุด ถนนจาก 3 ไป 2 ได้ค่าจ้างกลับ 4 บาท
#include <bits/stdc++.h>
using namespace std;
const long long INF = 1e18;
int main()
{
int n, m;
cin >> n >> m;
vector<vector<pair<int, int>>> adj(n + 1);
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({v, w}); // (1)!
}
vector<long long> dist(n + 1, INF);
vector<bool> done(n + 1, false);
dist[1] = 0;
for (int round = 0; round < n; round++) {
int u = -1;
for (int i = 1; i <= n; i++) {
if (!done[i] && (u == -1 || dist[i] < dist[u])) {
u = i;
}
}
if (dist[u] == INF) {
break;
}
done[u] = true;
cout << "take " << u << " dist=" << dist[u] << '\n';
for (auto [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
}
}
}
for (int u = 1; u <= n; u++) {
cout << dist[u] << (u == n ? '\n' : ' ');
}
return 0;
}
- ถนนทางเดียว ใส่เฉพาะ
adj[u]
4 4
1 2 2
1 3 5
3 2 -4
2 4 3
take 1 dist=0
take 2 dist=2
take 3 dist=5
take 4 dist=5
0 1 5 5
ทางที่ถูกคือ 1 ไป 3 ไป 2 ไป 4 ค่าใช้จ่าย 5 - 4 + 3 = 4 แต่โปรแกรมตอบ 5 จุด 2 ถูกเลือกเป็นจุดที่เสร็จตั้งแต่ระยะ 2 และผ่อนถนนไปจุด 4 ได้ 5 ทีหลังจุด 3 ผ่อนกลับมาลดจุด 2 เหลือ 1 แต่จุด 2 เสร็จไปแล้ว ไม่มีใครส่งค่าใหม่ต่อให้จุด 4 คำตอบจึงขัดกันเอง จุด 2 บอก 1 แต่จุด 4 ยังคิดจากจุด 2 ที่ระยะ 2
เหตุผลในหน้า 28.1 พังตรงประโยค "เดินจาก x ต่อไปถึง u ซึ่งไม่ทำให้ระยะลดลง" ทางที่ผ่านจุด 3 ซึ่งตอนนั้นไกลกว่า กลับมาถูกลงได้ด้วยถนนติดลบ จุดที่ใกล้ที่สุดในตอนนั้นจึงยังไม่ใช่คำตอบสุดท้าย
แบบ priority_queue บังเอิญรอด แต่ไม่ปลอดภัย
ข้อมูลชุดนี้ป้อนให้แบบ priority_queue ในหน้า 28.2 ที่ทำเป็นทางเดียว ได้ 0 1 5 4 ถูก เพราะพอจุด 2 ได้ระยะใหม่ ก็ถูกใส่ลงคิวและถูกหยิบมาทำซ้ำ แต่นั่นไม่ใช่ Dijkstra แล้ว จุดเดียวกันอาจถูกทำซ้ำได้มากจนไม่มีขอบเขตเวลาที่รับประกัน และถ้ามีวงติดลบในหัวข้อถัดไป ระยะจะลดลงไม่หยุด ลองแล้วโปรแกรมพิมพ์ระยะที่ติดลบมากขึ้นเรื่อยๆ จนต้องหยุดเองเมื่อครบสิบวินาที โจทย์ที่มีน้ำหนักติดลบให้ใช้วิธีในหน้านี้
Bellman-Ford
ความคิดง่ายมาก ผ่อนถนนทุกเส้น วนแบบนี้ V - 1 รอบ ไม่ต้องสนลำดับ ไม่ต้องมีคิว ไม่ต้องเก็บ adjacency list เก็บแค่รายการถนนสามอาเรย์ก็พอ
ทำไม V - 1 รอบพอ ถ้าไม่มีวงติดลบ ทางที่สั้นที่สุดไม่จำเป็นต้องผ่านจุดซ้ำ เพราะวงที่ไม่ติดลบตัดทิ้งได้โดยไม่ทำให้ยาวขึ้น ทางที่สั้นที่สุดจึงมีไม่เกิน V - 1 เส้น ให้ทางที่สั้นที่สุดถึง v เป็น 1 = x0, x1, ..., xk = v รอบที่ 1 ผ่อนถนนทุกเส้น ซึ่งรวมถนน x0 ไป x1 ด้วย dist[x1] จึงถูกแล้ว รอบที่ 2 ผ่อนถนน x1 ไป x2 ด้วย dist[x1] ที่ถูกแล้ว dist[x2] จึงถูก ไล่ไปเรื่อยๆ หลังรอบที่ k dist[v] ถูกแน่นอน และ k ไม่เกิน V - 1
#include <bits/stdc++.h>
using namespace std;
const long long INF = 1e18;
int main()
{
int n, m;
cin >> n >> m;
vector<int> from(m), to(m), cost(m); // (1)!
for (int i = 0; i < m; i++) {
cin >> from[i] >> to[i] >> cost[i];
}
vector<long long> dist(n + 1, INF);
dist[1] = 0;
for (int round = 1; round <= n - 1; round++) { // (2)!
bool changed = false;
for (int i = 0; i < m; i++) {
int u = from[i], v = to[i];
if (dist[u] != INF && dist[u] + cost[i] < dist[v]) { // (3)!
dist[v] = dist[u] + cost[i];
changed = true;
}
}
cout << "after round " << round << ":";
for (int u = 1; u <= n; u++) {
if (dist[u] == INF) {
cout << " inf";
} else {
cout << ' ' << dist[u];
}
}
cout << '\n';
if (!changed) { // (4)!
break;
}
}
return 0;
}
- เก็บถนนเป็นรายการ เส้นที่ i วิ่งจาก
from[i]ไปto[i]ค่าใช้จ่ายcost[i] - วนไม่เกิน V - 1 รอบ แต่ละรอบผ่อนถนนครบทุกเส้น
- ผ่อนเฉพาะถนนที่ต้นทางไปถึงแล้ว เหตุผลอยู่ในส่วนลองทำให้พัง
- รอบไหนไม่มีอะไรเปลี่ยน รอบต่อไปก็ไม่มีอะไรเปลี่ยนเหมือนกัน เพราะผ่อนด้วยค่าชุดเดิม หยุดได้เลย
4 4
2 4 3
3 2 -4
1 3 5
1 2 2
after round 1: 0 2 5 inf
after round 2: 0 1 5 5
after round 3: 0 1 5 4
กราฟเดิม แต่เรียงถนนกลับด้าน รอบที่ 1 ถนนที่ออกจากจุด 2 กับจุด 3 ถูกผ่อนก่อนที่สองจุดนี้จะไปถึงได้ จึงไม่เกิดอะไร ได้แค่ระยะของถนนที่ออกจากจุด 1 รอบที่ 2 ได้ทางที่มีสองเส้น จุด 2 ลดเป็น 1 รอบที่ 3 ได้ทางที่มีสามเส้น จุด 4 เป็น 4 ถูกต้อง ลำดับของถนนมีผลกับจำนวนรอบที่ต้องใช้จริง แต่ไม่มีผลกับคำตอบ เพราะ V - 1 รอบพอเสมอ ถ้าเรียงถนนตามลำดับเดิมในหัวข้อก่อน รอบแรกก็ได้ 0 1 5 4 ครบแล้ว
แต่ละรอบดูถนน E เส้น วนไม่เกิน V - 1 รอบ จึงเป็น O(VE) ช้ากว่า Dijkstra มาก แลกกับการรับน้ำหนักติดลบได้
วงติดลบ
ถ้ามีวงที่ผลรวมน้ำหนักติดลบ เช่น วิ่ง 2 ไป 3 ไป 4 แล้วกลับ 2 ได้กำไรรอบละ 1 บาท ก็วนวงนี้กี่รอบก็ได้ ค่าใช้จ่ายลดลงไม่มีที่สิ้นสุด ทางที่สั้นที่สุดจึงไม่มีอยู่จริงสำหรับทุกจุดที่ไปถึงได้หลังผ่านวงนี้ โจทย์มักให้ตอบว่า "ลดได้ไม่สิ้นสุด" สำหรับจุดเหล่านั้น
ตรวจด้วยข้อสังเกตจากหัวข้อก่อน ถ้าไม่มีวงติดลบ หลัง V - 1 รอบจะไม่มีถนนไหนผ่อนได้อีก ถ้ารอบที่ V ยังผ่อนได้ แปลว่ามีวงติดลบที่ไปถึงจากจุดเริ่มได้แน่นอน ส่วนจุดที่ได้รับผลกระทบ คือจุดที่ยังผ่อนได้ กับทุกจุดที่เดินต่อจากจุดพวกนั้นได้ วนผ่อนอีก V รอบพร้อมส่งเครื่องหมายต่อไปตามถนน ก็ทั่วถึงทุกจุด
#include <bits/stdc++.h>
using namespace std;
const long long INF = 1e18;
int main()
{
int n, m;
cin >> n >> m;
vector<int> from(m), to(m), cost(m);
for (int i = 0; i < m; i++) {
cin >> from[i] >> to[i] >> cost[i];
}
vector<long long> dist(n + 1, INF);
dist[1] = 0;
for (int round = 1; round <= n - 1; round++) {
for (int i = 0; i < m; i++) {
int u = from[i], v = to[i];
if (dist[u] != INF && dist[u] + cost[i] < dist[v]) {
dist[v] = dist[u] + cost[i];
}
}
}
vector<bool> endless(n + 1, false);
for (int round = 1; round <= n; round++) { // (1)!
for (int i = 0; i < m; i++) {
int u = from[i], v = to[i];
if (dist[u] == INF) {
continue;
}
if (endless[u] || dist[u] + cost[i] < dist[v]) { // (2)!
endless[v] = true;
dist[v] = min(dist[v], dist[u] + cost[i]);
}
}
}
for (int u = 1; u <= n; u++) {
cout << u << ": ";
if (dist[u] == INF) {
cout << "unreachable" << '\n';
} else if (endless[u]) {
cout << "-inf" << '\n';
} else {
cout << dist[u] << '\n';
}
}
return 0;
}
- หลัง V - 1 รอบแรก วนต่ออีก V รอบ เครื่องหมายเดินได้รอบละอย่างน้อยหนึ่งเส้น V รอบจึงไปถึงทุกจุดที่ไปถึงได้
- ปลายถนนได้รับผลกระทบถ้ายังผ่อนได้ หรือต้นถนนได้รับผลกระทบอยู่แล้ว
8 8
1 2 4
2 3 -2
3 4 3
4 2 -2
4 5 1
1 6 5
6 5 2
7 8 -3
1: 0
2: -inf
3: -inf
4: -inf
5: -inf
6: 5
7: unreachable
8: unreachable
วง 2 3 4 รวม -2 + 3 - 2 = -1 จุด 2 3 4 จึงได้ -inf จุด 5 ไปถึงได้จากจุด 4 จึงได้ด้วย ส่วนจุด 6 ไปได้แค่จากจุด 1 โดยตรง ไม่ผ่านวง จึงได้ 5 ตามปกติ จุด 7 กับ 8 ไม่มีทางไปจากจุด 1 เลย
หาวงติดลบที่ไหนก็ได้ในกราฟ
โปรแกรมนี้สนใจเฉพาะวงที่ไปถึงจากจุด 1 ได้ ถ้าโจทย์ถามว่า "มีวงติดลบอยู่ที่ใดในกราฟหรือไม่" ให้เริ่มทุกจุดที่ dist เป็น 0 แทนอนันต์ เหมือนมีจุดเริ่มสมมติที่มีถนนยาว 0 ไปทุกจุด แบบหลายจุดเริ่มในหน้า 28.3 แล้วดูว่ารอบที่ V ยังผ่อนได้หรือไม่
เร็วแค่ไหน
วิธีตรงไปตรงมาที่รับน้ำหนักติดลบได้คือลองทุกเส้นทางแบบหน้า 28.1 ซึ่งที่ 14 จุดก็ใช้เวลาเป็นสิบวินาทีแล้ว Bellman-Ford ช้ากว่า Dijkstra แต่ยังอยู่ในระดับพหุนาม วัดบนเครื่องเดียวกับหน้า 28.1 กราฟสุ่มที่มีถนนติดลบแต่ไม่มีวงติดลบ สร้างโดยสุ่มความสูง h ให้ทุกจุด แล้วให้ถนน u ไป v มีค่า สุ่มบวก + h[u] - h[v] วงใดๆ ผลรวมของ h หักล้างกันหมด จึงเหลือแต่ส่วนที่เป็นบวก แถวที่เป็นเส้นยาวคือกราฟที่ทางที่สั้นที่สุดไปจุดสุดท้ายผ่านทุกจุดเรียงกัน และถนนเส้นนั้นอยู่ในรายการกลับด้าน ค่ากลาง 5 รอบ -O2
| กราฟ | แบบ | จำนวนรอบที่วน | เวลา |
|---|---|---|---|
| V = 104, E = 5 × 104 | สุ่ม วนครบ V - 1 รอบ | 9999 | 0.297 วินาที |
| สุ่ม หยุดเมื่อไม่เปลี่ยน | 12 | 0.001 วินาที | |
| เส้นยาว หยุดเมื่อไม่เปลี่ยน | 9999 | 0.280 วินาที | |
| V = 105, E = 2 × 105 | สุ่ม หยุดเมื่อไม่เปลี่ยน | 10 | 0.003 วินาที |
| เส้นยาว หยุดเมื่อไม่เปลี่ยน | 99999 | 12.253 วินาที (วัด 3 รอบ) |
กราฟสุ่มทางที่สั้นที่สุดมีแค่สิบกว่าเส้น การหยุดเมื่อไม่มีอะไรเปลี่ยนจึงจบเร็วมาก แต่ชุดทดสอบที่ตั้งใจดักจะให้ทางยาวแบบแถวเส้นยาว ซึ่งต้องวนเกือบครบ V - 1 รอบ ที่ V เป็นแสนใช้กว่าสิบวินาที ขณะที่ Dijkstra กับกราฟขนาดเดียวกันในหน้า 28.2 ใช้ไม่ถึงหนึ่งในสิบวินาที จึงประเมินเวลาด้วย V × E เสมอ ลูปของ Bellman-Ford เรียบง่ายมาก แถวแรกที่ V × E เป็น 5 × 108 ยังใช้ราว 0.3 วินาที แต่ถ้าเกินระดับ 109 ให้ดูว่าโจทย์มีน้ำหนักติดลบจริงหรือไม่
ลองทำให้พัง
แบบที่ 1 ผ่อนจากจุดที่ไปไม่ถึง
ในโปรแกรม bf-cycle ลบ dist[u] != INF && ออกจากการผ่อน V - 1 รอบแรก เพราะคิดว่าอนันต์บวกอะไรก็ยังมากกว่าค่าอื่น
#include <bits/stdc++.h>
using namespace std;
const long long INF = 1e18;
int main()
{
int n, m;
cin >> n >> m;
vector<int> from(m), to(m), cost(m);
for (int i = 0; i < m; i++) {
cin >> from[i] >> to[i] >> cost[i];
}
vector<long long> dist(n + 1, INF);
dist[1] = 0;
for (int round = 1; round <= n - 1; round++) {
for (int i = 0; i < m; i++) {
int u = from[i], v = to[i];
if (dist[u] + cost[i] < dist[v]) {
dist[v] = dist[u] + cost[i];
}
}
}
vector<bool> endless(n + 1, false);
for (int round = 1; round <= n; round++) { // (1)!
for (int i = 0; i < m; i++) {
int u = from[i], v = to[i];
if (dist[u] == INF) {
continue;
}
if (endless[u] || dist[u] + cost[i] < dist[v]) { // (2)!
endless[v] = true;
dist[v] = min(dist[v], dist[u] + cost[i]);
}
}
}
for (int u = 1; u <= n; u++) {
cout << u << ": ";
if (dist[u] == INF) {
cout << "unreachable" << '\n';
} else if (endless[u]) {
cout << "-inf" << '\n';
} else {
cout << dist[u] << '\n';
}
}
return 0;
}
8 8
1 2 4
2 3 -2
3 4 3
4 2 -2
4 5 1
1 6 5
6 5 2
7 8 -3
1: 0
2: -inf
3: -inf
4: -inf
5: -inf
6: 5
7: unreachable
8: 999999999999999997
จุด 8 ไปไม่ถึงจากจุด 1 แต่ได้ระยะเป็นเลขยาวเหยียด เพราะถนน 7 ไป 8 ค่า -3 ทำให้ INF - 3 น้อยกว่า INF จึงถูกเก็บ พอตรวจตอนพิมพ์ว่าเท่ากับ INF หรือไม่ ก็ไม่เท่า ข้อนี้เกิดเฉพาะเมื่อมีน้ำหนักติดลบ Dijkstra ในหน้าก่อนๆ ไม่เคยเจอ เพราะไม่เคยหยิบจุดที่ไปไม่ถึงออกมาผ่อน ถ้าถนนที่ออกจากจุดที่ไปไม่ถึงมีค่าเป็นบวกและใช้ INT_MAX หรือ LLONG_MAX เป็นอนันต์ ก็จะล้นเป็นค่าติดลบแบบหน้า 28.1
แบบที่ 2 ถนนสองทางที่ติดลบ
ถ้าโจทย์ให้ถนนวิ่งได้สองทางแล้วใส่ทั้งสองทิศลงรายการ ถนนที่ติดลบแค่เส้นเดียวก็คือวงติดลบแล้ว เพราะวิ่งไปกลับบนถนนเส้นนั้นได้กำไรทุกเที่ยว ป้อนให้โปรแกรม bf-cycle โดยถนน 2 กับ 3 ค่า -1 วิ่งได้สองทาง
3 4
1 2 5
2 1 5
2 3 -1
3 2 -1
1: -inf
2: -inf
3: -inf
ทุกจุดเป็น -inf รวมทั้งจุดเริ่มเอง เพราะไปจุด 2 แล้ววนกลับมาได้ น้ำหนักติดลบจึงมีความหมายเฉพาะกับกราฟมีทิศทาง กราฟไม่มีทิศทางที่มีถนนติดลบ คำตอบเป็น -inf ทุกจุดที่ไปถึงถนนเส้นนั้นได้ ต้องอ่านโจทย์ให้ชัดว่าถนนเป็นทางเดียวหรือสองทาง
สรุป
- Dijkstra ถูกเฉพาะเมื่อน้ำหนักไม่ติดลบ ถ้ามีถนนติดลบ คำตอบผิดได้ และแบบ priority_queue ไม่มีเวลาที่รับประกัน
- Bellman-Ford ผ่อนถนนทุกเส้น V - 1 รอบ เป็น O(VE) หยุดก่อนได้เมื่อรอบไหนไม่มีอะไรเปลี่ยน
- ผ่อนเฉพาะถนนที่ต้นทางไปถึงแล้ว
dist[u] != INF - ถ้ารอบที่ V ยังผ่อนได้ มีวงติดลบ วนต่ออีก V รอบเพื่อส่งเครื่องหมายไปทุกจุดที่ได้รับผลกระทบ
- ถนนสองทางที่ติดลบคือวงติดลบในตัวมันเอง