Bài 23.2. Ứng dụng Tarjan trong tìm cầu

Bài 23.2. Ứng dụng Tarjan trong tìm cầu
Chào mừng các bạn quay trở lại với chuỗi bài viết về Cấu trúc dữ liệu và Giải thuật! Hôm nay, chúng ta sẽ lặn sâu vào một trong những giải thuật trên đồ thị mạnh mẽ và thanh lịch nhất: Giải thuật Tarjan. Cụ thể, chúng ta sẽ khám phá ứng dụng của nó trong việc xác định cầu trong đồ thị.
Cầu Là Gì? Tại Sao Lại Quan Trọng?
Trong lý thuyết đồ thị, một cầu (bridge) là một cạnh mà khi ta loại bỏ nó, số lượng các thành phần liên thông (connected components) trong đồ thị sẽ tăng lên. Nói cách khác, cầu là những cạnh quan trọng bậc nhất trong việc giữ cho đồ thị được "kết nối" với nhau.
Hãy tưởng tượng đồ thị của chúng ta mô tả một mạng lưới đường bộ, nơi các đỉnh là thành phố và các cạnh là con đường nối chúng. Nếu một con đường là cầu, điều đó có nghĩa là nếu con đường đó bị đóng, việc di chuyển giữa hai nhóm thành phố sẽ trở nên bất khả thi. Điều này có ý nghĩa sống còn trong nhiều ứng dụng thực tế:
- Mạng máy tính: Tìm cầu giúp xác định các kết nối yếu nhất, nếu bị đứt sẽ chia mạng thành nhiều phần.
- Mạng giao thông: Tìm cầu giúp xác định các tuyến đường thiết yếu mà việc tắc nghẽn hoặc phá hủy sẽ gây ảnh hưởng lớn đến luồng di chuyển.
- Mạng xã hội: Tìm cầu có thể chỉ ra những mối quan hệ hoặc cá nhân đóng vai trò là "người giữ cổng" (gatekeeper) giữa các nhóm khác nhau.
- Phân tích mạch điện: Xác định các phần tử quan trọng trong mạch.
Việc xác định các cầu là một bài toán cơ bản nhưng có ứng dụng rộng rãi, và giải thuật Tarjan cung cấp một cách tiếp cận hiệu quả để giải quyết nó.
Giới Thiệu Giải Thuật Tarjan
Giải thuật Tarjan, được phát triển bởi Robert Tarjan, là một giải thuật dựa trên duyệt đồ thị theo chiều sâu (DFS). Nó nổi tiếng với khả năng tìm các thành phần liên thông mạnh (strongly connected components) trong đồ thị có hướng, nhưng ý tưởng cốt lõi và các biến thể của nó cũng cực kỳ hiệu quả trong việc tìm các tính chất khác của đồ thị, bao gồm cả việc tìm cầu và khớp nối (articulation points).
Điểm mấu chốt của Tarjan là trong quá trình DFS, nó duy trì hai thông tin quan trọng cho mỗi đỉnh u
:
disc[u]
(discovery time): Thời điểm (bước đếm) mà đỉnhu
được lần đầu tiên thăm trong quá trình DFS. Chúng ta dùng một biến đếm (timer
hoặctime
) tăng dần sau mỗi lần gándisc
.low[u]
(lowest reachable ancestor time): Thời điểm khám phá nhỏ nhất (minimumdisc
value) mà đỉnhu
hoặc bất kỳ đỉnh nào trong cây con DFS gốcu
có thể truy cập tới được thông qua các cạnh cây DFS và có thể sử dụng tối đa một cạnh ngược (back-edge).
Ý nghĩa của low[u]
là gì? Nếu low[u]
nhỏ hơn disc[u]
, điều đó có nghĩa là có một đường đi từ u
(hoặc một đỉnh trong cây con của nó) sử dụng một cạnh ngược để quay trở lại một đỉnh đã thăm trước u
trong cây DFS (một tổ tiên của u
). Ngược lại, nếu low[u] == disc[u]
, điều đó gợi ý rằng u
là "gốc" của một cấu trúc mà không có cạnh ngược nào thoát ra khỏi cây con của nó để lên phía trên u
.
Ứng Dụng Tarjan Tìm Cầu
Bây giờ, làm thế nào để sử dụng hai giá trị disc
và low
này để tìm cầu?
Xét một cạnh (u, v)
trong đồ thị, trong đó v
là một đỉnh con của u
trong cây DFS (nghĩa là ta đi từ u
đến v
lần đầu tiên trong DFS). Cạnh (u, v)
là một cầu khi và chỉ khi low[v] > disc[u]
.
Tại sao điều kiện này lại đúng?
disc[u]
là thời điểmu
được thăm.low[v]
là thời điểm khám phá nhỏ nhất mà ta có thể đạt được từv
hoặc bất kỳ đỉnh nào trong cây con củav
(bao gồm cả các cạnh ngược đi từ cây con củav
).- Nếu
low[v] > disc[u]
, điều này có nghĩa là không có đường đi nào từv
(hoặc bất kỳ đỉnh nào trong cây con củav
) mà sử dụng một cạnh ngược (hoặc kết hợp cạnh ngược với các cạnh cây) để đi tới đỉnhu
hoặc bất kỳ tổ tiên nào củau
. Nói cách khác, tất cả các đường đi từ cây con củav
ra phần còn lại của đồ thị đều phải đi qua cạnh(u, v)
. - Do đó, nếu ta loại bỏ cạnh
(u, v)
, cây con gốcv
sẽ hoàn toàn bị tách khỏiu
và phần còn lại của đồ thị đã được thăm trướcu
, làm tăng số thành phần liên thông.
Các Bước Giải Thuật
Để áp dụng Tarjan tìm cầu, chúng ta thực hiện một quá trình DFS với các bước sau:
- Khởi tạo các mảng
disc
,low
,visited
. Gán giá trị khởi tạo thích hợp (ví dụ:-1
chodisc
,low
,false
chovisited
). - Khởi tạo biến đếm thời gian
timer = 0
. - Khởi tạo một danh sách hoặc vector để lưu trữ các cầu tìm được.
- Duyệt qua tất cả các đỉnh trong đồ thị. Nếu một đỉnh
u
chưa được thăm, gọi hàmDFS(u, -1)
(tham số-1
biểu thịu
là gốc của cây DFS hiện tại, không có đỉnh cha). Điều này đảm bảo chúng ta xử lý các thành phần liên thông khác nhau. - Hàm
DFS(u, parent)
:- Đánh dấu
u
là đã thăm (visited[u] = true
). - Gán
disc[u] = low[u] = timer++
. - Duyệt qua tất cả các đỉnh kề
v
củau
.- Trường hợp 1:
v
là đỉnh cha (v == parent
). Bỏ qua cạnh này. Đây là cạnh cây đi ngược lên cha, không phải cạnh ngược mà chúng ta dùng để cập nhậtlow
. - Trường hợp 2:
v
đã thăm (visited[v] == true
). Điều này có nghĩa(u, v)
là một cạnh ngược. Cập nhậtlow[u]
:low[u] = min(low[u], disc[v])
. Chúng ta dùngdisc[v]
chứ không phảilow[v]
vì chúng ta chỉ xét một cạnh ngược trực tiếp từu
đếnv
. - Trường hợp 3:
v
chưa thăm (visited[v] == false
). Điều này có nghĩa(u, v)
là một cạnh cây.- Gọi đệ quy
DFS(v, u)
. - Sau khi lời gọi đệ quy trả về (tức là toàn bộ cây con gốc
v
đã được duyệt), cập nhậtlow[u]
dựa trênlow[v]
:low[u] = min(low[u], low[v])
. Điều này truyền giá trịlow
nhỏ nhất từ cây con củav
lênu
. - Kiểm tra cầu: Nếu
low[v] > disc[u]
, thì cạnh(u, v)
là một cầu. Lưu cặp(u, v)
vào danh sách cầu.
- Gọi đệ quy
- Trường hợp 1:
- Đánh dấu
Cài Đặt Bằng C++
Chúng ta sẽ cài đặt giải thuật này bằng C++, sử dụng danh sách kề để biểu diễn đồ thị.
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// Các mảng và biến cần thiết cho Tarjan's Bridge Algorithm
vector<int> disc; // Thời điểm khám phá (discovery time)
vector<int> low; // Thời điểm khám phá nhỏ nhất reachable từ đỉnh u hoặc cây con của nó
vector<bool> visited; // Đánh dấu đỉnh đã thăm
int timer; // Biến đếm thời gian
vector<pair<int, int>> bridges; // Lưu trữ các cầu tìm được
// Hàm DFS chính để tìm cầu
void bridgeDFS(int u, int parent, const vector<vector<int>>& adj) {
// Đánh dấu u đã thăm, gán disc[u] và low[u]
visited[u] = true;
disc[u] = low[u] = timer++;
// Duyệt qua tất cả các đỉnh kề v của u
for (int v : adj[u]) {
// Nếu v là cha của u trong cây DFS, bỏ qua
if (v == parent) {
continue;
}
// Nếu v đã được thăm (v là tổ tiên của u trong cây DFS)
if (visited[v]) {
// Cập nhật low[u]. Đây là một cạnh ngược.
low[u] = min(low[u], disc[v]);
} else {
// Nếu v chưa được thăm (v là con của u trong cây DFS)
// Gọi đệ quy cho v
bridgeDFS(v, u, adj);
// Sau khi đệ quy trả về, cập nhật low[u] dựa trên low[v]
low[u] = min(low[u], low[v]);
// Kiểm tra điều kiện cầu: low[v] > disc[u]
// Nếu đúng, cạnh (u, v) là một cầu
if (low[v] > disc[u]) {
bridges.push_back({u, v});
}
}
}
}
// Hàm wrapper để tìm cầu cho toàn bộ đồ thị
void findBridges(int n, const vector<vector<int>>& adj) {
disc.assign(n, -1);
low.assign(n, -1);
visited.assign(n, false);
timer = 0;
bridges.clear(); // Xóa danh sách cầu cũ
// Duyệt qua tất cả các đỉnh để đảm bảo xử lý các thành phần liên thông
for (int i = 0; i < n; ++i) {
if (!visited[i]) {
bridgeDFS(i, -1, adj); // Bắt đầu DFS từ đỉnh chưa thăm
}
}
}
int main() {
// Ví dụ 1: Đồ thị đơn giản có cầu
// Cấu trúc: 0-1, 1-2, 2-0 (chu trình), 1-3 (cầu), 3-4
int n1 = 5; // Số đỉnh
vector<vector<int>> adj1(n1);
adj1[0].push_back(1); adj1[1].push_back(0);
adj1[1].push_back(2); adj1[2].push_back(1);
adj1[2].push_back(0); adj1[0].push_back(2);
adj1[1].push_back(3); adj1[3].push_back(1);
adj1[3].push_back(4); adj1[4].push_back(3);
cout << "Tim cau cho do thi 1:" << endl;
findBridges(n1, adj1);
if (bridges.empty()) {
cout << "Khong co cau nao." << endl;
} else {
cout << "Cac cau tim duoc:" << endl;
for (const auto& bridge : bridges) {
cout << "(" << bridge.first << ", " << bridge.second << ")" << endl;
}
}
cout << "---" << endl;
// Ví dụ 2: Đồ thị phức tạp hơn với nhiều chu trình
// Cấu trúc: 0-1, 1-2, 2-0, 1-3, 3-4, 4-5, 5-3 (chu trình), 4-6 (cầu)
int n2 = 7; // Số đỉnh
vector<vector<int>> adj2(n2);
adj2[0].push_back(1); adj2[1].push_back(0);
adj2[1].push_back(2); adj2[2].push_back(1);
adj2[2].push_back(0); adj2[0].push_back(2);
adj2[1].push_back(3); adj2[3].push_back(1);
adj2[3].push_back(4); adj2[4].push_back(3);
adj2[4].push_back(5); adj2[5].push_back(4);
adj2[5].push_back(3); adj2[3].push_back(5);
adj2[4].push_back(6); adj2[6].push_back(4);
cout << "Tim cau cho do thi 2:" << endl;
findBridges(n2, adj2);
if (bridges.empty()) {
cout << "Khong co cau nao." << endl;
} else {
cout << "Cac cau tim duoc:" << endl;
for (const auto& bridge : bridges) {
cout << "(" << bridge.first << ", " << bridge.second << ")" << endl;
}
}
cout << "---" << endl;
// Ví dụ 3: Đồ thị không có cầu
// Cấu trúc: 0-1, 1-2, 2-0
int n3 = 3;
vector<vector<int>> adj3(n3);
adj3[0].push_back(1); adj3[1].push_back(0);
adj3[1].push_back(2); adj3[2].push_back(1);
adj3[2].push_back(0); adj3[0].push_back(2);
cout << "Tim cau cho do thi 3:" << endl;
findBridges(n3, adj3);
if (bridges.empty()) {
cout << "Khong co cau nao." << endl;
} else {
cout << "Cac cau tim duoc:" << endl;
for (const auto& bridge : bridges) {
cout << "(" << bridge.first << ", " << bridge.second << ")" << endl;
}
}
cout << "---" << endl;
return 0;
}
Giải Thích Mã Nguồn C++
Đoạn mã trên minh họa cách cài đặt giải thuật Tarjan để tìm cầu:
Các biến toàn cục/phạm vi hàm:
disc
:vector<int>
lưu trữ thời điểm thăm (discovery time
) của mỗi đỉnh. Khởi tạo-1
để chỉ đỉnh chưa thăm.low
:vector<int>
lưu trữ thời điểm thăm nhỏ nhất (low-link value
) mà từ đỉnh hiện tại hoặc cây con của nó có thể truy cập được. Khởi tạo-1
.visited
:vector<bool>
đánh dấu đỉnh đã được thăm trong quá trình DFS hay chưa. Khởi tạofalse
.timer
: Biến đếm thời gian, tăng sau mỗi lần gándisc
. Bắt đầu từ 0.bridges
:vector<pair<int, int>>
để lưu trữ các cặp đỉnh(u, v)
tạo thành một cầu.
Hàm
bridgeDFS(int u, int parent, const vector<vector<int>>& adj)
:- Đây là hàm thực hiện quá trình duyệt đồ thị theo chiều sâu.
u
: Đỉnh hiện tại đang xét.parent
: Đỉnh mà chúng ta vừa đi từ đó đếnu
trong cây DFS. Cần thiết để phân biệt cạnh cây đi ngược lên cha với cạnh ngược thực sự.adj
: Danh sách kề biểu diễn đồ thị.visited[u] = true; disc[u] = low[u] = timer++;
: Khi lần đầu tiên thămu
, ta đánh dấu đã thăm, gán thời điểm khám phádisc[u]
và khởi tạolow[u]
bằng chínhdisc[u]
. Biến đếmtimer
tăng lên.for (int v : adj[u])
: Lặp qua tất cả các đỉnhv
kề vớiu
.if (v == parent) continue;
: Nếuv
là đỉnh cha, bỏ qua. Ta không coi cạnh quay lại cha là một cạnh ngược ảnh hưởng đếnlow
.if (visited[v]) { low[u] = min(low[u], disc[v]); }
: Nếuv
đã thăm và không phải là cha,(u, v)
là cạnh ngược. Ta cập nhậtlow[u]
bằng cách lấy giá trị nhỏ nhất giữalow[u]
hiện tại vàdisc[v]
. Điều này phản ánh rằng từu
có thể đi qua cạnh ngược(u, v)
đến một đỉnh đã thăm sớm hơn (disc[v]
).else { bridgeDFS(v, u, adj); low[u] = min(low[u], low[v]); ... }
: Nếuv
chưa thăm,(u, v)
là cạnh cây.- Gọi đệ quy
bridgeDFS(v, u, adj)
để duyệt cây con gốcv
. - Sau khi đệ quy trả về, cập nhật
low[u] = min(low[u], low[v])
. Điều này đảm bảo rằnglow[u]
phản ánh giá trịlow
nhỏ nhất có thể đạt được từ bất kỳ đỉnh nào trong cây con gốcv
(bao gồm cả việc sử dụng cạnh ngược từ cây con củav
). if (low[v] > disc[u]) { bridges.push_back({u, v}); }
: Đây là điều kiện kiểm tra cầu quan trọng nhất. Nếu thời điểm khám phá nhỏ nhất reachable từ cây con củav
(low[v]
) lớn hơn thời điểm khám phá củau
(disc[u]
), điều đó có nghĩa là không có cạnh ngược nào từ cây con củav
có thể đi ngược lênu
hoặc tổ tiên củau
. Cạnh(u, v)
chính là cầu.
- Gọi đệ quy
Hàm
findBridges(int n, const vector<vector<int>>& adj)
:- Hàm này là điểm khởi đầu. Nó nhận số đỉnh
n
và danh sách kềadj
. - Nó khởi tạo lại các mảng
disc
,low
,visited
và biến đếmtimer
. - Nó lặp qua tất cả các đỉnh từ
0
đếnn-1
. Nếu một đỉnh chưa được thăm, nó gọibridgeDFS
từ đỉnh đó. Điều này đảm bảo rằng giải thuật hoạt động chính xác ngay cả với đồ thị không liên thông.
- Hàm này là điểm khởi đầu. Nó nhận số đỉnh
Hàm
main()
:- Chứa các ví dụ minh họa với cấu trúc đồ thị cụ thể.
- Tạo danh sách kề cho từng ví dụ. Lưu ý rằng với đồ thị vô hướng, nếu có cạnh
(u, v)
, ta phải thêm cảv
vào danh sách kề củau
vàu
vào danh sách kề củav
. - Gọi
findBridges
cho mỗi đồ thị. - In ra kết quả tìm được.
Ví Dụ Minh Họa Chi Tiết
Chúng ta hãy xem xét Ví dụ 1: Đồ thị với các cạnh (0,1), (1,2), (2,0), (1,3), (3,4).
Đỉnh | disc | low | visited | Cạnh đang xét | Action |
---|---|---|---|---|---|
findBridges(5, adj1) |
disc:{-1,-1,-1,-1,-1}, low:{-1,-1,-1,-1,-1}, visited:{F,F,F,F,F}, timer:0 | - | - | - | |
- | - | - | - | Bắt đầu DFS từ 0 (chưa thăm) | bridgeDFS(0, -1, ...) |
bridgeDFS(0, -1) |
visited[0]=T, disc[0]=low[0]=0, timer=1 | 0 | T,F,F,F,F | - | |
- | - | - | - | Kề của 0: 1, 2 | |
- | - | - | - | Xét kề 1 (chưa thăm) | bridgeDFS(1, 0, ...) |
bridgeDFS(1, 0) |
visited[1]=T, disc[1]=low[1]=1, timer=2 | 0,1 | T,T,F,F,F | - | |
- | - | - | - | Kề của 1: 0, 2, 3 | |
- | - | - | - | Xét kề 0 (là cha) | continue |
- | - | - | - | Xét kề 2 (chưa thăm) | bridgeDFS(2, 1, ...) |
bridgeDFS(2, 1) |
visited[2]=T, disc[2]=low[2]=2, timer=3 | 0,1,2 | T,T,T,F,F | - | |
- | - | - | - | Kề của 2: 1, 0 | |
- | - | - | - | Xét kề 1 (là cha) | continue |
- | - | - | - | Xét kề 0 (đã thăm) | low[2] = min(low[2], disc[0]) = min(2, 0) = 0 |
bridgeDFS(2, 1) kết thúc |
disc:{0,1,2,-1,-1}, low:{0,1,0,-1,-1} | Trở về 1 | low[1] = min(low[1], low[2]) = min(1, 0) = 0 . Kiểm tra cầu (1,2): low2 <= disc1 -> KHÔNG phải cầu. |
||
- | disc:{0,1,2,-1,-1}, low:{0,0,0,-1,-1} | T,T,T,F,F | Tiếp tục ở 1: Xét kề 3 (chưa thăm) | bridgeDFS(3, 1, ...) |
|
bridgeDFS(3, 1) |
visited[3]=T, disc[3]=low[3]=3, timer=4 | 0,0,0,3 | T,T,T,T,F | - | |
- | - | - | - | Kề của 3: 1, 4 | |
- | - | - | - | Xét kề 1 (là cha) | continue |
- | - | - | - | Xét kề 4 (chưa thăm) | bridgeDFS(4, 3, ...) |
bridgeDFS(4, 3) |
visited[4]=T, disc[4]=low[4]=4, timer=5 | 0,0,0,3,4 | T,T,T,T,T | - | |
- | - | - | - | Kề của 4: 3 | |
- | - | - | - | Xét kề 3 (là cha) | continue |
bridgeDFS(4, 3) kết thúc |
disc:{0,0,0,3,4}, low:{0,0,0,3,4} | Trở về 3 | low[3] = min(low[3], low[4]) = min(3, 4) = 3 . Kiểm tra cầu (3,4): low4 > disc3 -> LÀ CẦU (3,4). Thêm (3,4) vào bridges . |
||
bridgeDFS(3, 1) kết thúc |
disc:{0,0,0,3,4}, low:{0,0,0,3,3} | Trở về 1 | low[1] = min(low[1], low[3]) = min(0, 3) = 0 . Kiểm tra cầu (1,3): low3 > disc1 -> LÀ CẦU (1,3). Thêm (1,3) vào bridges . |
||
bridgeDFS(1, 0) kết thúc |
disc:{0,0,0,3,4}, low:{0,0,0,3,3} | Trở về 0 | low[0] = min(low[0], low[1]) = min(0, 0) = 0 . Kiểm tra cầu (0,1): low1 <= disc0 -> KHÔNG phải cầu. |
||
- | disc:{0,0,0,3,4}, low:{0,0,0,3,3} | T,T,T,T,T | Tiếp tục ở 0: Xét kề 2 (đã thăm) | low[0] = min(low[0], disc[2]) = min(0, 2) = 0 . |
|
bridgeDFS(0, -1) kết thúc |
|||||
findBridges tiếp tục |
- | - | - | Duyệt các đỉnh còn lại (tất cả đã thăm) | - |
findBridges kết thúc |
In kết quả | Bridges: (3,4), (1,3) (hoặc (4,3), (3,1) tùy thứ tự thêm). |
Kết quả: Các cầu được tìm thấy là (1,3) và (3,4), đúng như mong đợi.
Độ Phức Tạp
- Thời gian: Giải thuật Tarjan dựa trên một lần duyệt DFS duy nhất. Trong quá trình DFS, mỗi đỉnh và mỗi cạnh đều được thăm một số lần hằng số. Do đó, độ phức tạp thời gian là O(V + E), trong đó V là số đỉnh và E là số cạnh. Đây là độ phức tạp tối ưu cho các giải thuật trên đồ thị phải thăm tất cả các đỉnh và cạnh.
- Không gian: Chúng ta sử dụng các mảng
disc
,low
,visited
có kích thước O(V), danh sách kề O(V + E), và stack đệ quy cho DFS có thể lên tới O(V) trong trường hợp xấu nhất (đồ thị dạng đường thẳng). Do đó, độ phức tạp không gian là O(V + E).
Bài tập ví dụ:
Giải cứu cô W
Trong một buổi nghiên cứu về dinh dưỡng, FullHouse Dev bắt gặp một câu chuyện thú vị về việc tăng cường sức mạnh thông qua việc ăn uống và nghỉ ngơi hợp lý. Điều này gợi nhớ đến câu chuyện về anh S và hành trình giải cứu cô W khỏi quái vật Lười.
Bài toán
Cô W đã bị quái vật Lười bắt cóc. Là bạn trai của cô W, anh S phải giải cứu cô ấy. Để đánh bại quái vật Lười và cứu cô W khỏi việc biến thành sinh vật lười, anh S cần đạt được một cấp độ sức mạnh nhất định.
Hiện tại, anh S đang ở cấp độ \(X\) và cần đạt đến cấp độ \(Y\). Có nhiều phương pháp chuyển đổi khác nhau:
- Nếu chọn phương pháp thứ \(i\), anh có thể chuyển từ cấp độ \(Ai\) sang cấp độ \(Bi\) với chi phí thể lực là \(Zi\) (và ngược lại).
- Tại cấp độ \(i\), anh có thể ăn Trái Ác Quỷ đặc biệt, khiến anh ngủ \(Hi\) giờ và sau đó thể lực sẽ bằng \(Ci\).
Anh S có thể chọn ăn Trái Ác Quỷ hoặc không. Có thể có nhiều cách khác nhau để di chuyển giữa các cấp độ với chi phí thời gian ngủ khác nhau.
INPUT FORMAT:
- Dòng đầu tiên chứa 4 số nguyên \(N\) (số cấp độ), \(M\) (số phương pháp chuyển đổi), \(A\) (cấp độ ban đầu), \(B\) (cấp độ cần đạt).
- \(M\) dòng tiếp theo, mỗi dòng chứa 3 số nguyên \(X\), \(Y\), \(Z\), thể hiện có thể chuyển từ cấp độ \(X\) sang cấp độ \(Y\) với chi phí thể lực \(Z\).
- \(N\) dòng cuối, mỗi dòng chứa 2 số nguyên \(Ci\), \(Hi\), thể hiện tại cấp độ \(i\), có thể đạt thể lực \(Ci\) sau khi ngủ \(Hi\) giờ.
OUTPUT FORMAT:
- Một số nguyên duy nhất là thời gian ngủ tối thiểu cần thiết để đạt được cấp độ mục tiêu. Nếu không thể đạt được, in ra -1.
Ràng buộc:
- \(1 \leq N \leq 1000\)
- \(0 \leq M \leq 1000\)
- \(1 \leq \text{Cấp\_độ\_ban\_đầu}, \text{Cấp\_độ\_mục\_tiêu} \leq N\)
- \(1 \leq Xi, Yi \leq N\)
- \(1 \leq Zi \leq 10^9\)
- \(1 \leq Ci, Hi \leq 10^9\)
Ví dụ:
INPUT
3 3 1 3 1 2 2 1 3 3 3 2 1 2 7 2 7 3 6
OUTPUT
14
Giải thích:
- Đầu tiên, anh S ăn Trái Ác Quỷ ở cấp độ 1, ngủ 7 giờ và đạt thể lực 2.
- Sau đó chuyển sang cấp độ 2, tốn 2 thể lực.
- Tiếp tục ăn Trái Ác Quỷ ở cấp độ 2, ngủ 7 giờ và đạt thể lực 2.
- Cuối cùng chuyển sang cấp độ 3 với chi phí 1 thể lực.
- Tổng thời gian ngủ = 7 + 7 = 14 giờ. Hướng dẫn giải bài "Giải cứu cô W" một cách ngắn gọn:
Bài toán yêu cầu tìm thời gian ngủ tối thiểu để đi từ cấp độ A đến cấp độ B. Thời gian chỉ phát sinh khi ăn Trái Ác Quỷ (ngủ). Việc di chuyển giữa các cấp độ bằng phương pháp chuyển đổi tốn thể lực, không tốn thời gian. Thể lực được phục hồi bằng cách ngủ.
Đây là bài toán tìm đường đi ngắn nhất trên đồ thị, nơi "chi phí" cần tối thiểu hóa là thời gian ngủ.
Mô hình hóa:
- Các cấp độ (1 đến N) là các đỉnh của đồ thị.
- Có hai loại "hành động" có thể thực hiện tại một cấp độ
u
:- Ngủ: Tốn
Hu
thời gian ngủ, đạtCu
thể lực. Hành động này không di chuyển anh S sang cấp độ khác ngay lập tức, nhưng thay đổi trạng thái (thời gian đã ngủ, thể lực hiện có) tại cấp độu
. - Di chuyển: Sử dụng một phương pháp chuyển đổi từ
u
sangv
(hoặc ngược lại) với chi phíZ
. Hành động này tốnZ
thể lực, không tốn thêm thời gian. Chỉ có thể thực hiện nếu anh S có đủZ
thể lực tại cấp độu
trước khi di chuyển.
- Ngủ: Tốn
Nhận xét quan trọng:
- Thời gian chỉ tăng khi ngủ.
- Thể lực là tài nguyên cần thiết cho việc di chuyển và được phục hồi bằng cách ngủ. Khi ngủ tại cấp độ
i
, anh S đạtCi
thể lực. Thể lực này có thể được sử dụng cho nhiều bước di chuyển liên tiếp cho đến khi anh S hết thể lực hoặc quyết định ngủ lại. - Khi anh S ngủ tại cấp độ
u
tốnHu
thời gian và đạtCu
thể lực, anh ta có thể thực hiện một chuỗi các bước di chuyển (0 thời gian) đến các cấp độ lân cận, miễn là tổng thể lực tiêu hao cho chuỗi di chuyển đó không vượt quáCu
. Bất kỳ cấp độ nàov
đạt được sau chuỗi di chuyển này đều được xem là đạt được với tổng thời gian ngủ bằng thời gian đã có khi đếnu
cộng thêmHu
.
Thuật toán: Sử dụng biến thể của thuật toán Dijkstra.
- Đỉnh của Dijkstra: Các cấp độ từ 1 đến N.
- Trọng số đường đi: Thời gian ngủ.
dist[u]
là thời gian ngủ tối thiểu để đạt được cấp độu
. - Hàng đợi ưu tiên (Priority Queue): Chứa các cặp
(thời gian, cấp độ)
, ưu tiên thời gian nhỏ hơn.
Khởi tạo:
- Nếu cấp độ ban đầu A bằng cấp độ mục tiêu B, thời gian ngủ tối thiểu là 0. In 0 và kết thúc.
- Khởi tạo
dist[i] = INF
(một giá trị rất lớn) cho tất cả các cấp đội
. - Cấp độ ban đầu là A. Để có thể di chuyển (nếu cần), anh S cần có thể lực. Cách duy nhất để có thể lực là ngủ. Giả sử hành động đầu tiên phải là ngủ tại cấp độ A.
- Sau khi ngủ lần đầu tại A: thời gian là
H[A]
, thể lực làC[A]
. Từ trạng thái này, anh S có thể di chuyển đến các cấp độ khác mà tổng thể lực tiêu hao không vượt quáC[A]
, với thời gian làH[A]
. - Thực hiện một thuật toán tìm đường đi ngắn nhất (ví dụ: Dijkstra) phụ trên đồ thị vật lý (các phương pháp chuyển đổi) để tìm tất cả các cấp độ
v
có thể đạt được từ A, với tổng chi phí thể lực từ A đếnv
không vượt quáC[A]
. Trọng số của các cạnh trong đồ thị phụ này là chi phí thể lựcZ
. - Đối với mỗi cấp độ
v
có thể đạt được trong bước này (với tổng thể lực tiêu haoS <= C[A]
), cập nhậtdist[v] = min(dist[v], H[A])
. Đẩy cặp(dist[v], v)
vào hàng đợi ưu tiên chính.
Thuật toán Dijkstra chính:
- Trong khi hàng đợi ưu tiên chính chưa rỗng:
- Lấy cặp
(d, u)
có thời gian nhỏ nhất từ hàng đợi.d
là thời gian ngủ tối thiểu để đạt cấp độu
. - Nếu
d > dist[u]
, bỏ qua (đã tìm thấy đường đi tốt hơn). - Nếu
u == B
, đây là thời gian ngủ tối thiểu để đạt B. Ind
và kết thúc thuật toán. - Xem xét hành động ngủ tại cấp độ u: Nếu ngủ tại
u
, tổng thời gian ngủ sẽ làd + H[u]
, và thể lực có được làC[u]
. - Từ trạng thái này (tại
u
, thời giand + H[u]
, thể lựcC[u]
), anh S có thể di chuyển đến các cấp độ khác dùng thể lựcC[u]
. - Thực hiện lại thuật toán tìm đường đi ngắn nhất phụ (Dijkstra) trên đồ thị vật lý bắt đầu từ
u
với "ngân sách" thể lực làC[u]
. Mục tiêu là tìm tất cả các cấp độv
có thể đạt được từu
mà tổng chi phí thể lực từu
đếnv
(trong lần di chuyển này sau khi ngủ tạiu
) không vượt quáC[u]
. - Trọng số cạnh trong đồ thị phụ là chi phí thể lực
Z
.min_s_used[v]
sẽ là tổng thể lực tối thiểu cần để đi từu
đếnv
trong lần di chuyển này. - Đối với mỗi cấp độ
v
màmin_s_used[v]
không phải là INF (tức làv
có thể đạt được từu
với tổng thể lực tiêu hao<= C[u]
):- Thời gian để đạt cấp độ
v
thông qua việc ngủ tạiu
rồi di chuyển làd + H[u]
. - Nếu
d + H[u] < dist[v]
, cập nhậtdist[v] = d + H[u]
và đẩy cặp(dist[v], v)
vào hàng đợi ưu tiên chính.
- Thời gian để đạt cấp độ
- Lấy cặp
- Trong khi hàng đợi ưu tiên chính chưa rỗng:
Kết quả:
- Nếu vòng lặp Dijkstra chính kết thúc mà chưa đạt được B (nghĩa là
dist[B]
vẫn là INF), in -1. - Ngược lại, in
dist[B]
.
- Nếu vòng lặp Dijkstra chính kết thúc mà chưa đạt được B (nghĩa là
Lưu ý:
- Sử dụng kiểu dữ liệu
long long
cho thời gian, thể lực và khoảng cách để tránh tràn số. - INF nên là một giá trị rất lớn, ví dụ
1e18
. - Thuật toán tìm đường đi ngắn nhất phụ (Dijkstra) bên trong có thể sử dụng mảng
min_s_used
riêng và hàng đợi ưu tiên riêng, khởi tạo lại cho mỗi lần chạy.
Comments