Đồ thị có hướng không có chu trình (DAG) mô tả các ràng buộc “việc phải làm trước việc ”: môn học tiên quyết, tệp phụ thuộc, công thức lắp ráp. Thứ tự tô pô là một cách xếp mọi đỉnh thành hàng sao cho mọi cung (cạnh có hướng) đều đi từ trái sang phải. Thứ tự này tồn tại khi và chỉ khi đồ thị không có chu trình, và nó là xương sống của quy hoạch động trên DAG: xử lý đỉnh theo thứ tự tô pô thì khi tới , mọi đỉnh đi tới đã có đáp án.
Thuật toán Kahn
Bậc vào của một đỉnh là số cung đi vào nó. Đỉnh có bậc vào bằng 0 không phải chờ ai, có thể đứng đầu hàng. Thuật toán Kahn lặp: lấy một đỉnh bậc vào 0, đưa vào thứ tự, gỡ mọi cung đi ra từ nó (giảm bậc vào của các đỉnh đích); đỉnh nào về bậc 0 thì đẩy vào hàng đợi. Mỗi đỉnh và mỗi cung được xử lý một lần, .
Nếu kết thúc mà thứ tự có ít hơn đỉnh, các đỉnh còn lại đều có bậc vào dương trong phần đồ thị còn lại: mỗi đỉnh có một đỉnh đi tới nó cũng còn lại, lần ngược mãi phải lặp, nên đồ thị có chu trình. Ngược lại, DAG luôn có ít nhất một đỉnh bậc vào 0 (đỉnh đầu của một đường dài nhất), nên Kahn không bao giờ kẹt khi không có chu trình.
Cách thứ hai dùng DFS: một đỉnh xong (đã duyệt hết các đỉnh đi ra) sau mọi đỉnh nó đi tới, nên đảo ngược thứ tự xong là một thứ tự tô pô. Tìm chu trình bằng DFS cần ba trạng thái: chưa thăm, đang trên ngăn xếp đệ quy, đã xong; gặp cung tới đỉnh đang trên ngăn xếp là có chu trình.
Hỏi nhanh
Kahn kết thúc khi hàng đợi rỗng và thứ tự mới có đỉnh. Kết luận nào đúng?
Mỗi đỉnh còn lại vẫn có một cung đi vào từ một đỉnh còn lại, nên lần ngược theo các cung đó phải quay lại một đỉnh đã gặp. Đồ thị không liên thông vẫn sắp được hết, và Kahn không phụ thuộc đỉnh bắt đầu.
Ví dụLịch Trình Tiên Quyết
Đề. chuyên đề, ràng buộc trước . In một thứ tự hợp lệ, hoặc IMPOSSIBLE.
Lời giải. Thuật toán Kahn: deg[v] là số cung đi vào chưa bị gỡ; đỉnh có deg = 0 không còn điều kiện nào phải chờ, xếp tiếp được. Đỉnh nằm trên một chu trình (hay phía sau chu trình) không bao giờ về 0, nên thứ tự thu được thiếu đỉnh nghĩa là có chu trình.
Bước 1: đếm bậc vào.
for (ll i = 1; i <= m; i++) {
ll a, b;
cin >> a >> b;
g[a].push_back(b);
deg[b]++;
}
Bước 2: lấy dần các đỉnh sẵn sàng. Mọi đỉnh bậc vào 0 vào hàng đợi trước. Lấy ra thì gỡ mọi cung ; đỉnh nào về 0 thì vào hàng đợi.
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
vector<ll> ord;
while (!q.empty()) {
ll u = q.front();
q.pop();
ord.push_back(u);
for (ll v : g[u])
if (--deg[v] == 0) q.push(v);
}
Bước 3: kiểm chu trình. ord có đủ đỉnh thì in, không thì in IMPOSSIBLE.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5;
ll n, m, deg[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll a, b;
cin >> a >> b;
g[a].push_back(b);
deg[b]++;
}
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
vector<ll> ord;
while (!q.empty()) {
ll u = q.front();
q.pop();
ord.push_back(u);
for (ll v : g[u])
if (--deg[v] == 0) q.push(v);
}
if ((ll)ord.size() < n) {
cout << "IMPOSSIBLE\n";
return 0;
}
for (ll u : ord) cout << u << ' ';
cout << '\n';
}
6 5
3 1
5 2
1 4
2 4
4 6
3 5 1 2 4 6
Mỗi đỉnh và mỗi cung được xử lý một lần: .
Thứ tự nhỏ nhất và thứ tự duy nhất
Kahn được tự do chọn đỉnh nào trong số các đỉnh bậc vào 0. Thay hàng đợi bằng hàng đợi ưu tiên lấy đỉnh nhỏ nhất thì được thứ tự tô pô nhỏ nhất theo từ điển: ở mỗi vị trí, đỉnh được chọn là đỉnh nhỏ nhất có thể đứng ở đó, và chọn nó không làm mất lựa chọn nào về sau. Chi phí thành .
Cùng lý lẽ đó, thứ tự tô pô là duy nhất khi và chỉ khi mỗi bước chỉ có đúng một đỉnh bậc vào 0. Có lúc hai đỉnh cùng sẵn sàng thì đổi chỗ chúng vẫn hợp lệ, vì không cung nào nối hai đỉnh đó (một cung sẽ làm một trong hai còn bậc vào dương). Thứ tự duy nhất cũng là thứ tự mà mọi cặp đỉnh liên tiếp có cung nối nhau.
Ví dụDãy Lệnh Ưu Tiên
Đề. lệnh, ràng buộc (có thể trùng). In thứ tự thực hiện nhỏ nhất theo từ điển, hoặc nếu không có.
Lời giải. Kahn như ví dụ trước, khác đúng một chỗ: trong các đỉnh đang sẵn sàng, luôn lấy đỉnh có số hiệu nhỏ nhất. Vì sao ra thứ tự nhỏ nhất theo từ điển: ở mỗi vị trí, đỉnh đặt được phải là đỉnh đang có bậc vào 0, và chọn đỉnh nhỏ nhất không làm đỉnh nào khác mất khả năng được đặt sau (nó chỉ gỡ thêm cung). Ràng buộc trùng làm deg[v] tăng hai lần và cũng bị gỡ hai lần, nên không cần lọc.
Bước 1: hàng đợi ưu tiên lấy số nhỏ nhất. priority_queue với greater thay cho queue.
priority_queue<ll, vector<ll>, greater<ll>> pq;
for (ll u = 1; u <= n; u++)
if (!deg[u]) pq.push(u);
vector<ll> ord;
while (!pq.empty()) {
ll u = pq.top();
pq.pop();
ord.push_back(u);
for (ll v : g[u])
if (--deg[v] == 0) pq.push(v);
}
Bước 2: kiểm chu trình như ví dụ trước, không đủ đỉnh thì in .
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, m, deg[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll a, b;
cin >> a >> b;
g[a].push_back(b);
deg[b]++;
}
priority_queue<ll, vector<ll>, greater<ll>> pq;
for (ll u = 1; u <= n; u++)
if (!deg[u]) pq.push(u);
vector<ll> ord;
while (!pq.empty()) {
ll u = pq.top();
pq.pop();
ord.push_back(u);
for (ll v : g[u])
if (--deg[v] == 0) pq.push(v);
}
if ((ll)ord.size() < n) {
cout << -1 << '\n';
return 0;
}
for (ll u : ord) cout << u << ' ';
cout << '\n';
}
6 6
3 1
3 5
2 5
1 4
5 4
4 6
2 3 1 5 4 6
vì hàng đợi ưu tiên.
Ví dụThứ Hạng Có Duy Nhất
Đề. đối tượng, quan hệ , luôn có ít nhất một cách xếp. Nếu hoán vị thứ hạng là duy nhất thì in Yes và , không thì No.
Lời giải. Kahn; nếu có lúc hàng đợi chứa từ hai đỉnh trở lên thì đổi thứ tự hai đỉnh đó vẫn hợp lệ, nên thứ hạng không duy nhất. Ngược lại mọi lúc chỉ có đúng một lựa chọn, và đỉnh được lấy ra thứ có hạng . Đề bảo đảm có ít nhất một cách xếp nên không cần kiểm chu trình.
Bước 1: Kahn có kiểm duy nhất. Kiểm q.size() > 1 mỗi vòng, trước khi lấy.
while (!q.empty()) {
if (q.size() > 1) {
cout << "No\n";
return 0;
}
ll u = q.front();
q.pop();
rk[u] = ++k;
for (ll v : g[u])
if (--deg[v] == 0) q.push(v);
}
Bước 2: in theo số hiệu. Đề đòi hạng của từng đối tượng, nên in rk[1..n], không in thứ tự các đối tượng.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, m, deg[N], rk[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll x, y;
cin >> x >> y;
g[x].push_back(y);
deg[y]++;
}
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
ll k = 0;
while (!q.empty()) {
if (q.size() > 1) {
cout << "No\n";
return 0;
}
ll u = q.front();
q.pop();
rk[u] = ++k;
for (ll v : g[u])
if (--deg[v] == 0) q.push(v);
}
cout << "Yes\n";
for (ll u = 1; u <= n; u++) cout << rk[u] << ' ';
cout << '\n';
}
5 4
2 3
3 1
1 5
5 4
Yes
3 1 2 5 4
.
Quy hoạch động theo thứ tự tô pô
Trên DAG, phụ thuộc vào các đỉnh đi tới (hay các đỉnh đi tới) không bao giờ tạo vòng phụ thuộc. Xử lý đỉnh theo thứ tự tô pô, mỗi khi lấy ra thì đẩy giá trị sang mọi mà : tới lượt , mọi đóng góp đã đủ. Đường dài nhất là , số đường từ nguồn là . Trên đồ thị tổng quát, đường dài nhất là bài khó; DAG làm nó thành .
Có thể lồng thẳng việc đẩy giá trị vào vòng lặp Kahn, không cần lưu thứ tự riêng.
Hỏi nhanh
Đếm số đường từ đỉnh 1 tới đỉnh trên DAG, vì sao phải chạy Kahn trên cả đồ thị chứ không chỉ từ đỉnh 1?
Một đỉnh có cung đi vào từ một đỉnh không tới được từ 1 sẽ giữ bậc vào dương nếu không bao giờ được lấy ra, nên và mọi đỉnh sau nó bị kẹt. Đẩy mọi đỉnh bậc vào 0 vào hàng đợi giải quyết việc đó; đỉnh không tới được chỉ mang .
Ví dụHành Trình Qua Các Mốc
Đề. DAG đỉnh, cung. Tìm số cung lớn nhất của một đường đi có hướng.
Lời giải. là số cung của đường dài nhất kết thúc ở , ban đầu 0 vì đường bắt đầu ở đâu cũng được. Đi theo thứ tự tô pô: lúc vào hàng đợi, mọi cung vào đã bị gỡ, tức mọi đỉnh đi trước đã xong, nên đã là giá trị cuối cùng.
Bước 1: cập nhật khi gỡ cung. Mỗi khi gỡ cung , thử kéo dài đường tốt nhất tới thêm một cung.
while (!q.empty()) {
ll u = q.front();
q.pop();
ans = max(ans, dp[u]);
for (ll v : g[u]) {
dp[v] = max(dp[v], dp[u] + 1);
if (--deg[v] == 0) q.push(v);
}
}
ans lấy max lúc lấy đỉnh ra, khi dp[u] chắc chắn đã đủ.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5;
ll n, m, deg[N], dp[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll u, v;
cin >> u >> v;
g[u].push_back(v);
deg[v]++;
}
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
ll ans = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
ans = max(ans, dp[u]);
for (ll v : g[u]) {
dp[v] = max(dp[v], dp[u] + 1);
if (--deg[v] == 0) q.push(v);
}
}
cout << ans << '\n';
}
6 5
1 3
2 3
3 4
3 5
5 6
3
.
Ví dụNhững Tuyến Đường Qua Cổng Dịch Chuyển
Đề. DAG đỉnh, cung (có thể trùng, mỗi cung tính riêng). Đếm số đường từ 1 tới theo modulo .
Lời giải. là số đường từ 1 tới : , còn lại 0, và mỗi cung cộng vào theo thứ tự tô pô. Cung trùng tự được cộng hai lần, đúng ý đề.
Bước 1: Kahn trên cả đồ thị. Hàng đợi bắt đầu từ mọi đỉnh bậc vào 0, không chỉ đỉnh 1. Một đỉnh không tới được từ 1 vẫn có cung đi vào các đỉnh khác và phải được xử lý để gỡ cung đó; bắt đầu chỉ từ 1 thì những đỉnh phía sau nó giữ deg > 0 mãi. Các đỉnh không tới được từ 1 có dp = 0, đẩy đi chỉ cộng 0.
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
dp[1] = 1;
Bước 2: cộng theo cung.
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll v : g[u]) {
dp[v] = (dp[v] + dp[u]) % MOD;
if (--deg[v] == 0) q.push(v);
}
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5, MOD = 1e9 + 7;
ll n, m, deg[N], dp[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll a, b;
cin >> a >> b;
g[a].push_back(b);
deg[b]++;
}
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
dp[1] = 1;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll v : g[u]) {
dp[v] = (dp[v] + dp[u]) % MOD;
if (--deg[v] == 0) q.push(v);
}
}
cout << dp[n] << '\n';
}
5 6
1 2
1 3
2 4
3 4
4 5
2 5
3
.
Đường dài nhất từ một nguồn và truy vết
Khi đường phải bắt đầu ở một nguồn cố định, các đỉnh không tới được từ nguồn phải bị loại: đặt của chúng là và chỉ cập nhật từ những đỉnh có giá trị hợp lệ. Lưu par[v] là đỉnh cho giá trị tốt nhất của để lần ngược đường đi, như với BFS (bài 30).
Ví dụTuyến Bay Qua Nhiều Thành Phố
Đề. DAG đỉnh, cung. In một đường từ 1 tới đi qua nhiều đỉnh nhất, hoặc IMPOSSIBLE.
Lời giải. Gộp hai ví dụ trước: là số đỉnh nhiều nhất trên một đường từ 1 tới , và par[v] để truy vết như BFS ở bài 30. Đỉnh không tới được từ 1 phải mang giá trị “không hợp lệ” để không kéo theo đỉnh khác.
Bước 1: khởi tạo. dp khởi đầu NEG (không tới được), chỉ dp[1] = 1.
for (ll u = 1; u <= n; u++) dp[u] = NEG;
dp[1] = 1;
Bước 2: Kahn trên cả đồ thị. Như ví dụ đếm đường, hàng đợi bắt đầu từ mọi đỉnh bậc vào 0. Chỉ cập nhật từ đỉnh có dp hợp lệ; mỗi lần dp[v] tăng thì ghi lại par[v].
for (ll v : g[u]) {
if (dp[u] != NEG && dp[u] + 1 > dp[v]) {
dp[v] = dp[u] + 1;
par[v] = u;
}
if (--deg[v] == 0) q.push(v);
}
Bước 3: truy vết. vẫn NEG thì in IMPOSSIBLE; không thì lần par từ về 1 rồi đảo.
vector<ll> path;
for (ll v = n; v != 1; v = par[v]) path.push_back(v);
path.push_back(1);
reverse(path.begin(), path.end());
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5, NEG = -1e18;
ll n, m, deg[N], dp[N], par[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll u, v;
cin >> u >> v;
g[u].push_back(v);
deg[v]++;
}
for (ll u = 1; u <= n; u++) dp[u] = NEG;
dp[1] = 1;
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll v : g[u]) {
if (dp[u] != NEG && dp[u] + 1 > dp[v]) {
dp[v] = dp[u] + 1;
par[v] = u;
}
if (--deg[v] == 0) q.push(v);
}
}
if (dp[n] == NEG) {
cout << "IMPOSSIBLE\n";
return 0;
}
vector<ll> path;
for (ll v = n; v != 1; v = par[v]) path.push_back(v);
path.push_back(1);
reverse(path.begin(), path.end());
cout << path.size() << '\n';
for (ll v : path) cout << v << ' ';
cout << '\n';
}
6 5
1 2
2 6
1 3
3 4
4 6
4
1 3 4 6
.
Định hướng cạnh theo thứ tự tô pô
Một thứ tự tô pô cho mọi đỉnh một vị trí . Định hướng mọi cạnh từ đỉnh có vị trí nhỏ sang đỉnh có vị trí lớn thì không thể có chu trình, vì đi theo bất kỳ cung nào vị trí cũng tăng. Vậy muốn thêm chiều cho các cạnh vô hướng mà không tạo chu trình, chỉ cần các cung có sẵn đã không có chu trình: lấy một thứ tự tô pô của chúng rồi định hướng phần còn lại theo .
Ví dụĐịnh Hướng Các Liên Kết
Đề. Nhiều test, tổng . Một số cạnh đã có hướng, số còn lại vô hướng. Định hướng các cạnh vô hướng để cả đồ thị không có chu trình, in YES và mọi cạnh; không được thì in NO.
Lời giải. Nếu các cung đã có hướng tự tạo chu trình thì vô vọng. Ngược lại lấy một thứ tự tô pô của riêng các cung đó, rồi định hướng mọi cạnh vô hướng theo đúng thứ tự ấy, từ vị trí nhỏ sang vị trí lớn. Khi đó mọi cung của kết quả, có sẵn lẫn mới, đều đi từ vị trí nhỏ sang vị trí lớn, nên không thể có chu trình.
Bước 1: chỉ cung có sẵn vào đồ thị. Cạnh vô hướng (et = 0) chờ tới cuối. Nhiều test nên mỗi test xóa g và deg của các đỉnh trước.
for (ll i = 1; i <= m; i++) {
cin >> et[i] >> ex[i] >> ey[i];
if (et[i]) {
g[ex[i]].push_back(ey[i]);
deg[ey[i]]++;
}
}
Bước 2: Kahn ghi vị trí. pos[u] là vị trí của trong thứ tự tô pô. Thiếu đỉnh (k < n) là các cung có sẵn đã có chu trình, in NO.
while (!q.empty()) {
ll u = q.front();
q.pop();
pos[u] = ++k;
for (ll v : g[u])
if (--deg[v] == 0) q.push(v);
}
Bước 3: định hướng cạnh vô hướng. In theo chiều pos tăng; cung có sẵn in nguyên.
for (ll i = 1; i <= m; i++) {
ll x = ex[i], y = ey[i];
if (!et[i] && pos[x] > pos[y]) swap(x, y);
cout << x << ' ' << y << '\n';
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll deg[N], pos[N], et[N], ex[N], ey[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll T;
cin >> T;
while (T--) {
ll n, m;
cin >> n >> m;
for (ll u = 1; u <= n; u++) {
g[u].clear();
deg[u] = 0;
}
for (ll i = 1; i <= m; i++) {
cin >> et[i] >> ex[i] >> ey[i];
if (et[i]) {
g[ex[i]].push_back(ey[i]);
deg[ey[i]]++;
}
}
queue<ll> q;
for (ll u = 1; u <= n; u++)
if (!deg[u]) q.push(u);
ll k = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
pos[u] = ++k;
for (ll v : g[u])
if (--deg[v] == 0) q.push(v);
}
if (k < n) {
cout << "NO\n";
continue;
}
cout << "YES\n";
for (ll i = 1; i <= m; i++) {
ll x = ex[i], y = ey[i];
if (!et[i] && pos[x] > pos[y]) swap(x, y);
cout << x << ' ' << y << '\n';
}
}
}
1
5 6
1 4 2
0 1 2
1 3 4
0 5 3
0 1 5
1 4 5
YES
4 2
1 2
3 4
3 5
1 5
4 5
Mỗi test .
Khi đi thi
- Ràng buộc “trước sau”, phụ thuộc, công thức không vòng: thứ tự tô pô. Đề không bảo đảm không có chu trình thì luôn kiểm số đỉnh lấy ra.
- Quy hoạch động trên DAG đẩy giá trị khi lấy đỉnh ra khỏi hàng đợi; đường phải bắt đầu ở một nguồn thì khởi tạo các đỉnh khác bằng hoặc 0 tùy bài, đừng để đỉnh không tới được làm sai.
- Muốn thứ tự nhỏ nhất theo từ điển thì hàng đợi ưu tiên; muốn ưu tiên “đỉnh 1 càng sớm càng tốt, rồi đỉnh 2…” thì làm trên đồ thị ngược với hàng đợi ưu tiên lấy lớn nhất rồi đảo thứ tự.
Tóm tắt
- Thứ tự tô pô tồn tại khi và chỉ khi đồ thị không có chu trình; Kahn lấy dần các đỉnh bậc vào 0, .
- Lấy ra ít hơn đỉnh nghĩa là có chu trình; DFS ba trạng thái cũng tìm được chu trình.
- Hàng đợi ưu tiên cho thứ tự nhỏ nhất theo từ điển; mỗi bước đúng một đỉnh sẵn sàng thì thứ tự duy nhất.
- Quy hoạch động theo thứ tự tô pô: đường dài nhất, đếm đường, truy vết bằng
par. - Định hướng mọi cạnh theo vị trí trong một thứ tự tô pô thì không thể có chu trình.
Bài tập
IMPOSSIBLE.Đọc thêm
- Sắp xếp tô pôVNOI WikiThuật toán Kahn, DFS và các ứng dụng, viết bằng tiếng Việt.
- Topological Sortingcp-algorithmsChứng minh bằng thời điểm xong của DFS.
- Topological SortUSACO GuideKahn, quy hoạch động trên DAG và bài luyện.
- 拓扑排序OI WikiKahn, DFS và thứ tự nhỏ nhất theo từ điển.