Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Bài 43: Ghép cặp trên đồ thị hai phía và luồng cực đại trong C++ và Python: thuật toán Kuhn, Edmonds–Karp, lát cắt nhỏ nhất, định lý König

Bài 43: Ghép cặp trên đồ thị hai phía và luồng cực đại trong C++ và Python: thuật toán Kuhn, Edmonds–Karp, lát cắt nhỏ nhất, định lý König
Series: Thuật toán chuyên sâu Độ khó: Khá Bài 43 / Thuật toán chuyên sâu Ghép cặp Luồng cực đại
// Thuật toán chuyên sâu · Bài 43
Ghép cặp trên đồ thị hai phía và luồng cực đại
Phân công mỗi người một việc sao cho được nhiều người nhất, bơm nhiều nước nhất qua mạng ống, cắt ít đường nhất để chia đôi thành phố: ba bài toán tưởng khác nhau hoá ra cùng một ý tưởng, đó là đường tăng.

Lời mở đầu

Một đề thi quen thuộc: có $n$ bạn và $k$ công việc, mỗi bạn chỉ làm được vài việc, mỗi việc chỉ cần một bạn. Hỏi giao được nhiều nhất bao nhiêu việc? Thử tham lam “ai chọn trước thì lấy trước” rất dễ sai: một bạn chọn mất việc duy nhất mà bạn khác làm được. Quay lui thì bùng nổ. Cách đúng là một thuật toán ngắn chưa tới 15 dòng: ghép cặp bằng đường tăng (thuật toán Kuhn). Mở rộng ý tưởng đó lên đồ thị có trọng số, ta được luồng cực đại: bài toán “bơm được bao nhiêu nước từ nguồn tới bể chứa qua một mạng ống có giới hạn”.

Bài này dùng rất nhiều BFS và DFS từ bài 21, cách nghĩ “đi trên đồ thị phụ” giống bài 29 Đường đi ngắn nhất, và ở phần lát cắt em sẽ gặp lại câu hỏi “cắt cạnh nào thì đồ thị rời ra” của bài 36 về cầu và khớp, nhưng lần này là cắt nhiều cạnh với chi phí nhỏ nhất. Luồng cực đại là kiến thức nền của đội tuyển quốc gia, còn ghép cặp thì gặp cả ở đề HSG tỉnh.

Trong bài này bạn sẽ học: nhận biết đồ thị hai phía và kiểm tra bằng tô 2 màu với BFS; ghép cặp, đường tăng và vì sao không còn đường tăng thì ghép đã lớn nhất (định lý Berge); thuật toán Kuhn $O(VE)$; mạng luồng, dung lượng, đồ thị thặng dư và cạnh ngược; thuật toán Edmonds–Karp; định lý lát cắt nhỏ nhất = luồng lớn nhất và cách truy ra lát cắt; mô hình hoá: phân công việc, đường đi rời cạnh, định lý König (phủ đỉnh nhỏ nhất), phủ DAG bằng ít đường nhất; giới thiệu thuật toán Dinic. Có 2 khung chạy tay (Kuhn, Edmonds–Karp), 3 mô phỏng từng bước và 8 bài tập kèm gợi ý.

Lộ trình bài viết, bấm vào từng phần để nhảy tới:

Phần 1Đồ thị hai phía và kiểm tra bằng tô 2 màu

Đồ thị vô hướng gọi là hai phía (bipartite) nếu chia được tập đỉnh thành hai nhóm $L$ và $R$ sao cho mọi cạnh đều nối một đỉnh của $L$ với một đỉnh của $R$, không có cạnh nào nằm gọn trong một nhóm. Rất nhiều bài toán thực tế tự nhiên có dạng này: người và việc, học sinh và câu lạc bộ, hàng và cột của bảng, ô đen và ô trắng của bàn cờ.

Nói cách khác: đồ thị hai phía là đồ thị tô được bằng 2 màu sao cho hai đầu mỗi cạnh khác màu. Cách kiểm tra rất tự nhiên: BFS từ một đỉnh, tô nó màu 1; mỗi đỉnh mới gặp được tô ngược màu với đỉnh vừa đi tới nó. Nếu lúc nào đó gặp một cạnh mà hai đầu đã cùng màu thì không thể tô, đồ thị không hai phía. Đồ thị nhiều thành phần liên thông thì BFS lần lượt từng thành phần, mỗi thành phần tô độc lập.

123456tô được 2 màu: hai phía1233 kề cả màu xanh lẫn màu vàngchu trình lẻ: không hai phía

Hình 1. Bên trái: đồ thị 6 đỉnh tô được 2 màu, nhóm xanh {1, 3, 5} và nhóm vàng {2, 4, 6}. Bên phải: tam giác (chu trình 3 đỉnh), đỉnh 3 kề cả hai màu nên không tô được.

Định lý: hai phía ⇔ không có chu trình lẻ
Đi dọc một chu trình, màu đổi ở mỗi bước. Quay về đỉnh xuất phát sau $k$ bước thì màu phải đổi chẵn lần, nên $k$ chẵn. Vậy đồ thị hai phía không có chu trình độ dài lẻ. Chiều ngược lại cũng đúng: nếu BFS gặp xung đột ở cạnh $u$–$v$ cùng màu, thì đường BFS từ gốc tới $u$, từ gốc tới $v$ cùng cạnh $u$–$v$ tạo thành một chu trình lẻ. Vì vậy khi đề hỏi “có chu trình lẻ không?” hay “chia được thành hai đội không đối đầu không?”, em nghĩ ngay tới tô 2 màu.
BướcLấy raCác đỉnh kềHành độngMàu 1..6
0––tô đỉnh 1 màu 11 · · · · ·
112, 4tô 2 và 4 màu 21 2 · 2 · ·
221, 31 khác màu (tốt), tô 3 màu 11 2 1 2 · ·
343, 1, 53, 1 khác màu, tô 5 màu 11 2 1 2 1 ·
432, 4đều khác màu1 2 1 2 1 ·
554, 6tô 6 màu 21 2 1 2 1 2

Bảng 1. BFS tô màu đồ thị bên trái Hình 1 (cạnh nhập theo thứ tự 1–2, 2–3, 3–4, 4–1, 4–5, 5–6). Không có xung đột nên đồ thị hai phía.

