Chương 5·Bài 5.1

Eulerian Path

Cho một đồ thị vô hướng nn đỉnh, mm cạnh. Tìm một chu trình đi qua mỗi cạnh đúng một lần, hoặc cho biết không có chu trình như vậy, trong O(n+m)O(n + m).

Đây là bài toán chu trình Euler (Eulerian circuit), cũng chính là câu đố quen thuộc “vẽ một hình bằng một nét bút, không nhấc bút và không tô lại nét nào”. Có chu trình hay không chỉ cần nhìn bậc của các đỉnh. Để dựng chu trình, thuật toán Hierholzer cứ đi theo các cạnh chưa dùng cho tới khi kẹt, rồi chèn thêm các vòng nhỏ vào những chỗ còn cạnh chưa đi. Nhiều bài trông chẳng liên quan cũng đưa được về Euler: xâu ngắn nhất chứa mọi mẫu, khôi phục dãy từ các cặp kề nhau, định hướng cạnh sao cho các đỉnh cân bằng.

IKhi nào có chu trình, đường đi Euler

Một đường đi Euler là một đường đi qua mỗi cạnh của đồ thị đúng một lần; được phép đi qua một đỉnh nhiều lần. Nếu đường đi đó kết thúc ở chính đỉnh xuất phát thì ta có một chu trình Euler. Bậc của một đỉnh là số cạnh nối với nó; một khuyên (cạnh nối một đỉnh với chính nó) góp 22 vào bậc.

Cách dễ nghĩ ra nhất là quay lui (bài 0.8): thử mọi cách chọn cạnh đi tiếp. Số cách đi tăng theo cấp số nhân, nên chỉ chạy được khi đồ thị có vài chục cạnh. May mắn là chỉ cần nhìn bậc của các đỉnh là biết có chu trình hay không:

  • Đồ thị vô hướng có chu trình Euler khi và chỉ khi mọi đỉnh có bậc chẵn, và mọi cạnh nằm trong cùng một thành phần liên thông.
  • Đồ thị vô hướng có đường đi Euler khi và chỉ khi có đúng 00 hoặc đúng 22 đỉnh có bậc lẻ, và mọi cạnh nằm trong cùng một thành phần liên thông. Khi có hai đỉnh bậc lẻ, đường đi bắt đầu ở một đỉnh và kết thúc ở đỉnh kia.

Điều kiện liên thông chỉ xét những đỉnh có cạnh; một đỉnh không có cạnh nào thì không ảnh hưởng gì.

Chứng minh

Điều kiện cần. Đi theo một chu trình Euler, mỗi lần đi ngang qua một đỉnh ta dùng một cạnh để vào và một cạnh để ra. Mỗi cạnh được dùng đúng một lần, nên các cạnh của mỗi đỉnh chia thành từng cặp (vào, ra), và bậc của mỗi đỉnh là số chẵn. Với đường đi Euler, đỉnh đầu có thêm một cạnh ra không ghép cặp và đỉnh cuối có thêm một cạnh vào không ghép cặp, nên nếu hai đỉnh này khác nhau thì chúng có bậc lẻ, còn mọi đỉnh khác có bậc chẵn.

Điều kiện đủ cho chu trình được chứng minh ở mục sau, bằng chính thuật toán dựng chu trình. Với đường đi khi có đúng hai đỉnh bậc lẻ uu và vv: thêm tạm một cạnh uu, vv thì mọi đỉnh có bậc chẵn, nên có chu trình Euler; bỏ cạnh tạm khỏi chu trình đó, ta được đường đi Euler từ uu tới vv.

Đồ thị có hướng làm tương tự, với bậc vào (số cung đi vào) và bậc ra (số cung đi ra). Có chu trình Euler khi mọi đỉnh có bậc vào bằng bậc ra; có đường đi Euler từ ss tới tt khi đỉnh ss có bậc ra hơn bậc vào đúng 11, đỉnh tt có bậc vào hơn bậc ra đúng 11, còn mọi đỉnh khác có bậc vào bằng bậc ra. Cả hai trường hợp đều cần các cung nằm trong cùng một thành phần liên thông khi bỏ hướng.

Hỏi nhanh

Một đồ thị vô hướng liên thông có đúng 44 đỉnh bậc lẻ. Ta muốn vẽ hết các cạnh bằng một số nét bút, mỗi nét là một đường đi, và mỗi cạnh được vẽ đúng một lần.

Cần ít nhất bao nhiêu nét bút?

Ví dụĐồ Thị Khép Kín

Đề. Có nhiều đồ thị vô hướng, kết thúc bằng một dòng chỉ có số 00. Mỗi đồ thị có n≤999n \le 999 đỉnh. In 11 nếu đồ thị có chu trình Euler và cả nn đỉnh liên thông, ngược lại in 00.

Lời giải. Ta kiểm hai điều: mọi đỉnh có bậc chẵn, và cả nn đỉnh nằm trong một thành phần liên thông. Đề đòi cả nn đỉnh liên thông, nên khác với điều kiện chung ở trên, một đỉnh không có cạnh cũng làm đáp án thành 00. Để kiểm liên thông, ta dùng DSU (bài 2.4): mỗi cạnh hợp hai đầu mút, và mỗi lần hợp được hai tập khác nhau thì số thành phần giảm 11. Mỗi đồ thị cần đặt lại DSU và bậc của các đỉnh.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1005;
ll n, m, par[N], deg[N];

ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    while (cin >> n && n) {
        cin >> m;
        for (ll i = 1; i <= n; i++) par[i] = i, deg[i] = 0;
        ll comps = n;
        for (ll i = 1; i <= m; i++) {
            ll u, v;
            cin >> u >> v;
            deg[u]++;
            deg[v]++;
            u = find(u), v = find(v);
            if (u != v) par[u] = v, comps--;
        }
        bool ok = comps == 1;
        for (ll i = 1; i <= n; i++)
            if (deg[i] % 2) ok = 0;
        cout << ok << '\n';
    }
}
3 3
1 2
2 3
3 1
4 3
1 2
2 3
3 1
0

Độ phức tạp: O(n+m)O(n + m) cho mỗi đồ thị, tính cả DSU; với n=1000n = 1000 và mm cỡ vài nghìn cạnh, cần khoảng 10410^4 phép tính.

IIDựng chu trình: thuật toán Hierholzer

Giả sử mọi đỉnh có bậc chẵn. Ta bắt đầu từ một đỉnh ss và cứ đi theo một cạnh chưa dùng bất kỳ, cho tới khi đứng ở một đỉnh không còn cạnh chưa dùng. Ta có thể thấy rằng chỗ kẹt luôn là chính đỉnh ss (xem ô Chứng minh), nên ta được một chu trình. Nếu chu trình này chưa đi hết các cạnh, các cạnh còn lại gắn vào một đỉnh nào đó trên chu trình; từ đỉnh đó ta đi thêm một vòng nhỏ theo các cạnh chưa dùng, rồi chèn vòng nhỏ đó vào chu trình lớn tại đúng đỉnh ấy. Lặp lại cho tới khi hết cạnh, ta được chu trình Euler.

Thuật toán Hierholzer làm việc chèn này một cách tự động bằng một ngăn xếp:

  • ban đầu ngăn xếp chỉ có đỉnh ss;
  • xét đỉnh trên cùng của ngăn xếp: nếu nó còn cạnh chưa dùng, đánh dấu cạnh đó đã dùng và đưa đỉnh ở đầu kia vào ngăn xếp;
  • nếu nó không còn cạnh chưa dùng, lấy nó ra khỏi ngăn xếp và ghi vào kết quả.

Khi ngăn xếp rỗng, dãy đỉnh trong kết quả là chu trình Euler viết theo thứ tự ngược; đảo lại là xong. Trong mô phỏng dưới đây, cạnh xanh lá là cạnh đã dùng, đỉnh xanh dương là đỉnh đang nằm trên ngăn xếp; bạn có thể thêm một cạnh để thấy điều kiện bậc chẵn bị hỏng:

Đỉnh được ghi vào đường khi lùi. Thêm một cạnh để thấy điều kiện bậc chẵn hỏng.
Chứng minh

Một vòng đi chỉ kẹt ở chỗ nó bắt đầu. Giả sử ở một lúc nào đó, mọi đỉnh đều có số cạnh đã dùng là số chẵn, và ta bắt đầu đi từ đỉnh ww theo các cạnh chưa dùng. Mỗi lần đi vào một đỉnh vv khác ww, số cạnh đã dùng ở vv thành số lẻ, mà bậc của vv chẵn, nên vv còn ít nhất một cạnh chưa dùng để đi tiếp. Vì vậy vòng đi chỉ có thể kẹt ở ww, tức là nó là một chu trình.

Kết quả là chu trình Euler. Vòng đi đầu tiên bắt đầu khi chưa có cạnh nào được dùng, nên nó là một chu trình từ ss. Mỗi khi đỉnh trên cùng hết cạnh, ta lấy nó ra và ghi vào kết quả. Nếu đỉnh ngay dưới nó còn cạnh chưa dùng, thì lúc đó các cạnh đã dùng gồm phần đường còn trên ngăn xếp nối với phần đã ghi, tạo thành một chu trình, nên mọi đỉnh có số cạnh đã dùng chẵn. Theo ý trên, vòng đi mới từ đỉnh đó là một chu trình nhỏ quay về chính nó, và nó được ghi vào kết quả ngay tại vị trí của đỉnh đó, tức là được chèn vào chu trình lớn. Mọi cạnh tới được từ ss đều được đi đúng một lần, nên nếu các cạnh liên thông, kết quả là chu trình Euler. Điều này cũng chứng minh điều kiện đủ ở mục trước.

Khi cài đặt cho đồ thị vô hướng, mỗi cạnh nằm trong danh sách kề của cả hai đầu mút. Đi theo cạnh từ một đầu thì đầu kia cũng phải biết cạnh đó đã dùng, vì vậy danh sách kề lưu số hiệu của cạnh chứ không lưu đỉnh kề, và ta đánh dấu đã dùng theo số hiệu cạnh. Mỗi đỉnh giữ thêm một con trỏ tới cạnh đầu tiên chưa xét trong danh sách kề của nó; con trỏ chỉ tiến, nên cả thuật toán chỉ tốn O(n+m)O(n + m). Dùng ngăn xếp tự quản lý thay cho đệ quy, vì đệ quy sâu tới mm tầng có thể tràn bộ nhớ ngăn xếp.

Nếu đồ thị có cạnh nằm ở thành phần không chứa ss, thuật toán không đi tới được chúng, và kết quả có ít hơn m+1m + 1 đỉnh. Vì vậy thay cho bước kiểm liên thông riêng, ta chỉ cần kiểm kết quả có đúng m+1m + 1 đỉnh hay không.

Ví dụHành Trình Qua Mọi Cạnh

Đề. Cho đa đồ thị vô hướng n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cạnh (có thể có cạnh song song). In một chu trình đi qua mỗi cạnh đúng một lần, bắt đầu và kết thúc ở đỉnh 11, hoặc in IMPOSSIBLE nếu không có.

Lời giải. Nếu có đỉnh bậc lẻ thì không có chu trình. Ngược lại, ta chạy Hierholzer từ đỉnh 11; kết quả có ít hơn m+1m + 1 đỉnh nghĩa là có cạnh không tới được từ đỉnh 11, cũng không có chu trình. Trong code, eu[e] và ev[e] là hai đầu mút của cạnh ee, g[u] là danh sách số hiệu các cạnh nối với uu, used[e] đánh dấu cạnh đã dùng, và ptr[u] là con trỏ của đỉnh uu.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5, M = 2e5 + 5;
ll n, m, eu[M], ev[M], used[M], ptr[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++) {
        cin >> eu[i] >> ev[i];
        g[eu[i]].push_back(i);
        g[ev[i]].push_back(i);
    }
    for (ll u = 1; u <= n; u++)
        if (g[u].size() % 2) {
            cout << "IMPOSSIBLE\n";
            return 0;
        }
    vector<ll> st = {1}, path;
    while (!st.empty()) {
        ll u = st.back();
        while (ptr[u] < (ll)g[u].size() && used[g[u][ptr[u]]]) ptr[u]++;
        if (ptr[u] == (ll)g[u].size()) {
            path.push_back(u);
            st.pop_back();
        } else {
            ll e = g[u][ptr[u]];
            used[e] = 1;
            st.push_back(eu[e] == u ? ev[e] : eu[e]);
        }
    }
    if ((ll)path.size() != m + 1) {
        cout << "IMPOSSIBLE\n";
        return 0;
    }
    reverse(path.begin(), path.end());
    for (ll v : path) cout << v << ' ';
    cout << '\n';
}
4 4
1 2
2 3
3 4
4 1

Độ phức tạp: O(n+m)O(n + m), vì mỗi cạnh được xét hai lần (một lần từ mỗi đầu) và mỗi con trỏ chỉ tiến; với n=105n = 10^5 và m=2⋅105m = 2 \cdot 10^5 cần khoảng 5⋅1055 \cdot 10^5 phép tính.

Hỏi nhanh

Một bạn lấy code Hierholzer của đồ thị có hướng (danh sách kề lưu đỉnh kề, chỉ dùng con trỏ, không đánh dấu cạnh) rồi chạy trên đồ thị vô hướng, thêm mỗi cạnh vào danh sách kề của cả hai đầu. Bạn có thể bấm Chạy với đồ thị tam giác 1,2,31, 2, 3:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, m, ptr[N];
vector<ll> g[N];

signed main() {
    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    vector<ll> st = {1}, path;
    while (!st.empty()) {
        ll u = st.back();
        if (ptr[u] == (ll)g[u].size()) {
            path.push_back(u);
            st.pop_back();
        } else st.push_back(g[u][ptr[u]++]);
    }
    reverse(path.begin(), path.end());
    for (ll v : path) cout << v << ' ';
    cout << '\n';
}
3 3
1 2
2 3
3 1

Tam giác chỉ có 33 cạnh, nên chu trình đúng là 1,2,3,11, 2, 3, 1. Vì sao chương trình in ra dãy 77 đỉnh?

Danh sách kề dạng vector cho mỗi đỉnh là cách quen tay, nhưng với đồ thị lớn nó có giá:

Hierholzer trên 100 nghìn đỉnh, một triệu cạnh.vector<ll> g[N]229 ms · gấp 3,8 lầnmảng liên kết head, nxt61 ms
Hierholzer dựng đồ thị rồi đi hết một triệu cạnh, hai cách lưu danh sách kề.

Lưu danh sách kề bằng mảng liên kết (ba mảng head, nxt, to) nhanh hơn vector khoảng 3,83{,}8 lần, vì không phải cấp phát bộ nhớ riêng cho từng đỉnh. Với đồ thị cỡ triệu cạnh mà thời gian sát giới hạn, nên cân nhắc đổi sang cách này.

IIIĐường đi Euler trên đồ thị có hướng

Trên đồ thị có hướng, mỗi cung chỉ nằm trong danh sách kề của đỉnh xuất phát, nên không cần đánh dấu cung đã dùng: con trỏ của mỗi đỉnh là đủ. Hierholzer giữ nguyên các bước còn lại. Nếu cần đường đi thay vì chu trình, ta bắt đầu từ đỉnh có bậc ra hơn bậc vào đúng 11; đường đi sẽ tự kết thúc ở đỉnh có bậc vào hơn bậc ra.

Hỏi nhanh

Một đồ thị có hướng liên thông khi bỏ hướng có các đỉnh với (bậc ra, bậc vào) lần lượt là: đỉnh 11: (2,1)(2, 1); đỉnh 22: (1,1)(1, 1); đỉnh 33: (1,2)(1, 2); đỉnh 44: (1,1)(1, 1).

Đồ thị này có gì?

Ví dụTừ Một Đến Đích Qua Mọi Cung

Đề. Cho đa đồ thị có hướng n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cung. In một đường đi qua mỗi cung đúng một lần, bắt đầu ở đỉnh 11 và kết thúc ở đỉnh nn, hoặc in IMPOSSIBLE.

Lời giải. Đường đi Euler từ 11 tới nn cần đỉnh 11 có bậc ra hơn bậc vào đúng 11, đỉnh nn có bậc vào hơn bậc ra đúng 11, và mọi đỉnh khác cân bằng. Nếu đúng như vậy, ta chạy Hierholzer từ đỉnh 11; kết quả thiếu cung nghĩa là có cung không tới được, cũng in IMPOSSIBLE. Ở đây g[u] lưu thẳng đỉnh đích của các cung đi ra từ uu.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, m, in[N], out[N], ptr[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);
        out[u]++;
        in[v]++;
    }
    bool ok = out[1] - in[1] == 1 && in[n] - out[n] == 1;
    for (ll u = 2; u < n; u++)
        if (in[u] != out[u]) ok = 0;
    if (!ok) {
        cout << "IMPOSSIBLE\n";
        return 0;
    }
    vector<ll> st = {1}, path;
    while (!st.empty()) {
        ll u = st.back();
        if (ptr[u] == (ll)g[u].size()) {
            path.push_back(u);
            st.pop_back();
        } else st.push_back(g[u][ptr[u]++]);
    }
    if ((ll)path.size() != m + 1) {
        cout << "IMPOSSIBLE\n";
        return 0;
    }
    reverse(path.begin(), path.end());
    for (ll v : path) cout << v << ' ';
    cout << '\n';
}
4 3
1 2
2 3
3 4

Độ phức tạp: O(n+m)O(n + m); với n=105n = 10^5 và m=2⋅105m = 2 \cdot 10^5 cần khoảng 4⋅1054 \cdot 10^5 phép tính.

Sử dụng kiến thức này cũng có thể giải bài: tìm đường đi Euler có dãy đỉnh nhỏ nhất theo thứ tự từ điển. Sắp xếp danh sách kề của mỗi đỉnh tăng dần, rồi chạy Hierholzer từ đỉnh có bậc ra hơn bậc vào (nếu có), hoặc từ đỉnh nhỏ nhất có cung ra; thuật toán luôn đi theo cung nhỏ nhất còn lại trước, và vòng nào quay về sớm được ghi vào sau, đúng vị trí của nó.

IVĐịnh hướng cạnh để các đỉnh cân bằng

Bài toán: cho đồ thị vô hướng, hãy định hướng mỗi cạnh (chọn một trong hai chiều) sao cho có nhiều đỉnh cân bằng nhất, tức là có bậc vào bằng bậc ra.

Một đỉnh có bậc lẻ không bao giờ cân bằng được, vì bậc vào cộng bậc ra của nó là số lẻ. Ta sẽ thấy mọi đỉnh bậc chẵn đều cân bằng được cùng lúc. Nếu mọi đỉnh có bậc chẵn, ta đi theo các chu trình Euler (mỗi thành phần liên thông một chu trình) và định hướng mỗi cạnh theo chiều đi qua nó: mỗi lần đi qua một đỉnh dùng một cạnh vào và một cạnh ra, nên mọi đỉnh đều cân bằng.

Khi có đỉnh bậc lẻ, ta thêm một đỉnh phụ và nối nó với mọi đỉnh bậc lẻ bằng một cạnh tạm. Số đỉnh bậc lẻ luôn chẵn, vì tổng các bậc bằng hai lần số cạnh, nên sau khi thêm, mọi đỉnh kể cả đỉnh phụ đều có bậc chẵn. Định hướng theo chu trình Euler như trên rồi bỏ các cạnh tạm: mỗi đỉnh bậc chẵn ban đầu không có cạnh tạm nào nên vẫn cân bằng.

Khi cài đặt, ta không cần ghi lại cả chu trình. Hierholzer đi theo mỗi cạnh đúng một lần; định hướng mỗi cạnh theo đúng chiều đi qua nó lúc đó là đủ, vì theo ô Chứng minh ở mục trước, mỗi vòng đi đều quay về chính chỗ nó bắt đầu.

Ví dụCải Cách Đường Một Chiều

Đề. Có nhiều bộ dữ liệu. Mỗi bộ là một đồ thị vô hướng n≤200n \le 200 đỉnh, không có khuyên hay cạnh song song, có thể không liên thông; tổng nn qua các bộ không quá 200200. Định hướng mọi cạnh để có nhiều đỉnh cân bằng nhất. In số đỉnh cân bằng, rồi in các cạnh đã định hướng theo thứ tự bất kỳ.

Lời giải. Theo lập luận trên, đáp án là số đỉnh có bậc chẵn. Đỉnh phụ là đỉnh 00; các cạnh tạm được đánh số sau mm cạnh thật, nên khi đi qua một cạnh, chỉ cần in nó nếu số hiệu không quá mm. Đồ thị có thể không liên thông, nên ta bắt đầu Hierholzer từ mọi đỉnh còn cạnh chưa dùng.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 205, M = 20505;
ll t, n, m, eu[M], ev[M], used[M], ptr[N];
vector<ll> g[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> t;
    while (t--) {
        cin >> n >> m;
        for (ll u = 0; u <= n; u++) g[u].clear(), ptr[u] = 0;
        for (ll i = 1; i <= m; i++) {
            cin >> eu[i] >> ev[i];
            g[eu[i]].push_back(i);
            g[ev[i]].push_back(i);
        }
        ll k = m, ans = 0;
        for (ll u = 1; u <= n; u++) {
            if (g[u].size() % 2 == 0) {
                ans++;
                continue;
            }
            k++;
            eu[k] = 0, ev[k] = u;
            g[0].push_back(k);
            g[u].push_back(k);
        }
        for (ll i = 1; i <= k; i++) used[i] = 0;
        cout << ans << '\n';
        for (ll s = 0; s <= n; s++) {
            vector<ll> st = {s};
            while (!st.empty()) {
                ll u = st.back();
                while (ptr[u] < (ll)g[u].size() && used[g[u][ptr[u]]]) ptr[u]++;
                if (ptr[u] == (ll)g[u].size()) st.pop_back();
                else {
                    ll e = g[u][ptr[u]], v = eu[e] == u ? ev[e] : eu[e];
                    used[e] = 1;
                    if (e <= m) cout << u << ' ' << v << '\n';
                    st.push_back(v);
                }
            }
        }
    }
}
1
4 3
1 2
2 3
3 4

Độ phức tạp: O(n+m)O(n + m) cho mỗi bộ dữ liệu; với n=200n = 200 và m≈2⋅104m \approx 2 \cdot 10^4 cạnh cần khoảng 10510^5 phép tính.

VXâu de Bruijn

Bài toán: với số nguyên nn, tìm xâu nhị phân ngắn nhất chứa mọi xâu nhị phân dài nn (mỗi xâu như vậy gọi là một mẫu). Chẳng hạn với n=2n = 2, xâu 0011000110 chứa đủ bốn mẫu 0000, 0101, 1111, 1010.

Một xâu dài LL có đúng L−n+1L - n + 1 đoạn con dài nn, mà cần đủ 2n2^n mẫu khác nhau, nên L≥2n+n−1L \ge 2^n + n - 1. Ta sẽ dựng được xâu đúng độ dài này bằng chu trình Euler.

Dựng đồ thị de Bruijn: mỗi đỉnh là một xâu nhị phân dài n−1n - 1, và mỗi mẫu nn bit là một cung, đi từ đỉnh là n−1n - 1 bit đầu của mẫu tới đỉnh là n−1n - 1 bit cuối của mẫu. Mỗi đỉnh có đúng 22 cung ra (thêm bit 00 hoặc bit 11 vào cuối) và đúng 22 cung vào, và từ một đỉnh bất kỳ đi tới được mọi đỉnh khác bằng cách lần lượt thêm các bit của đỉnh đích. Vì vậy đồ thị có chu trình Euler qua cả 2n2^n cung. Đi theo chu trình đó: viết n−1n - 1 bit của đỉnh xuất phát, rồi mỗi lần đi qua một cung thì viết thêm bit cuối của mẫu ứng với cung đó. Mỗi đoạn con dài nn của xâu thu được là một cung của chu trình, tức là một mẫu, và mỗi mẫu xuất hiện đúng một lần. Xâu dài đúng 2n+n−12^n + n - 1.

Ví dụPhủ Đủ Mọi Mẫu Nhị Phân

Đề. Với n≤15n \le 15, in một xâu nhị phân ngắn nhất chứa mọi xâu nhị phân dài nn.

Lời giải. Đỉnh là số nguyên có n−1n - 1 bit; cung từ uu theo bit bb đi tới (2u+b) mod 2n−1(2u + b) \bmod 2^{n-1}, tức là đẩy bit bb vào cuối rồi bỏ bit đầu. Mỗi đỉnh có đúng hai cung ra, nên con trỏ của mỗi đỉnh chỉ cần đếm 00, 11 hoặc 22 cung đã dùng, thử bit 00 trước. Mỗi phần tử của ngăn xếp nhớ thêm bit của cung đã đưa nó vào; khi lấy một phần tử ra, ta ghi bit đó (đỉnh xuất phát có bit −1-1, không ghi). Đảo dãy bit đã ghi, ta được bit cuối của các cung theo thứ tự đi, và đặt nó sau n−1n - 1 bit 00 của đỉnh xuất phát. Khi n=1n = 1, đồ thị có một đỉnh với hai khuyên, cách làm vẫn đúng.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, ptr[1 << 15];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    ll V = 1 << (n - 1), mask = V - 1;
    vector<pair<ll, ll> > st = {{0, -1}};
    string bits;
    while (!st.empty()) {
        ll u = st.back().first;
        if (ptr[u] < 2) {
            ll b = ptr[u]++;
            st.push_back({((u << 1) | b) & mask, b});
        } else {
            if (st.back().second >= 0) bits += char('0' + st.back().second);
            st.pop_back();
        }
    }
    reverse(bits.begin(), bits.end());
    cout << string(n - 1, '0') << bits << '\n';
}
3

Độ phức tạp: O(2n)O(2^n); với n=15n = 15 cần khoảng 6⋅1046 \cdot 10^4 bước, và xâu dài 2n+n−12^n + n - 1.

Sử dụng kiến thức này cũng có thể giải bài: khôi phục một dãy khi chỉ biết các cặp phần tử kề nhau của nó, đã bị xáo trộn. Mỗi cặp là một cạnh nối hai giá trị, nên dãy chính là một đường đi Euler trên đồ thị các giá trị (nén giá trị trước khi dựng đồ thị).

Bài nâng cao

Xem 2 bài nâng cao

Ví dụTô Màu Các Điểm

Đề. Có n≤2⋅105n \le 2 \cdot 10^5 điểm phân biệt với tọa độ nguyên 1≤xi,yi≤2⋅1051 \le x_i, y_i \le 2 \cdot 10^5. Tô mỗi điểm màu đỏ (r) hoặc xanh (b) sao cho trên mỗi đường thẳng đứng x=hx = h và mỗi đường nằm ngang y=hy = h, số điểm đỏ và số điểm xanh lệch nhau không quá 11.

Lời giải. Ta đổi cách nhìn: cho mỗi hoành độ và mỗi tung độ là một đỉnh, còn mỗi điểm là một cạnh nối hoành độ của nó với tung độ của nó. Một đường thẳng đứng x=hx = h khi đó là đỉnh hh bên phía hoành độ, và các điểm trên đường đó là các cạnh nối với đỉnh này.

Bây giờ định hướng mỗi cạnh: đi từ hoành độ sang tung độ thì tô đỏ, đi từ tung độ sang hoành độ thì tô xanh. Ở một đỉnh hoành độ, số điểm đỏ là bậc ra và số điểm xanh là bậc vào; ở một đỉnh tung độ thì ngược lại. Như vậy bài toán trở thành: định hướng các cạnh sao cho ở mọi đỉnh, bậc vào và bậc ra lệch nhau không quá 11. Đây đúng là bài của mục Định hướng cạnh để các đỉnh cân bằng: thêm đỉnh phụ nối với mọi đỉnh bậc lẻ, định hướng theo chu trình Euler, rồi bỏ cạnh tạm. Mỗi đỉnh có nhiều nhất một cạnh tạm, nên sau khi bỏ, bậc vào và bậc ra lệch nhau nhiều nhất 11.

Trong code, hoành độ xx là đỉnh xx, tung độ yy là đỉnh 2⋅105+y2 \cdot 10^5 + y, và đỉnh phụ là đỉnh 00.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll C = 2e5, N = 2 * C + 5, M = 6e5 + 5;
ll n, eu[M], ev[M], used[M], ptr[N];
char ans[M];
vector<ll> g[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++) {
        ll x, y;
        cin >> x >> y;
        eu[i] = x, ev[i] = C + y;
        g[x].push_back(i);
        g[C + y].push_back(i);
    }
    ll k = n;
    for (ll u = 1; u <= 2 * C; u++)
        if (g[u].size() % 2) {
            k++;
            eu[k] = 0, ev[k] = u;
            g[0].push_back(k);
            g[u].push_back(k);
        }
    vector<ll> st;
    for (ll s = 0; s <= 2 * C; s++) {
        st.push_back(s);
        while (!st.empty()) {
            ll u = st.back();
            while (ptr[u] < (ll)g[u].size() && used[g[u][ptr[u]]]) ptr[u]++;
            if (ptr[u] == (ll)g[u].size()) st.pop_back();
            else {
                ll e = g[u][ptr[u]], v = eu[e] == u ? ev[e] : eu[e];
                used[e] = 1;
                if (e <= n) ans[e] = u == eu[e] ? 'r' : 'b';
                st.push_back(v);
            }
        }
    }
    for (ll i = 1; i <= n; i++) cout << ans[i];
    cout << '\n';
}
4
1 1
1 2
2 1
2 2

Độ phức tạp: O(n+C)O(n + C) với C=2⋅105C = 2 \cdot 10^5 là giới hạn tọa độ; cần khoảng 10610^6 phép tính.

Ví dụMạng Cáp Cân Bằng

Đề. Cho đồ thị vô hướng liên thông n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cạnh (có thể có khuyên và cạnh song song). Thêm ít cạnh nhất, rồi định hướng mọi cạnh sao cho ở mỗi đỉnh, bậc vào là số chẵn và bậc ra cũng là số chẵn. In số cạnh sau khi thêm và các cạnh đã định hướng.

Lời giải. Bậc của mỗi đỉnh bằng bậc vào cộng bậc ra, nên phải là số chẵn. Ngoài ra, mỗi cạnh góp đúng 11 vào tổng các bậc ra, nên tổng các bậc ra bằng số cạnh; mỗi bậc ra đều chẵn nên số cạnh cũng phải chẵn. Vì vậy ta cần: ghép các đỉnh bậc lẻ thành từng cặp và nối mỗi cặp bằng một cạnh mới; nếu sau đó số cạnh lẻ, thêm một khuyên ở đỉnh 11. Mỗi cạnh mới chữa được nhiều nhất hai đỉnh lẻ, và khuyên chỉ cần khi số cạnh lẻ, nên số cạnh thêm là ít nhất.

Phần bất ngờ là cách định hướng. Lấy một chu trình Euler v0,v1,…,vp=v0v_0, v_1, \dots, v_p = v_0 của đồ thị mới, với pp chẵn, rồi định hướng các cạnh xen kẽ: cạnh thứ 11 đi xuôi theo chu trình, cạnh thứ 22 đi ngược, cạnh thứ 33 đi xuôi, và cứ thế. Khi đó hai cạnh liên tiếp tại mỗi lần đi qua một đỉnh hoặc cùng đi vào đỉnh đó, hoặc cùng đi ra khỏi nó (xem ô Chứng minh), nên mỗi lần đi qua góp 22 vào bậc vào hoặc 22 vào bậc ra.

Chứng minh

Cạnh thứ jj nối vj−1v_{j-1} với vjv_j. Xét đỉnh vjv_j với 1≤j<p1 \le j < p, nằm giữa cạnh thứ jj và cạnh thứ j+1j + 1. Nếu jj lẻ, cạnh thứ jj đi xuôi, từ vj−1v_{j-1} vào vjv_j, còn cạnh thứ j+1j + 1 đi ngược, từ vj+1v_{j+1} vào vjv_j: cả hai cùng đi vào vjv_j. Nếu jj chẵn thì ngược lại, cả hai cùng đi ra. Tại đỉnh v0=vpv_0 = v_p, cạnh thứ 11 đi xuôi, ra khỏi v0v_0, và cạnh thứ pp với pp chẵn đi ngược, từ vpv_p sang vp−1v_{p-1}, cũng ra khỏi v0v_0. Vậy các cạnh của mỗi đỉnh chia thành từng cặp cùng chiều, nên bậc vào và bậc ra đều chẵn.

Hierholzer cho dãy đỉnh của chu trình theo thứ tự ngược; đảo lại, rồi in hai đỉnh liên tiếp của mỗi cạnh theo thứ tự xuôi hoặc ngược, xen kẽ nhau. Đồ thị liên thông nên một lần chạy từ đỉnh 11 là đi hết mọi cạnh.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5, M = 5e5 + 5;
ll n, m, eu[M], ev[M], used[M], ptr[N];
vector<ll> g[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    ll k = 0;
    for (ll i = 1; i <= m; i++) {
        k++;
        cin >> eu[k] >> ev[k];
        g[eu[k]].push_back(k);
        g[ev[k]].push_back(k);
    }
    ll last = 0;
    for (ll u = 1; u <= n; u++)
        if (g[u].size() % 2) {
            if (!last) last = u;
            else {
                k++;
                eu[k] = last, ev[k] = u;
                g[last].push_back(k);
                g[u].push_back(k);
                last = 0;
            }
        }
    if (k % 2) {
        k++;
        eu[k] = ev[k] = 1;
        g[1].push_back(k);
        g[1].push_back(k);
    }
    vector<ll> st = {1}, path;
    while (!st.empty()) {
        ll u = st.back();
        while (ptr[u] < (ll)g[u].size() && used[g[u][ptr[u]]]) ptr[u]++;
        if (ptr[u] == (ll)g[u].size()) {
            path.push_back(u);
            st.pop_back();
        } else {
            ll e = g[u][ptr[u]];
            used[e] = 1;
            st.push_back(eu[e] == u ? ev[e] : eu[e]);
        }
    }
    reverse(path.begin(), path.end());
    cout << k << '\n';
    for (ll j = 1; j <= k; j++) {
        if (j % 2) cout << path[j - 1] << ' ' << path[j] << '\n';
        else cout << path[j] << ' ' << path[j - 1] << '\n';
    }
}
3 2
1 2
2 3

Độ phức tạp: O(n+m)O(n + m); với n=105n = 10^5 và m=2⋅105m = 2 \cdot 10^5 cần khoảng 10610^6 phép tính.

Khi đi thi

  • Gặp “mỗi cạnh đúng một lần”, “vẽ một nét”, “xâu ngắn nhất chứa mọi mẫu”, hay “ghép các mảnh nối đuôi nhau” thì nghĩ tới Euler. Kiểm bậc trước, rồi kiểm kết quả có đủ m+1m + 1 đỉnh thay cho bước kiểm liên thông.
  • Với đồ thị vô hướng, danh sách kề lưu số hiệu cạnh và đánh dấu đã dùng theo số hiệu cạnh. Dùng ngăn xếp tự quản lý thay cho đệ quy, và nhớ đảo kết quả.
  • Số nét bút ít nhất để vẽ hết một thành phần liên thông có cạnh là max⁡(1,k/2)\max(1, k / 2), với kk là số đỉnh bậc lẻ. Bài cần cân bằng bậc thì nghĩ tới đỉnh phụ nối với mọi đỉnh bậc lẻ.

Tóm tắt

  • Đồ thị vô hướng có chu trình Euler khi mọi bậc chẵn, có đường đi Euler khi có đúng hai đỉnh bậc lẻ; đồ thị có hướng cần bậc vào bằng bậc ra, hoặc lệch đúng 11 ở hai đầu đường. Các cạnh phải liên thông.
  • Hierholzer: ngăn xếp, đi theo cạnh chưa dùng, ghi đỉnh khi lấy ra, đảo kết quả; O(n+m)O(n + m).
  • Định hướng theo chu trình Euler cho mọi đỉnh cân bằng; đỉnh phụ nối với các đỉnh bậc lẻ giúp áp dụng cho mọi đồ thị.
  • Đồ thị de Bruijn: mỗi mẫu là một cung giữa hai xâu ngắn hơn nó một ký tự; chu trình Euler cho xâu ngắn nhất chứa mọi mẫu.

Bài tập

Bài tập ngoài

Khi nào có chu trình

ITrails and GladesCodeforces

Định hướng cạnh

IVFair ShareCodeforces

Đọc thêm