Xem code C++ / Python: kiểm tra đồ thị hai phía bằng bfs tô 2 màu (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<vector<int>> adj(n + 1);
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    vector<int> color(n + 1, 0);          // 0: chưa tô, 1 và 2: hai màu
    for(int s = 1; s <= n; s++){
        if(color[s]) continue;            // mỗi thành phần liên thông BFS một lần
        color[s] = 1;
        queue<int> q; q.push(s);
        while(!q.empty()){
            int u = q.front(); q.pop();
            for(int v : adj[u]){
                if(color[v] == 0){
                    color[v] = 3 - color[u];      // tô màu ngược với u
                    q.push(v);
                } else if(color[v] == color[u]){  // hai đầu một cạnh cùng màu
                    cout << "NO";
                    return 0;
                }
            }
        }
    }
    cout << "YES\n";
    for(int u = 1; u <= n; u++) cout << color[u] << " ";
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append(v); adj[v].append(u)
    color = [0] * (n + 1)                 # 0: chưa tô, 1 và 2: hai màu
    for s in range(1, n + 1):
        if color[s]:
            continue
        color[s] = 1
        q = deque([s])
        while q:
            u = q.popleft()
            for v in adj[u]:
                if color[v] == 0:
                    color[v] = 3 - color[u]       # tô màu ngược với u
                    q.append(v)
                elif color[v] == color[u]:        # hai đầu một cạnh cùng màu
                    print("NO")
                    return
    print("YES")
    print(*color[1:])

main()
Hai lỗi hay gặp
1. Chỉ BFS từ đỉnh 1 rồi kết luận: sai khi đồ thị không liên thông, chu trình lẻ có thể nằm ở thành phần khác. Luôn có vòng for ngoài cùng duyệt mọi đỉnh chưa tô.
2. Có cạnh khuyên $u$–$u$ thì đồ thị chắc chắn không hai phía (chu trình độ dài 1). Code trên xử lý đúng vì khi xét $v = u$ thì color[v] == color[u].

Phần 2Ghép cặp cực đại bằng đường tăng (thuật toán Kuhn)

Cho đồ thị hai phía với phía trái $L$, phía phải $R$. Một cặp ghép (matching) là một tập cạnh không có hai cạnh nào chung đỉnh: mỗi người nhận tối đa một việc, mỗi việc giao cho tối đa một người. Bài toán: tìm cặp ghép có nhiều cạnh nhất. Đỉnh thuộc một cạnh của cặp ghép gọi là đã ghép, ngược lại là tự do.

Đường tăng (augmenting path) là một đường đi bắt đầu ở một đỉnh trái tự do, kết thúc ở một đỉnh phải tự do, và các cạnh xen kẽ: chưa ghép, đã ghép, chưa ghép, …, chưa ghép. Đường như vậy luôn có số cạnh chưa ghép nhiều hơn số cạnh đã ghép đúng 1. Nếu ta lật nó (cạnh chưa ghép thành ghép, cạnh đã ghép bỏ ra) thì vẫn được một cặp ghép hợp lệ, và nhiều hơn đúng 1 cạnh.

Trước: 3 cặp, có đường tăngL1R1L2R2L3R3L4R4Sau khi lật: 4 cặpL1R1L2R2L3R3L4R4

Hình 2. Cặp ghép gồm L1–R2, L2–R1, L3–R3 (xanh lá). L4 tự do, R4 tự do. Đường tăng L4 → R3 → L3 → R4: nét đứt là cạnh chưa ghép, nét liền cam là cạnh đang ghép. Lật đường này được 4 cặp: L3 “nhường” R3 cho L4 và chuyển sang R4.

Thuật toán Kuhn lần lượt xét từng đỉnh trái $u$ và tìm một đường tăng xuất phát từ $u$ bằng DFS. Hàm tryKuhn(u) thử từng đỉnh phải $v$ kề với $u$:

  • nếu $v$ tự do: ghép $u$ với $v$, xong;
  • nếu $v$ đang ghép với $w$: gọi đệ quy tryKuhn(w), tức là “nhờ” $w$ tìm chỗ khác. Nếu $w$ tìm được thì $v$ được nhường lại cho $u$.

Mảng vis đánh dấu các đỉnh phải đã thử trong lượt hiện tại, để DFS không đi vòng mãi. Chuỗi lời gọi đệ quy thành công chính là một đường tăng, và các lệnh gán matchR[v] = u khi đệ quy trả về chính là thao tác lật đường đó. Mô phỏng dưới đây chạy trên đúng đồ thị Hình 2: ở lượt L3 có một nhánh thất bại (L1 nhờ L2, L2 không có chỗ nào khác), còn lượt L4 thì phải đi qua 2 lần “nhờ”:

MÔ PHỎNGThuật toán Kuhn: tìm đường tăng cho từng đỉnh tráiNhập số đỉnh mỗi bên (≤ 7) và các cạnh "u v" (u bên trái, v bên phải). Cạnh nét đứt cam là cạnh đang thử thêm vào ghép.
đang ghépđường tăng đang thửđỉnh phải đã thăm trong lượtchưa ghép
LượtDiễn biến DFSKết quảCặp ghép sau lượt
L1R1 tự doghép L1–R1L1–R1
L2R1 của L1 → nhờ L1: R1 đã thăm, R2 tự dođường L2–R1–L1–R2L1–R2, L2–R1
L3R2 của L1 → nhờ L1: R1 của L2 → nhờ L2: hết cách ✗; R3 tự doghép L3–R3L1–R2, L2–R1, L3–R3
L4R3 của L3 → nhờ L3: R2 của L1 → nhờ L1 ✗ (như trên); R3 đã thăm; R4 tự dođường L4–R3–L3–R4L1–R2, L2–R1, L3–R4, L4–R3

Bảng 2. Bốn lượt của thuật toán Kuhn trên đồ thị Hình 2. Kết quả: 4 cặp, tức là ghép được hết.

Vì sao đúng? Định lý Berge: một cặp ghép $M$ là lớn nhất khi và chỉ khi không còn đường tăng. Chiều “có đường tăng thì chưa lớn nhất” thì rõ (lật là tăng 1). Chiều ngược lại: giả sử có cặp ghép $M^*$ lớn hơn $M$. Xét các cạnh thuộc đúng một trong hai tập $M$, $M^*$: mỗi đỉnh chạm tối đa 2 cạnh như vậy (một của $M$, một của $M^*$), nên chúng tạo thành các đường và chu trình mà cạnh $M$, $M^*$ xen kẽ. Chu trình xen kẽ có số cạnh hai loại bằng nhau. Vì $|M^*| > |M|$ nên phải có một đường chứa nhiều cạnh $M^*$ hơn, và đường đó chính là một đường tăng của $M$. Mâu thuẫn.

Còn một chi tiết: Kuhn chỉ thử mỗi đỉnh trái một lần. Nếu lượt của $u$ thất bại, có thể sau này $u$ lại có đường tăng không? Không: người ta chứng minh được nếu không có đường tăng từ $u$ thì sau khi lật các đường tăng khác vẫn không có. Vì vậy chạy $|L|$ lượt là đủ. Mỗi lượt là một DFS $O(V + E)$, tổng $O(V \cdot E)$, thoải mái với $V \le 1000$, $E \le 10^4$.

C++▶ Chạy tay từng dòng codetryKuhn(u): thử từng đỉnh phải v kề với u; v trống thì ghép, v đã có chủ thì nhờ chủ của v tìm chỗ khác (ô vàng: đỉnh phải đã thăm trong lượt).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int nL, nR, m;
5vector<int> adj[505];
6int matchR[505], matchL[505]; // matchR[v] = đỉnh trái đang ghép với v (0: chưa)
7bool vis[505];
8
9bool tryKuhn(int u){
10 for(int v : adj[u]){
11 if(vis[v]) continue;
12 vis[v] = true;
13 if(matchR[v] == 0 || tryKuhn(matchR[v])){
14 matchR[v] = u;
15 return true;
16 }
17 }
18 return false;
19}
20
21int main(){
22 cin >> nL >> nR >> m;
23 for(int i = 0; i < m; i++){
24 int u, v;
25 cin >> u >> v;
26 adj[u].push_back(v);
27 }
28 int res = 0;
29 for(int u = 1; u <= nL; u++){
30 memset(vis, 0, sizeof(vis));
31 if(tryKuhn(u)) res++;
32 }
33 for(int v = 1; v <= nR; v++) if(matchR[v]) matchL[matchR[v]] = v;
34 cout << res << "\n";
35 for(int u = 1; u <= nL; u++) if(matchL[u]) cout << u << " " << matchL[u] << "\n";
36 return 0;
37}
Xem code C++ / Python: ghép cặp cực đại bằng thuật toán kuhn (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int nL, nR, m;
vector<int> adj[100005];
int matchR[100005], matchL[100005];   // 0: chưa ghép
int vis[100005], stamp = 0;           // vis[v] == stamp: đã thăm trong lượt này

bool tryKuhn(int u){
    for(int v : adj[u]){
        if(vis[v] == stamp) continue;
        vis[v] = stamp;
        if(matchR[v] == 0 || tryKuhn(matchR[v])){   // v trống, hoặc người giữ v đổi được
            matchR[v] = u;
            return true;
        }
    }
    return false;
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> nL >> nR >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
    }
    int res = 0;
    for(int u = 1; u <= nL; u++){
        stamp++;                      // "xoá" vis trong O(1)
        if(tryKuhn(u)) res++;
    }
    for(int v = 1; v <= nR; v++) if(matchR[v]) matchL[matchR[v]] = v;
    cout << res << "\n";
    for(int u = 1; u <= nL; u++) if(matchL[u]) cout << u << " " << matchL[u] << "\n";
    return 0;
}
import sys
sys.setrecursionlimit(20000)

def main():
    data = sys.stdin.buffer.read().split()
    nL, nR, m = int(data[0]), int(data[1]), int(data[2])
    adj = [[] for _ in range(nL + 1)]
    for i in range(m):
        adj[int(data[3 + 2 * i])].append(int(data[4 + 2 * i]))
    match_r = [0] * (nR + 1)          # 0: chưa ghép
    vis = [0] * (nR + 1)

    def try_kuhn(u, stamp):
        for v in adj[u]:
            if vis[v] == stamp:
                continue
            vis[v] = stamp
            if match_r[v] == 0 or try_kuhn(match_r[v], stamp):
                match_r[v] = u
                return True
        return False

    res = 0
    for u in range(1, nL + 1):
        if try_kuhn(u, u):            # dùng chính u làm "dấu thời gian"
            res += 1
    match_l = [0] * (nL + 1)
    for v in range(1, nR + 1):
        if match_r[v]:
            match_l[match_r[v]] = v
    out = [str(res)] + ['%d %d' % (u, match_l[u]) for u in range(1, nL + 1) if match_l[u]]
    print('\n'.join(out))

main()
Mẹo tăng tốc khi đi thi
1. Dấu thời gian: thay vì memset(vis) mỗi lượt (tốn $O(|R|)$), dùng vis[v] == stamp và tăng stamp mỗi lượt, như trong code trên.
2. Ghép tham lam trước: duyệt qua mỗi đỉnh trái, nếu có đỉnh phải kề còn tự do thì ghép luôn; sau đó mới chạy Kuhn cho các đỉnh còn lại. Thực tế nhanh hơn nhiều lần.
3. Python: đệ quy sâu tới $|L|$ tầng, nhớ sys.setrecursionlimit. Nếu $|L|$ cỡ $10^4$ trở lên nên chuyển sang Hopcroft–Karp hoặc Dinic (Phần 5).

Phần 3Luồng cực đại: mạng, đồ thị thặng dư và Edmonds–Karp

Mạng luồng là một đồ thị có hướng, mỗi cung $u \to v$ có dung lượng $c(u, v) \ge 0$ (sức chứa của ống nước), cùng hai đỉnh đặc biệt: đỉnh phát $s$ (nguồn) và đỉnh thu $t$ (bể chứa). Một luồng gán cho mỗi cung một số $f(u, v)$ thoả hai điều kiện:

  • Giới hạn dung lượng: $0 \le f(u, v) \le c(u, v)$.
  • Bảo toàn: ở mọi đỉnh khác $s$ và $t$, tổng luồng chảy vào bằng tổng luồng chảy ra (nước không tự sinh ra hay mất đi giữa đường).

Giá trị của luồng là tổng luồng chảy ra khỏi $s$ (cũng bằng tổng chảy vào $t$). Bài toán luồng cực đại: tìm luồng có giá trị lớn nhất.

Ý tưởng tự nhiên: còn đường nào từ $s$ tới $t$ mà mọi cung chưa đầy thì đẩy thêm nước theo đường đó. Nhưng tham lam như vậy có thể kẹt. Xem mạng ở Hình 3: nếu đẩy 2 đơn vị theo đường 1→2→4→7 trước, từ $s$ chỉ còn đi được 1→3→4 nhưng 4→7 đã đầy, tổng mắc kẹt ở 2. Trong khi đáp án đúng là 3: 1 đơn vị theo 1→2→4→7, 1 theo 1→3→4→7, 1 theo 1→2→5→6→7.

(a) Sau khi đẩy 2 đơn vị theo 1→2→4→7: nhãn là luồng / dung lượng2/22/22/20/10/10/10/10/11s234567t(b) Đồ thị thặng dư: xanh = còn đẩy được, hồng nét đứt = cạnh ngược222111111s234567t

Hình 3. (a) Đẩy tham lam 2 đơn vị theo 1→2→4→7 (đỏ: cung đã đầy). (b) Đồ thị thặng dư tương ứng: nhờ cạnh ngược 4→2 (hồng) vẫn còn đường tăng 1→3→4→2→5→6→7 (cam).

Cách sửa: cho phép “trả lại” luồng đã đẩy. Ta xây đồ thị thặng dư: với mỗi cung $u \to v$ đang có luồng $f$ trên dung lượng $c$,

  • cung thuận $u \to v$ còn $c - f$: lượng còn đẩy thêm được;
  • cạnh ngược $v \to u$ có $f$: lượng có thể rút bớt lại.

Một đường tăng là đường từ $s$ tới $t$ trong đồ thị thặng dư, chỉ đi qua các cạnh có giá trị dương. Đẩy $\delta$ = giá trị nhỏ nhất trên đường (nút cổ chai) dọc theo nó: đi qua cạnh thuận thì tăng luồng, đi qua cạnh ngược thì giảm luồng của cung gốc. Ở Hình 3b, đường 1→3→4→2→5→6→7 đi qua cạnh ngược 4→2: nghĩa là 1 đơn vị nước đang chảy 2→4 được “đổi hướng”, giờ đi 2→5→6→7, còn chỗ trống trên 4→7 nhường cho nước từ 3. Tổng tăng thành 3.

Edmonds–Karp = lặp “tìm đường tăng bằng BFS (đường ít cạnh nhất), đẩy nút cổ chai” tới khi BFS không tới được $t$. Dùng BFS thay vì DFS tuỳ ý là điều quan trọng: người ta chứng minh số lần tăng không quá $O(VE)$, nên tổng thời gian là $O(VE^2)$ bất kể dung lượng lớn cỡ nào.

Khuôn mẫu cài đặt: cạnh i và cạnh ngược i ^ 1
Lưu mọi cạnh trong các mảng to[], cap[]. Mỗi khi thêm cung $u \to v$ dung lượng $c$, thêm liền nhau hai cạnh: cạnh thuận ở chỉ số chẵn $2k$ (cap $= c$) và cạnh ngược ở chỉ số $2k + 1$ (cap $= 0$). Khi đó cạnh ngược của cạnh id luôn là id ^ 1. Mảng cap chính là đồ thị thặng dư: đẩy $f$ qua cạnh id thì cap[id] -= f; cap[id ^ 1] += f;. Luồng thật trên cung gốc thứ $k$ bằng cap[2k + 1].

Em chạy tay chương trình trên đúng mạng Hình 3. Để ý lượt 2: BFS đi từ 4 ngược về 2 qua cạnh id 3 (cạnh ngược của 2→4), đúng như hình vẽ:

C++▶ Chạy tay từng dòng codeEdmonds–Karp: BFS trên mảng cap (đồ thị thặng dư), lần theo par từ t về s, trừ nút cổ chai ở cạnh thuận và cộng vào cạnh ngược id ^ 1.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int n, m, s, t;
5vector<int> to; // cạnh id đi tới đỉnh to[id]
6vector<long long> cap; // dung lượng thặng dư của cạnh id
7vector<int> adj[205];
8
9void addEdge(int u, int v, long long c){
10 adj[u].push_back(to.size()); to.push_back(v); cap.push_back(c); // thuận: id chẵn
11 adj[v].push_back(to.size()); to.push_back(u); cap.push_back(0); // ngược: id lẻ
12}
13
14int main(){
15 cin >> n >> m >> s >> t;
16 for(int i = 0; i < m; i++){
17 int u, v; long long c;
18 cin >> u >> v >> c;
19 addEdge(u, v, c);
20 }
21 long long flow = 0;
22 while(true){
23 vector<int> par(n + 1, -1); // par[v]: cạnh đi vào v
24 par[s] = -2;
25 queue<int> q; q.push(s);
26 while(!q.empty() && par[t] == -1){
27 int u = q.front(); q.pop();
28 for(int id : adj[u]){
29 int v = to[id];
30 if(par[v] == -1 && cap[id] > 0){
31 par[v] = id; q.push(v);
32 }
33 }
34 }
35 if(par[t] == -1) break;
36 long long f = LLONG_MAX;
37 for(int v = t; v != s; v = to[par[v] ^ 1]) f = min(f, cap[par[v]]);
38 for(int v = t; v != s; v = to[par[v] ^ 1]){
39 cap[par[v]] -= f;
40 cap[par[v] ^ 1] += f;
41 }
42 flow += f;
43 }
44 cout << flow;
45 return 0;
46}

Khung chạy tay cho thấy từng dòng code, còn mô phỏng dưới đây vẽ cả mạng luồng lẫn đồ thị thặng dư sau mỗi bước. Mạng mặc định có 6 đỉnh, luồng cực đại 19. Em thử nhập lại mạng Hình 3 (n = 7, các cung 1 2 2, 2 4 2, 4 7 2, 1 3 1, 3 4 1, 2 5 1, 5 6 1, 6 7 1) để thấy cạnh ngược được dùng:

MÔ PHỎNGEdmonds–Karp: BFS tìm đường, đẩy luồng, cập nhật đồ thị thặng dưNhập n (≤ 8) và các cung "u v c" (từ u tới v, dung lượng c ≤ 99). Phát ở đỉnh 1, thu ở đỉnh n.
trong hàng đợi / đường tăngđã thămcung thặng dư thuậncạnh ngượccung bão hoà
Xem code C++ / Python: luồng cực đại bằng edmonds–karp (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int n, m, s, t;
vector<int> to;
vector<long long> cap;
vector<vector<int>> adj;

void addEdge(int u, int v, long long c){
    adj[u].push_back(to.size()); to.push_back(v); cap.push_back(c);   // id chẵn: thuận
    adj[v].push_back(to.size()); to.push_back(u); cap.push_back(0);   // id lẻ: ngược
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m >> s >> t;
    adj.assign(n + 1, {});
    for(int i = 0; i < m; i++){
        int u, v; long long c;
        cin >> u >> v >> c;
        addEdge(u, v, c);
    }
    long long flow = 0;
    while(true){
        vector<int> par(n + 1, -1);
        par[s] = -2;
        queue<int> q; q.push(s);
        while(!q.empty() && par[t] == -1){            // BFS trên đồ thị thặng dư
            int u = q.front(); q.pop();
            for(int id : adj[u])
                if(par[to[id]] == -1 && cap[id] > 0){ par[to[id]] = id; q.push(to[id]); }
        }
        if(par[t] == -1) break;                       // hết đường tăng
        long long f = LLONG_MAX;
        for(int v = t; v != s; v = to[par[v] ^ 1]) f = min(f, cap[par[v]]);
        for(int v = t; v != s; v = to[par[v] ^ 1]){ cap[par[v]] -= f; cap[par[v] ^ 1] += f; }
        flow += f;
    }
    cout << flow;
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, m, s, t = int(data[0]), int(data[1]), int(data[2]), int(data[3])
    adj = [[] for _ in range(n + 1)]
    to, cap = [], []
    for i in range(m):
        u, v, c = int(data[4 + 3 * i]), int(data[5 + 3 * i]), int(data[6 + 3 * i])
        adj[u].append(len(to)); to.append(v); cap.append(c)    # id chẵn: thuận
        adj[v].append(len(to)); to.append(u); cap.append(0)    # id lẻ: ngược
    flow = 0
    while True:
        par = [-1] * (n + 1)
        par[s] = -2
        q = deque([s])
        while q and par[t] == -1:                 # BFS trên đồ thị thặng dư
            u = q.popleft()
            for e in adj[u]:
                if par[to[e]] == -1 and cap[e] > 0:
                    par[to[e]] = e
                    q.append(to[e])
        if par[t] == -1:                          # hết đường tăng
            break
        f, v = float('inf'), t
        while v != s:
            f = min(f, cap[par[v]]); v = to[par[v] ^ 1]
        v = t
        while v != s:
            cap[par[v]] -= f; cap[par[v] ^ 1] += f; v = to[par[v] ^ 1]
        flow += f
    print(flow)

main()
Những chỗ dễ sai
1. Quên cạnh ngược, hoặc cho cạnh ngược dung lượng $c$ thay vì 0 (với đồ thị có hướng). Hậu quả: thuật toán kẹt như Hình 3a hoặc cho đáp án quá lớn.
2. Tổng luồng có thể vượt int (nhiều cung dung lượng $10^9$): dùng long long cho cap và flow.
3. Cung song song (nhiều cung cùng $u \to v$) và cặp cung ngược chiều $u \to v$, $v \to u$ đều được phép, vì mỗi cung có cặp chỉ số riêng. Đừng gộp bằng ma trận kề cap[u][v] nếu chưa hiểu rõ.

Phần 4Lát cắt nhỏ nhất = luồng lớn nhất

Một lát cắt $s$–$t$ là cách chia tập đỉnh thành hai phần: $S$ chứa $s$, $T$ chứa $t$. Dung lượng lát cắt là tổng dung lượng các cung đi từ $S$ sang $T$ (cung đi từ $T$ về $S$ không tính). Xoá hết các cung đó thì không còn đường nào từ $s$ tới $t$. Bài toán lát cắt nhỏ nhất: tìm cách chia có dung lượng nhỏ nhất, chẳng hạn “phá ít đường nhất để quân địch không đi được từ $s$ tới $t$”.

Mọi luồng ≤ mọi lát cắt. Mỗi đơn vị nước đi từ $s$ (trong $S$) tới $t$ (trong $T$) phải băng qua ranh giới, chính xác hơn: giá trị luồng = (tổng luồng trên cung $S \to T$) − (tổng luồng trên cung $T \to S$) $\le$ tổng dung lượng cung $S \to T$. Vậy luồng lớn nhất không vượt quá lát cắt nhỏ nhất.

Định lý Ford–Fulkerson: hai đại lượng này bằng nhau. Khi Edmonds–Karp dừng, BFS cuối cùng trên đồ thị thặng dư không tới được $t$. Gọi $S$ là tập đỉnh tới được, $T$ là phần còn lại:

  • mọi cung $u \to v$ với $u \in S$, $v \in T$ phải đầy ($f = c$), nếu không BFS đã đi qua được;
  • mọi cung $v \to u$ từ $T$ về $S$ phải có luồng 0, nếu không cạnh ngược $u \to v$ còn dương và BFS đã đi qua được.

Thay vào đẳng thức ở trên: luồng = tổng dung lượng cung $S \to T$ − 0. Một luồng bằng một lát cắt thì cả hai đều tối ưu. Đồng thời đây cũng là cách truy ra lát cắt: chạy luồng cực đại, BFS thêm một lần, các cung từ đỉnh tới được sang đỉnh không tới được là lát cắt nhỏ nhất.

Mô phỏng dưới đây chạy xong luồng cực đại rồi làm đúng hai việc đó: BFS tìm phía $S$, sau đó cộng dung lượng từng cung cắt. Mạng mặc định là ví dụ kinh điển trong sách CLRS:

MÔ PHỎNGLát cắt nhỏ nhất: BFS trên đồ thị thặng dư cuối cùngNhập mạng như khung trên. Mô phỏng chạy xong luồng cực đại rồi tìm phía S, cộng các cung cắt.
phía S (tới được từ s)phía Tcung thuộc lát cắtđang xét
CungHướngLuồng / dung lượngTính vào lát cắt?
2→4$S \to T$12 / 12 (đầy)+12
5→4$S \to T$7 / 7 (đầy)+7
5→6$S \to T$4 / 4 (đầy)+4
4→3$T \to S$0 / 9không
các cung còn lạitrong $S$ hoặc trong $T$–không
Tổng23 = luồng cực đại

Bảng 3. Lát cắt nhỏ nhất của mạng CLRS (mặc định trong mô phỏng): $S = \{1, 2, 3, 5\}$, $T = \{4, 6\}$.

Xem code C++ / Python: luồng cực đại và truy vết lát cắt nhỏ nhất (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int n, m, s, t;
vector<int> to;
vector<long long> cap;
vector<vector<int>> adj;

vector<int> bfs(){                       // par[v] = cạnh đi vào v, -1: không tới được
    vector<int> par(n + 1, -1);
    par[s] = -2;
    queue<int> q; q.push(s);
    while(!q.empty()){
        int u = q.front(); q.pop();
        for(int id : adj[u])
            if(par[to[id]] == -1 && cap[id] > 0){ par[to[id]] = id; q.push(to[id]); }
    }
    return par;
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m >> s >> t;
    adj.assign(n + 1, {});
    vector<int> eu(m), ev(m);
    for(int i = 0; i < m; i++){
        long long c;
        cin >> eu[i] >> ev[i] >> c;
        adj[eu[i]].push_back(to.size()); to.push_back(ev[i]); cap.push_back(c);
        adj[ev[i]].push_back(to.size()); to.push_back(eu[i]); cap.push_back(0);
    }
    long long flow = 0;
    while(true){
        vector<int> par = bfs();
        if(par[t] == -1) break;
        long long f = LLONG_MAX;
        for(int v = t; v != s; v = to[par[v] ^ 1]) f = min(f, cap[par[v]]);
        for(int v = t; v != s; v = to[par[v] ^ 1]){ cap[par[v]] -= f; cap[par[v] ^ 1] += f; }
        flow += f;
    }
    vector<int> par = bfs();             // BFS lần cuối: phía S = các đỉnh tới được
    vector<int> cut;
    for(int i = 0; i < m; i++)
        if(par[eu[i]] != -1 && par[ev[i]] == -1) cut.push_back(i);   // cung đi từ S sang T
    cout << flow << "\n" << cut.size() << "\n";
    for(int i : cut) cout << eu[i] << " " << ev[i] << "\n";
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, m, s, t = int(data[0]), int(data[1]), int(data[2]), int(data[3])
    adj = [[] for _ in range(n + 1)]
    to, cap, E = [], [], []
    for i in range(m):
        u, v, c = int(data[4 + 3 * i]), int(data[5 + 3 * i]), int(data[6 + 3 * i])
        E.append((u, v))
        adj[u].append(len(to)); to.append(v); cap.append(c)
        adj[v].append(len(to)); to.append(u); cap.append(0)

    def bfs():                            # par[v] = cạnh đi vào v, -1: không tới được
        par = [-1] * (n + 1)
        par[s] = -2
        q = deque([s])
        while q:
            u = q.popleft()
            for e in adj[u]:
                if par[to[e]] == -1 and cap[e] > 0:
                    par[to[e]] = e
                    q.append(to[e])
        return par

    flow = 0
    while True:
        par = bfs()
        if par[t] == -1:
            break
        f, v = float('inf'), t
        while v != s:
            f = min(f, cap[par[v]]); v = to[par[v] ^ 1]
        v = t
        while v != s:
            cap[par[v]] -= f; cap[par[v] ^ 1] += f; v = to[par[v] ^ 1]
        flow += f
    par = bfs()                           # BFS lần cuối: phía S = các đỉnh tới được
    cut = [(u, v) for u, v in E if par[u] != -1 and par[v] == -1]
    print(flow)
    print(len(cut))
    for u, v in cut:
        print(u, v)

main()
Lát cắt nhỏ nhất có thể không duy nhất
Code trên luôn in lát cắt có phía $S$ nhỏ nhất (chỉ gồm những đỉnh tới được). Có thể có lát cắt khác cùng dung lượng: chẳng hạn ở Hình 3, cắt {1→2, 1→3} hay cắt {4→7, 6→7} đều tốn 3. Vì vậy đề thi thường chỉ hỏi giá trị, hoặc chấp nhận mọi lát cắt đúng. Nếu cần phía $T$ nhỏ nhất thì BFS ngược từ $t$ theo các cạnh còn dương.

Phần 5Mô hình hoá: ghép cặp, đường đi rời nhau, König và Dinic

Sức mạnh thật của luồng nằm ở chỗ rất nhiều bài toán đổi được thành luồng. Kỹ năng quan trọng nhất là dựng mạng: đỉnh là gì, cung nào, dung lượng bao nhiêu.

1. Ghép cặp là một bài luồng. Thêm đỉnh phát $s$ nối tới mọi đỉnh trái (dung lượng 1), mọi đỉnh phải nối tới đỉnh thu $t$ (dung lượng 1), cạnh $L_i$–$R_j$ thành cung $L_i \to R_j$ dung lượng 1. Luồng nguyên lớn nhất chính là cặp ghép lớn nhất: dung lượng 1 ở hai đầu bảo đảm mỗi đỉnh dùng tối đa một lần. Thuật toán Kuhn thực chất là Ford–Fulkerson chạy trên mạng này, và việc “nhờ đỉnh cũ nhường chỗ” chính là đi theo cạnh ngược.

stL1R1L2R2L3R3mỗi đỉnh trái nhận tối đa 1mỗi đỉnh phải gửi đi tối đa 1

Hình 4. Ghép cặp L–R thành luồng: mọi cung (xanh: từ s và về t, tím: cạnh của đồ thị gốc) đều có dung lượng 1. Muốn mỗi người nhận tối đa $k$ việc thì đổi dung lượng $s \to L_i$ thành $k$.

Mô hình này mở rộng rất tự nhiên: mỗi người làm tối đa $a_i$ việc (cung $s \to L_i$ dung lượng $a_i$), mỗi việc cần $b_j$ người (cung $R_j \to t$ dung lượng $b_j$), hỏi có phân công đủ không (luồng có bằng $\sum b_j$ không). Kuhn không làm được những biến thể này, nhưng luồng thì làm được ngay.

2. Đường đi rời cạnh. Số đường đi từ $s$ tới $t$ nhiều nhất, đôi một không chung cạnh, bằng luồng cực đại khi mọi cung dung lượng 1 (định lý Menger). Đồ thị vô hướng thì mỗi cạnh $u$–$v$ thành cặp cạnh $u \to v$, $v \to u$ cùng dung lượng 1 và là cạnh ngược của nhau. Muốn các đường không chung đỉnh thì tách đỉnh: mỗi đỉnh $v$ thành $v_{vào} \to v_{ra}$ dung lượng 1, cung vào $v$ nối tới $v_{vào}$, cung ra khỏi $v$ đi từ $v_{ra}$.

3. Định lý König. Trên đồ thị hai phía, phủ đỉnh nhỏ nhất (chọn ít đỉnh nhất sao cho mọi cạnh có ít nhất một đầu được chọn) có kích thước bằng cặp ghép lớn nhất. Đây chính là “lát cắt nhỏ nhất = luồng lớn nhất” trên mạng Hình 4. Hệ quả: tập độc lập lớn nhất (nhiều đỉnh nhất, không hai đỉnh nào kề nhau) có $|V|$ − cặp ghép lớn nhất đỉnh. Bài “mỗi lần dọn sạch một hàng hoặc một cột” (bài tập 7) là ví dụ điển hình: hàng, cột là đỉnh, mỗi ô cần dọn là một cạnh, cần chọn ít hàng/cột nhất phủ mọi ô.

BướcViệc làmKết quả trên ví dụ
1Tìm cặp ghép lớn nhấtL1–R2, L2–R1 (2 cặp)
2Từ mọi đỉnh trái tự do, đi theo đường xen kẽ: trái → phải bằng cạnh chưa ghép, phải → trái bằng cạnh đã ghéptừ L3: L3 → R1 → L2; tập thăm Z = {L3, R1, L2}
3Phủ đỉnh = (đỉnh trái không thuộc Z) ∪ (đỉnh phải thuộc Z){L1} ∪ {R1}: 2 đỉnh = 2 cặp

Bảng 4. Dựng phủ đỉnh nhỏ nhất theo König trên đồ thị cạnh L1–R1, L1–R2, L2–R1, L3–R1. Chọn L1 và R1 là phủ hết 4 cạnh.

4. Phủ DAG bằng ít đường nhất. Cho đồ thị có hướng không chu trình, cần ít đường đi nhất (không chung đỉnh) sao cho mọi đỉnh nằm trên đúng một đường. Tách mỗi đỉnh thành bản “ra” (bên trái) và bản “vào” (bên phải), cung $u \to v$ thành cạnh $u_{ra}$–$v_{vào}$. Mỗi cạnh được ghép nối hai đỉnh liên tiếp trên cùng một đường, mỗi cặp ghép làm giảm số đường đi 1, nên đáp án là $n$ − cặp ghép lớn nhất (bài tập 8).

5. Thuật toán Dinic. Khi mạng lớn, Edmonds–Karp chậm vì mỗi lượt BFS chỉ đẩy một đường. Dinic cải tiến: mỗi pha BFS một lần để chia tầng (level) theo khoảng cách từ $s$, rồi dùng DFS đẩy nhiều đường cùng lúc, chỉ đi từ tầng $i$ sang tầng $i + 1$, tới khi không đẩy thêm được (gọi là luồng chặn). Mảng it[u] nhớ cạnh đang thử của $u$ để không thử lại cạnh đã “chết”. Sau mỗi pha, khoảng cách từ $s$ tới $t$ tăng ít nhất 1, nên có tối đa $V$ pha.

Xem code C++ / Python: luồng cực đại bằng dinic (đỉnh phát 1, đỉnh thu n) (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int n, m;
vector<int> to, level, it;
vector<long long> cap;
vector<vector<int>> adj;

void addEdge(int u, int v, long long c){
    adj[u].push_back(to.size()); to.push_back(v); cap.push_back(c);
    adj[v].push_back(to.size()); to.push_back(u); cap.push_back(0);
}

bool bfs(int s, int t){                       // chia tầng theo khoảng cách từ s
    level.assign(n + 1, -1);
    level[s] = 0;
    queue<int> q; q.push(s);
    while(!q.empty()){
        int u = q.front(); q.pop();
        for(int id : adj[u])
            if(cap[id] > 0 && level[to[id]] < 0){ level[to[id]] = level[u] + 1; q.push(to[id]); }
    }
    return level[t] >= 0;
}

long long dfs(int u, int t, long long f){     // chỉ đi từ tầng i sang tầng i + 1
    if(u == t) return f;
    for(int &k = it[u]; k < (int)adj[u].size(); k++){
        int id = adj[u][k], v = to[id];
        if(cap[id] > 0 && level[v] == level[u] + 1){
            long long d = dfs(v, t, min(f, cap[id]));
            if(d > 0){ cap[id] -= d; cap[id ^ 1] += d; return d; }
        }
    }
    return 0;                                 // it[u] đã qua hết: u "chết" trong pha này
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m;
    adj.assign(n + 1, {});
    for(int i = 0; i < m; i++){
        int u, v; long long c;
        cin >> u >> v >> c;
        addEdge(u, v, c);
    }
    long long flow = 0;
    while(bfs(1, n)){
        it.assign(n + 1, 0);
        while(long long f = dfs(1, n, LLONG_MAX)) flow += f;   // luồng chặn của pha
    }
    cout << flow;
    return 0;
}
import sys
from collections import deque
sys.setrecursionlimit(10000)

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    to, cap = [], []
    for i in range(m):
        u, v, c = int(data[2 + 3 * i]), int(data[3 + 3 * i]), int(data[4 + 3 * i])
        adj[u].append(len(to)); to.append(v); cap.append(c)
        adj[v].append(len(to)); to.append(u); cap.append(0)
    s, t, flow, INF = 1, n, 0, float('inf')
    while True:
        level = [-1] * (n + 1)                # chia tầng theo khoảng cách từ s
        level[s] = 0
        q = deque([s])
        while q:
            u = q.popleft()
            for e in adj[u]:
                if cap[e] > 0 and level[to[e]] < 0:
                    level[to[e]] = level[u] + 1
                    q.append(to[e])
        if level[t] < 0:
            break
        it = [0] * (n + 1)

        def dfs(u, f):                        # chỉ đi từ tầng i sang tầng i + 1
            if u == t:
                return f
            while it[u] < len(adj[u]):
                e = adj[u][it[u]]
                v = to[e]
                if cap[e] > 0 and level[v] == level[u] + 1:
                    d = dfs(v, min(f, cap[e]))
                    if d > 0:
                        cap[e] -= d; cap[e ^ 1] += d
                        return d
                it[u] += 1
            return 0

        while True:                           # luồng chặn của pha
            f = dfs(s, INF)
            if f == 0:
                break
            flow += f
    print(flow)

main()
Thuật toánĐộ phức tạpDùng khi
Kuhn$O(VE)$ghép cặp hai phía, $V \le$ vài nghìn; code ngắn nhất
Hopcroft–Karp$O(E\sqrt{V})$ghép cặp hai phía rất lớn, $V, E \sim 10^5$
Edmonds–Karp$O(VE^2)$luồng nhỏ, dễ cài và dễ truy lát cắt
Dinic$O(V^2E)$; mạng ghép cặp: $O(E\sqrt{V})$mặc định khi đi thi cho mọi bài luồng

Bảng 5. So sánh các thuật toán. Trên mạng ghép cặp (Hình 4), Dinic chạy đúng như Hopcroft–Karp. Thực tế Dinic thường nhanh hơn nhiều so với cận trên lý thuyết.

Dấu hiệu nhận ra bài ghép cặp, luồng
Đề có hai loại đối tượng ghép với nhau, mỗi đối tượng dùng có giới hạn số lần → ghép cặp hoặc luồng. Đề hỏi “ít nhất bao nhiêu thứ phải phá/chọn để chặn hết” → lát cắt hoặc phủ đỉnh (König). Đề hỏi “nhiều nhất bao nhiêu đường không chung cạnh/đỉnh” → Menger. Bảng, bàn cờ với quan hệ kề 4 hướng → tô màu ô theo $(i + j) \bmod 2$ được đồ thị hai phía. Còn nếu đồ thị không hai phía mà hỏi ghép cặp thì cần thuật toán Blossom (Edmonds), vượt quá phạm vi đề HSG, thường đề sẽ có cấu trúc đặc biệt để tránh.

Trên blog hiện chưa có bài lời giải nào thuộc chủ đề ghép cặp và luồng. Em luyện chắc phần nền tảng là BFS trên đồ thị (dùng trong tô 2 màu, Edmonds–Karp, Dinic) qua các bài dưới đây, rồi làm 8 bài tập cuối bài.

Luyện tập trên web
Các bài dưới đây trên blog đều có lời giải chi tiết bằng C++ và Python, xếp từ dễ tới khó. Em hãy tự làm trước rồi mới xem lời giải nhé.Xem toàn bộ bài cùng chủ đề: nhãn BFS.

Bài tậpLuyện ghép cặp và luồng cực đại

Bài 1Chia hai độiDễ

Lớp có $n$ bạn ($n \le 10^5$) và $m$ cặp bạn hay cãi nhau ($m \le 2 \cdot 10^5$). Thầy muốn chia cả lớp thành hai đội (một đội có thể rỗng) sao cho không có hai bạn cãi nhau nào chung đội. In YES nếu chia được, NO nếu không.

Input
4 4
1 2
2 3
3 4
4 1
Output
YES

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Đây chính là kiểm tra đồ thị hai phía ở Phần 1: mỗi bạn là một đỉnh, mỗi cặp cãi nhau là một cạnh, hai đội là hai màu. Ví dụ là chu trình 4 đỉnh (chẵn) nên chia được: {1, 3} và {2, 4}. Nhớ BFS từ mọi đỉnh chưa tô vì lớp có thể có nhiều nhóm rời nhau.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<vector<int>> adj(n + 1);
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    vector<int> color(n + 1, 0);
    for(int s = 1; s <= n; s++){
        if(color[s]) continue;
        color[s] = 1;
        queue<int> q; q.push(s);
        while(!q.empty()){
            int u = q.front(); q.pop();
            for(int v : adj[u]){
                if(!color[v]){ color[v] = 3 - color[u]; q.push(v); }
                else if(color[v] == color[u]){ cout << "NO"; return 0; }
            }
        }
    }
    cout << "YES";
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append(v); adj[v].append(u)
    color = [0] * (n + 1)
    for s in range(1, n + 1):
        if color[s]:
            continue
        color[s] = 1
        q = deque([s])
        while q:
            u = q.popleft()
            for v in adj[u]:
                if not color[v]:
                    color[v] = 3 - color[u]; q.append(v)
                elif color[v] == color[u]:
                    print("NO"); return
    print("YES")

main()
Bài 2Phân công công việcDễ

Có $n$ người và $k$ công việc ($n, k \le 200$). Cho $m$ cặp $(a, b)$ ($m \le 5000$) nghĩa là người $a$ làm được việc $b$. Mỗi người nhận tối đa một việc, mỗi việc giao cho tối đa một người. Hỏi giao được nhiều nhất bao nhiêu việc?

Input
3 3 4
1 1
1 2
2 1
3 1
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Ghép cặp cực đại, chạy thẳng thuật toán Kuhn. Ví dụ: người 2 và người 3 chỉ làm được việc 1 nên chỉ một trong hai người có việc; người 1 nhận việc 2. Đáp án 2. Lưu ý một người có thể xuất hiện ở nhiều cặp, có việc không ai làm được.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int n, k, m;
vector<int> adj[205];
int matchJob[205], vis[205], stamp = 0;   // matchJob[j] = người đang làm việc j

bool tryKuhn(int u){
    for(int j : adj[u]){
        if(vis[j] == stamp) continue;
        vis[j] = stamp;
        if(matchJob[j] == 0 || tryKuhn(matchJob[j])){ matchJob[j] = u; return true; }
    }
    return false;
}

int main(){
    cin >> n >> k >> m;
    for(int i = 0; i < m; i++){
        int a, b;
        cin >> a >> b;
        adj[a].push_back(b);
    }
    int res = 0;
    for(int u = 1; u <= n; u++){ stamp++; if(tryKuhn(u)) res++; }
    cout << res;
    return 0;
}
import sys
sys.setrecursionlimit(10000)

def main():
    data = sys.stdin.buffer.read().split()
    n, k, m = int(data[0]), int(data[1]), int(data[2])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        adj[int(data[3 + 2 * i])].append(int(data[4 + 2 * i]))
    match_job = [0] * (k + 1)             # match_job[j] = người đang làm việc j
    vis = [0] * (k + 1)

    def try_kuhn(u, stamp):
        for j in adj[u]:
            if vis[j] == stamp:
                continue
            vis[j] = stamp
            if match_job[j] == 0 or try_kuhn(match_job[j], stamp):
                match_job[j] = u
                return True
        return False

    print(sum(1 for u in range(1, n + 1) if try_kuhn(u, u)))

main()
Bài 3Hệ thống ống nướcTrung bình

Thành phố có $n$ trạm ($n \le 200$) và $m$ ống một chiều ($m \le 2000$); ống thứ $i$ dẫn nước từ trạm $u_i$ tới trạm $v_i$ với lưu lượng tối đa $c_i$ ($1 \le c_i \le 10^9$). Có thể có nhiều ống nối cùng hai trạm. Hỏi mỗi giây bơm được nhiều nhất bao nhiêu nước từ trạm 1 tới trạm $n$?

Input
4 5
1 2 3
1 3 2
2 3 1
2 4 2
3 4 3
Output
5

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Luồng cực đại thuần tuý, dùng Dinic ở Phần 5 (Edmonds–Karp cũng qua với giới hạn này). Ví dụ: 2 đơn vị theo 1→2→4, 2 theo 1→3→4, 1 theo 1→2→3→4. Lát cắt {1} có dung lượng 3 + 2 = 5 nên không thể hơn. Tổng có thể tới $2 \cdot 10^{12}$: bắt buộc long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int n, m;
vector<int> to, level, it;
vector<long long> cap;
vector<vector<int>> adj;

void addEdge(int u, int v, long long c){
    adj[u].push_back(to.size()); to.push_back(v); cap.push_back(c);
    adj[v].push_back(to.size()); to.push_back(u); cap.push_back(0);
}

bool bfs(int s, int t){                       // chia tầng theo khoảng cách từ s
    level.assign(n + 1, -1);
    level[s] = 0;
    queue<int> q; q.push(s);
    while(!q.empty()){
        int u = q.front(); q.pop();
        for(int id : adj[u])
            if(cap[id] > 0 && level[to[id]] < 0){ level[to[id]] = level[u] + 1; q.push(to[id]); }
    }
    return level[t] >= 0;
}

long long dfs(int u, int t, long long f){     // chỉ đi từ tầng i sang tầng i + 1
    if(u == t) return f;
    for(int &k = it[u]; k < (int)adj[u].size(); k++){
        int id = adj[u][k], v = to[id];
        if(cap[id] > 0 && level[v] == level[u] + 1){
            long long d = dfs(v, t, min(f, cap[id]));
            if(d > 0){ cap[id] -= d; cap[id ^ 1] += d; return d; }
        }
    }
    return 0;                                 // it[u] đã qua hết: u "chết" trong pha này
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m;
    adj.assign(n + 1, {});
    for(int i = 0; i < m; i++){
        int u, v; long long c;
        cin >> u >> v >> c;
        addEdge(u, v, c);
    }
    long long flow = 0;
    while(bfs(1, n)){
        it.assign(n + 1, 0);
        while(long long f = dfs(1, n, LLONG_MAX)) flow += f;   // luồng chặn của pha
    }
    cout << flow;
    return 0;
}
import sys
from collections import deque
sys.setrecursionlimit(10000)

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    to, cap = [], []
    for i in range(m):
        u, v, c = int(data[2 + 3 * i]), int(data[3 + 3 * i]), int(data[4 + 3 * i])
        adj[u].append(len(to)); to.append(v); cap.append(c)
        adj[v].append(len(to)); to.append(u); cap.append(0)
    s, t, flow, INF = 1, n, 0, float('inf')
    while True:
        level = [-1] * (n + 1)                # chia tầng theo khoảng cách từ s
        level[s] = 0
        q = deque([s])
        while q:
            u = q.popleft()
            for e in adj[u]:
                if cap[e] > 0 and level[to[e]] < 0:
                    level[to[e]] = level[u] + 1
                    q.append(to[e])
        if level[t] < 0:
            break
        it = [0] * (n + 1)

        def dfs(u, f):                        # chỉ đi từ tầng i sang tầng i + 1
            if u == t:
                return f
            while it[u] < len(adj[u]):
                e = adj[u][it[u]]
                v = to[e]
                if cap[e] > 0 and level[v] == level[u] + 1:
                    d = dfs(v, min(f, cap[e]))
                    if d > 0:
                        cap[e] -= d; cap[e ^ 1] += d
                        return d
                it[u] += 1
            return 0

        while True:                           # luồng chặn của pha
            f = dfs(s, INF)
            if f == 0:
                break
            flow += f
    print(flow)

main()
Bài 4Những con đường không chungTrung bình

Có $n$ thành phố ($n \le 100$) và $m$ con đường hai chiều ($m \le 1000$). Đoàn xe cần đi từ thành phố 1 tới thành phố $n$ theo nhiều lộ trình khác nhau, hai lộ trình bất kỳ không được đi chung một con đường (được phép đi qua chung thành phố). Hỏi có thể chọn nhiều nhất bao nhiêu lộ trình?

Input
4 5
1 2
1 3
2 3
2 4
3 4
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Định lý Menger: số đường rời cạnh lớn nhất = luồng cực đại khi mỗi đường dung lượng 1. Đường hai chiều: thêm cặp cạnh $u \to v$ và $v \to u$ mỗi chiều dung lượng 1 và là cạnh ngược của nhau (trong code: cạnh ngược khởi tạo cap = 1 thay vì 0). Ví dụ: 1–2–4 và 1–3–4. Thành phố 1 chỉ có 2 đường đi ra nên không thể quá 2.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<int> to;
    vector<long long> cap;
    vector<vector<int>> adj(n + 1);
    for(int i = 0; i < m; i++){
        int u, v; long long c = 1;
        cin >> u >> v;
        adj[u].push_back(to.size()); to.push_back(v); cap.push_back(c);   // u -> v
        adj[v].push_back(to.size()); to.push_back(u); cap.push_back(c);   // v -> u, cũng dung lượng c
    }
    long long flow = 0;
    while(true){
        vector<int> par(n + 1, -1);
        par[1] = -2;
        queue<int> q; q.push(1);
        while(!q.empty() && par[n] == -1){
            int u = q.front(); q.pop();
            for(int id : adj[u])
                if(par[to[id]] == -1 && cap[id] > 0){ par[to[id]] = id; q.push(to[id]); }
        }
        if(par[n] == -1) break;
        long long f = LLONG_MAX;
        for(int v = n; v != 1; v = to[par[v] ^ 1]) f = min(f, cap[par[v]]);
        for(int v = n; v != 1; v = to[par[v] ^ 1]){ cap[par[v]] -= f; cap[par[v] ^ 1] += f; }
        flow += f;
    }
    cout << flow;
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    to, cap = [], []
    pos = 2
    for _ in range(m):
        u, v = int(data[pos]), int(data[pos + 1]); pos += 2
        c = 1
        adj[u].append(len(to)); to.append(v); cap.append(c)   # u -> v
        adj[v].append(len(to)); to.append(u); cap.append(c)   # v -> u, cũng dung lượng c
    flow = 0
    while True:
        par = [-1] * (n + 1)
        par[1] = -2
        q = deque([1])
        while q and par[n] == -1:
            u = q.popleft()
            for e in adj[u]:
                if par[to[e]] == -1 and cap[e] > 0:
                    par[to[e]] = e
                    q.append(to[e])
        if par[n] == -1:
            break
        f, v = float('inf'), n
        while v != 1:
            f = min(f, cap[par[v]]); v = to[par[v] ^ 1]
        v = n
        while v != 1:
            cap[par[v]] -= f; cap[par[v] ^ 1] += f; v = to[par[v] ^ 1]
        flow += f
    print(flow)

main()
Bài 5Cắt đứt liên lạcKhá

Có $n$ đồn ($n \le 100$) và $m$ con đường hai chiều ($m \le 1000$); phá con đường thứ $i$ tốn $c_i$ ($1 \le c_i \le 10^9$). Tìm tổng chi phí nhỏ nhất để phá một số đường sao cho từ đồn 1 không còn đi được tới đồn $n$.

Input
4 5
1 2 5
1 3 4
2 3 1
2 4 2
3 4 6
Output
7

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Lát cắt nhỏ nhất = luồng cực đại (Phần 4). Đường hai chiều chi phí $c$ thành cặp cạnh mỗi chiều dung lượng $c$, là cạnh ngược của nhau. Ví dụ: phá 1–3 (4), 2–3 (1), 2–4 (2) tốn 7 và tách {1, 2} khỏi {3, 4}. Phá hết đường quanh đồn 1 tốn 9, quanh đồn 4 tốn 8, đều đắt hơn.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<int> to;
    vector<long long> cap;
    vector<vector<int>> adj(n + 1);
    for(int i = 0; i < m; i++){
        int u, v; long long c;
        cin >> u >> v >> c;
        adj[u].push_back(to.size()); to.push_back(v); cap.push_back(c);   // u -> v
        adj[v].push_back(to.size()); to.push_back(u); cap.push_back(c);   // v -> u, cũng dung lượng c
    }
    long long flow = 0;
    while(true){
        vector<int> par(n + 1, -1);
        par[1] = -2;
        queue<int> q; q.push(1);
        while(!q.empty() && par[n] == -1){
            int u = q.front(); q.pop();
            for(int id : adj[u])
                if(par[to[id]] == -1 && cap[id] > 0){ par[to[id]] = id; q.push(to[id]); }
        }
        if(par[n] == -1) break;
        long long f = LLONG_MAX;
        for(int v = n; v != 1; v = to[par[v] ^ 1]) f = min(f, cap[par[v]]);
        for(int v = n; v != 1; v = to[par[v] ^ 1]){ cap[par[v]] -= f; cap[par[v] ^ 1] += f; }
        flow += f;
    }
    cout << flow;
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    to, cap = [], []
    pos = 2
    for _ in range(m):
        u, v = int(data[pos]), int(data[pos + 1]); pos += 2
        c = int(data[pos]); pos += 1
        adj[u].append(len(to)); to.append(v); cap.append(c)   # u -> v
        adj[v].append(len(to)); to.append(u); cap.append(c)   # v -> u, cũng dung lượng c
    flow = 0
    while True:
        par = [-1] * (n + 1)
        par[1] = -2
        q = deque([1])
        while q and par[n] == -1:
            u = q.popleft()
            for e in adj[u]:
                if par[to[e]] == -1 and cap[e] > 0:
                    par[to[e]] = e
                    q.append(to[e])
        if par[n] == -1:
            break
        f, v = float('inf'), n
        while v != 1:
            f = min(f, cap[par[v]]); v = to[par[v] ^ 1]
        v = n
        while v != 1:
            cap[par[v]] -= f; cap[par[v] ^ 1] += f; v = to[par[v] ^ 1]
        flow += f
    print(flow)

main()
Bài 6Lát gạch dominoKhá

Cho bảng $n \times m$ ($n, m \le 30$), ô . là ô trống, ô # là ô bị chặn. Đặt các viên domino $1 \times 2$ (nằm ngang hoặc dọc) lên các ô trống, hai viên không chồng nhau. Hỏi đặt được nhiều nhất bao nhiêu viên?

Input
3 3
...
.#.
...
Output
4

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Tô bảng như bàn cờ theo $(i + j) \bmod 2$: mỗi viên domino luôn phủ một ô đen và một ô trắng kề nhau. Vậy ô đen là phía trái, ô trắng là phía phải, hai ô trống kề nhau là một cạnh, số domino nhiều nhất = ghép cặp cực đại. Ví dụ: 8 ô trống quanh ô giữa xếp thành vòng, đặt đủ 4 viên.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int n, m;
vector<string> g;
vector<int> adj[1005];          // ô đen (i + j chẵn) -> các ô trắng kề
int matchW[1005], vis[1005], stamp = 0;

bool tryKuhn(int u){
    for(int w : adj[u]){
        if(vis[w] == stamp) continue;
        vis[w] = stamp;
        if(matchW[w] == -1 || tryKuhn(matchW[w])){ matchW[w] = u; return true; }
    }
    return false;
}

int main(){
    cin >> n >> m;
    g.resize(n);
    for(auto &row : g) cin >> row;
    int dx[] = {1, -1, 0, 0}, dy[] = {0, 0, 1, -1};
    for(int i = 0; i < n; i++)
        for(int j = 0; j < m; j++){
            if(g[i][j] == '#' || (i + j) % 2) continue;
            for(int d = 0; d < 4; d++){
                int x = i + dx[d], y = j + dy[d];
                if(x >= 0 && x < n && y >= 0 && y < m && g[x][y] == '.') adj[i * m + j].push_back(x * m + y);
            }
        }
    memset(matchW, -1, sizeof(matchW));
    int res = 0;
    for(int u = 0; u < n * m; u++)
        if(!adj[u].empty()){ stamp++; if(tryKuhn(u)) res++; }
    cout << res;
    return 0;
}
import sys
sys.setrecursionlimit(10000)

def main():
    data = sys.stdin.read().split()
    n, m = int(data[0]), int(data[1])
    g = data[2:2 + n]
    adj = {}                              # ô đen (i + j chẵn) -> các ô trắng kề
    for i in range(n):
        for j in range(m):
            if g[i][j] == '#' or (i + j) % 2:
                continue
            nb = []
            for x, y in ((i + 1, j), (i - 1, j), (i, j + 1), (i, j - 1)):
                if 0 <= x < n and 0 <= y < m and g[x][y] == '.':
                    nb.append(x * m + y)
            if nb:
                adj[i * m + j] = nb
    match_w = [-1] * (n * m)
    vis = [0] * (n * m)

    def try_kuhn(u, stamp):
        for w in adj[u]:
            if vis[w] == stamp:
                continue
            vis[w] = stamp
            if match_w[w] == -1 or try_kuhn(match_w[w], stamp):
                match_w[w] = u
                return True
        return False

    res = 0
    for k, u in enumerate(adj):
        if try_kuhn(u, k + 1):
            res += 1
    print(res)

main()
Bài 7Robot dọn rácKhá

Sân trường là bảng $n \times m$ ($n, m \le 100$), ô * có rác, ô . sạch. Mỗi lượt, robot dọn sạch cả một hàng hoặc cả một cột. Hỏi cần ít nhất bao nhiêu lượt để sân sạch hết?

Input
3 4
*.*.
.*..
.*..
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Mỗi hàng là một đỉnh trái, mỗi cột là một đỉnh phải, ô rác $(i, j)$ là cạnh nối hàng $i$ với cột $j$. Chọn hàng/cột để dọn hết rác = chọn tập đỉnh phủ mọi cạnh, tức là phủ đỉnh nhỏ nhất. Theo định lý König, đáp án bằng cặp ghép lớn nhất. Ví dụ: dọn hàng 1 và cột 2. Nếu đề bắt in các hàng/cột cần dọn thì dựng tập phủ như Bảng 4.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int n, m;
vector<int> adj[105];           // hàng i -> các cột có rác ở hàng i
int matchCol[105], vis[105], stamp = 0;

bool tryKuhn(int r){
    for(int c : adj[r]){
        if(vis[c] == stamp) continue;
        vis[c] = stamp;
        if(matchCol[c] == 0 || tryKuhn(matchCol[c])){ matchCol[c] = r; return true; }
    }
    return false;
}

int main(){
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
        string s;
        cin >> s;
        for(int j = 1; j <= m; j++) if(s[j - 1] == '*') adj[i].push_back(j);   // ô rác = cạnh (hàng, cột)
    }
    int res = 0;
    for(int i = 1; i <= n; i++){ stamp++; if(tryKuhn(i)) res++; }
    cout << res;                  // König: phủ đỉnh nhỏ nhất = ghép cặp lớn nhất
    return 0;
}
import sys
sys.setrecursionlimit(10000)

def main():
    data = sys.stdin.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[j + 1 for j, ch in enumerate(data[2 + i]) if ch == '*'] for i in range(n)]
    match_col = [0] * (m + 1)
    vis = [0] * (m + 1)

    def try_kuhn(r, stamp):               # r: chỉ số hàng (đếm từ 1)
        for c in adj[r - 1]:
            if vis[c] == stamp:
                continue
            vis[c] = stamp
            if match_col[c] == 0 or try_kuhn(match_col[c], stamp):
                match_col[c] = r
                return True
        return False

    print(sum(1 for r in range(1, n + 1) if try_kuhn(r, r)))   # König

main()
Bài 8Ít robot nhấtKhó

Nhà kho có $n$ trạm ($n \le 300$) nối bởi $m$ băng chuyền một chiều ($m \le 5000$), không có vòng tròn khép kín (đồ thị DAG). Mỗi robot xuất phát ở một trạm bất kỳ, đi theo các băng chuyền và dừng ở một trạm bất kỳ. Mỗi trạm phải được đúng một robot đi qua. Hỏi cần ít nhất bao nhiêu robot?

Input
5 4
1 2
2 3
1 4
4 5
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Phủ DAG bằng ít đường không chung đỉnh nhất (mục 4 Phần 5). Lúc đầu cho mỗi trạm một robot ($n$ robot). Mỗi băng chuyền $u \to v$ được dùng sẽ nối hai robot làm một, nhưng mỗi trạm $u$ chỉ đi tiếp tới tối đa một trạm, mỗi trạm $v$ chỉ được tới từ tối đa một trạm: đó đúng là ghép cặp giữa bản “ra” và bản “vào”. Đáp án $n$ − cặp ghép lớn nhất. Ví dụ: robot 1→2→3 và robot 4→5, ghép được 3 cặp, $5 - 3 = 2$. (Nếu đề cho phép nhiều robot cùng đi qua một trạm thì phải lấy bao đóng bắc cầu trước rồi mới ghép.)
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int n, m;
vector<int> adj[305];           // bản "ra" của u -> bản "vào" của v
int matchIn[305], vis[305], stamp = 0;

bool tryKuhn(int u){
    for(int v : adj[u]){
        if(vis[v] == stamp) continue;
        vis[v] = stamp;
        if(matchIn[v] == 0 || tryKuhn(matchIn[v])){ matchIn[v] = u; return true; }
    }
    return false;
}

int main(){
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
    }
    int match = 0;
    for(int u = 1; u <= n; u++){ stamp++; if(tryKuhn(u)) match++; }
    cout << n - match;            // mỗi cặp ghép nối hai đoạn đường thành một
    return 0;
}
import sys
sys.setrecursionlimit(10000)

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]      # bản "ra" của u -> bản "vào" của v
    for i in range(m):
        adj[int(data[2 + 2 * i])].append(int(data[3 + 2 * i]))
    match_in = [0] * (n + 1)
    vis = [0] * (n + 1)

    def try_kuhn(u, stamp):
        for v in adj[u]:
            if vis[v] == stamp:
                continue
            vis[v] = stamp
            if match_in[v] == 0 or try_kuhn(match_in[v], stamp):
                match_in[v] = u
                return True
        return False

    match = sum(1 for u in range(1, n + 1) if try_kuhn(u, u))
    print(n - match)                      # mỗi cặp ghép nối hai đoạn đường thành một

main()
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Bản quyền thuộc về Thầy Nguyễn Việt Nam Sơn. Bài viết, lời giải, hình minh hoạ và mã nguồn trên trang là công sức biên soạn của thầy qua nhiều năm giảng dạy. Vui lòng không sao chép, đăng lại hay sử dụng dưới mọi hình thức khi chưa được sự đồng ý của tác giả. Nếu thấy hữu ích, hãy chia sẻ bằng đường link bài gốc nhé. Cảm ơn bạn!
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét