Cho một cây gồm đỉnh. Chọn nhiều đỉnh nhất có thể sao cho không có hai đỉnh được chọn nào nối với nhau bằng một cạnh, trong .
Ta treo cây xuống từ một đỉnh làm gốc; khi đó mỗi đỉnh có một phần cây nằm dưới nó, gọi là cây con. Với mỗi đỉnh , câu trả lời cho cả cây con của chỉ cần hai con số: số đỉnh chọn được nhiều nhất khi chọn , và khi không chọn . Hai con số này tính được từ hai con số của các con của , nên chỉ cần đi một lượt từ lá lên gốc. Cách nghĩ này, đặt trạng thái theo cây con rồi tính từ con lên cha, gọi là quy hoạch động trên cây (Tree DP), và là nền cho rất nhiều bài đếm, bài tối ưu trên cây.
ITính từ con lên cha
Cây có đỉnh và đúng cạnh, giữa hai đỉnh bất kỳ có đúng một đường đi. Sau khi chọn gốc, ta đặt tên các quan hệ: đỉnh ngay phía trên một đỉnh là cha của , các đỉnh ngay phía dưới là con, và cây con của gồm cùng mọi đỉnh nằm dưới nó.
Ví dụ đơn giản nhất của quy hoạch động trên cây là kích thước cây con , tức số đỉnh của cây con của . Nó bằng (chính ) cộng kích thước cây con của từng con của . Cái cần cẩn thận duy nhất là thứ tự: khi tính cho , mọi con của phải đã xong. Hàm đệ quy làm được điều này một cách tự nhiên, nhưng cây có thể là một đường dài đỉnh, và đệ quy sâu chừng ấy tầng có thể làm tràn ngăn xếp. Ta tránh bằng cách duyệt BFS từ gốc, ghi lại thứ tự gặp các đỉnh vào mảng ord và cha của từng đỉnh vào par. Trong thứ tự này, cha luôn đứng trước con của nó, nên nếu đi ngược từ cuối mảng về đầu thì con luôn được xử lý trước cha.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, par[N], ord[N], sz[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i < n; i++) {
ll u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
ll m = 0;
ord[++m] = 1;
for (ll i = 1; i <= m; i++) {
ll u = ord[i];
for (ll v : g[u])
if (v != par[u]) par[v] = u, ord[++m] = v;
}
for (ll i = 1; i <= n; i++) sz[i] = 1;
for (ll i = n; i >= 2; i--) sz[par[ord[i]]] += sz[ord[i]];
for (ll i = 1; i <= n; i++) cout << sz[i] << ' ';
cout << '\n';
}
5
1 2
1 3
2 4
2 5
5 3 1 1 1
Độ phức tạp: , với cần khoảng phép tính.
Hỏi nhanh
Một bạn viết nhầm vòng cuối của chương trình trên thành for (ll i = 2; i <= n; i++) sz[par[ord[i]]] += sz[ord[i]];, tức duyệt xuôi thay vì ngược. Cây là đường với gốc . Chương trình in ra bằng bao nhiêu?
Thứ tự BFS là . Vòng xuôi cộng vào trước, rồi mới cộng vào ; lúc đó đỉnh 1 đã nhận xong nên không biết đỉnh 3. Đi ngược thì vào trước, rồi mới vào , cho .
Ví dụChọn Đại Diện Sửa Đường
Đề. Có khu vực nối bởi con đường hai chiều thành một cây, trụ sở ở khu vực . Mỗi đường thuộc loại (bình thường) hoặc loại (cần sửa). Chọn một khu vực nghĩa là cử một người từ đi theo đường duy nhất về khu vực và sửa mọi đường loại trên đường đi. Hãy chọn ít khu vực nhất để mọi đường loại đều được sửa, rồi in số khu vực và một danh sách.
Lời giải. Treo cây từ khu vực và xét một đường loại nối đỉnh với cha của nó. Người sửa đường này phải xuất phát từ cây con của . Nếu cây con của đã có một khu vực được chọn, người đó đi về gốc chắc chắn qua đường này, ta không cần làm gì thêm. Nếu chưa có, ta buộc phải chọn thêm một khu vực trong cây con của , và chọn ngay là không kém: người ở sửa đúng đường này, còn các đường phía trên mà một người ở sâu hơn sửa được thì người ở cũng sửa được. Mỗi lần chọn như vậy là bắt buộc nên số lượng là ít nhất.
Vậy trạng thái của chỉ là một cờ: cây con của đã có khu vực được chọn hay chưa. Ta duyệt ngược thứ tự BFS; xử lý xong thì báo lên cha của nếu cây con của đã có người. Đề cho in danh sách theo thứ tự bất kỳ, nhưng ta sắp tăng dần cho gọn.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, par[N], typ[N], ord[N], has[N];
vector<pair<ll, ll> > g[N];
vector<ll> ans;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i < n; i++) {
ll u, v, t;
cin >> u >> v >> t;
g[u].push_back(make_pair(v, t));
g[v].push_back(make_pair(u, t));
}
ll m = 0;
ord[++m] = 1;
for (ll i = 1; i <= m; i++) {
ll u = ord[i];
for (ll j = 0; j < (ll)g[u].size(); j++) {
ll v = g[u][j].first;
if (v == par[u]) continue;
par[v] = u;
typ[v] = g[u][j].second;
ord[++m] = v;
}
}
for (ll i = n; i >= 2; i--) {
ll u = ord[i];
if (typ[u] == 2 && !has[u]) {
has[u] = 1;
ans.push_back(u);
}
if (has[u]) has[par[u]] = 1;
}
sort(ans.begin(), ans.end());
cout << ans.size() << '\n';
for (ll i = 0; i < (ll)ans.size(); i++) cout << ans[i] << ' ';
cout << '\n';
}
7
1 2 2
2 3 1
2 4 2
1 5 1
5 6 2
6 7 1
2
4 6
Độ phức tạp: cho BFS và một lượt ngược, với cần khoảng phép tính, cộng cho bước sắp xếp danh sách.
IIChọn hoặc không chọn đỉnh gốc của cây con
Ta quay lại bài toán đầu: chọn nhiều đỉnh nhất sao cho không có hai đỉnh được chọn kề nhau. Cách nghĩ vét cạn là thử mọi tập con, phương án. Ta có thể thấy cây con của chỉ nối với phần còn lại của cây qua một cạnh duy nhất, cạnh từ lên cha. Vì vậy, muốn biết một cách chọn trong cây con của có ghép được với cách chọn bên ngoài hay không, chỉ cần biết có được chọn hay không. Với mỗi đỉnh ta giữ hai con số:
- là số đỉnh chọn được nhiều nhất trong cây con của khi được chọn;
- là số đó khi không được chọn.
Nếu chọn thì không con nào của được chọn, nên bằng cộng tổng của các con. Nếu không chọn thì mỗi con tự do chọn hay không, ta lấy cách tốt hơn cho từng con: bằng tổng qua các con . Đáp án là . Khi mỗi đỉnh có thêm giá trị thì thay số bằng giá trị của đỉnh đó.
Chương trình dưới đây tính cho cây trong hình. Mỗi đỉnh lấy dữ liệu từ các con của nó, đi theo thứ tự ngược của BFS.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, par[N], ord[N], f0[N], f1[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i < n; i++) {
ll u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
ll m = 0;
ord[++m] = 1;
for (ll i = 1; i <= m; i++) {
ll u = ord[i];
for (ll v : g[u])
if (v != par[u]) par[v] = u, ord[++m] = v;
}
for (ll i = n; i >= 1; i--) {
ll u = ord[i];
f1[u] = 1;
for (ll v : g[u]) {
if (v == par[u]) continue;
f0[u] += max(f0[v], f1[v]);
f1[u] += f0[v];
}
}
cout << max(f0[1], f1[1]) << '\n';
}
7
1 2
1 3
2 4
2 5
3 6
5 7
4
Độ phức tạp: , vì mỗi cạnh được xét hai lần, với cần khoảng phép tính.
Sử dụng kiến thức này cũng có thể giải bài phủ đỉnh nhỏ nhất (chọn ít đỉnh nhất để mỗi cạnh có ít nhất một đầu được chọn): các đỉnh không được chọn khi đó không có hai đỉnh nào kề nhau, nên đáp án bằng trừ đi kích thước tập độc lập lớn nhất.
Ví dụGhép Cạnh Trên Cây
Đề. Cho một cây đỉnh. Chọn nhiều cạnh nhất có thể sao cho không có hai cạnh được chọn cùng đi qua một đỉnh (một cặp ghép của cây). In số cạnh đó.
Lời giải. Cũng hai trạng thái cho mỗi đỉnh , nhưng ý nghĩa khác: là số cạnh nhiều nhất trong cây con của khi không ghép với con nào, và là số đó khi ghép với đúng một con. Đặt là tổng của qua các con của . Nếu không ghép với con nào thì mỗi con tự do chọn, nên . Nếu ghép với con thì cạnh được thêm vào, bắt buộc ở trạng thái chưa ghép với con của nó, còn các con khác tự do:
Đỉnh lá không có con nên không thể ghép xuống; ta gán cho nó để trạng thái này không bao giờ được chọn.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, par[N], ord[N], d0[N], d1[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i < n; i++) {
ll u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
ll m = 0;
ord[++m] = 1;
for (ll i = 1; i <= m; i++) {
ll u = ord[i];
for (ll v : g[u])
if (v != par[u]) par[v] = u, ord[++m] = v;
}
for (ll i = n; i >= 1; i--) {
ll u = ord[i], b = 0;
for (ll v : g[u])
if (v != par[u]) b += max(d0[v], d1[v]);
d0[u] = b;
d1[u] = -1e18;
for (ll v : g[u])
if (v != par[u]) d1[u] = max(d1[u], b - max(d0[v], d1[v]) + d0[v] + 1);
}
cout << max(d0[1], d1[1]) << '\n';
}
5
1 2
2 3
2 4
4 5
2
Độ phức tạp: , với cần khoảng phép tính.
Hỏi nhanh
Đỉnh có hai con. Con thứ nhất có , con thứ hai có . Tính cho bài ghép cặp lớn nhất.
Ở đây , nên . Ghép với con thứ nhất cho , ghép với con thứ hai cho ; lấy lớn nhất được .
Đáp án đảo hai trạng thái. Đáp án quên rằng ghép với con thứ hai tốt hơn, và tính bằng cách cộng của hai con mà quên cho phép con tự chọn trạng thái tốt hơn.
IIIĐường kính bằng quy hoạch động
Đường kính của cây là đường đi dài nhất giữa hai đỉnh. Cho một cây mà cạnh có độ dài ; ta muốn tìm độ dài của đường kính. Hai lần BFS ở bài 3.1 làm được khi mọi cạnh có độ dài ; ở đây ta tìm bằng quy hoạch động để dùng được cho độ dài tùy ý không âm.
Mỗi đường đi trên cây treo gốc có một đỉnh cao nhất, là đỉnh gần gốc nhất của nó, và đường đi gồm hai nhánh đi xuống từ đỉnh đó, qua hai con khác nhau (hoặc chỉ một nhánh). Gọi là độ dài nhánh đi xuống dài nhất từ , nếu là lá. Với mỗi con của , nhánh đi qua có độ dài . Hai nhánh tốt nhất qua hai con khác nhau cho đường dài nhất có đỉnh cao nhất là , và đường kính là giá trị lớn nhất của đại lượng này qua mọi .
Để lấy hai nhánh tốt nhất, ta giữ hai biến khi lướt qua các con: nhánh tốt nhất và nhánh tốt nhì. Khi có một con thì và đường kính qua chỉ là một nhánh.
Hỏi nhanh
Đỉnh có ba con, các nhánh đi qua chúng dài , và . Đường dài nhất mà đỉnh cao nhất là dài bao nhiêu?
Đường đi chỉ có hai nhánh nên không thể cộng cả ba. Hai nhánh dài nhất là và , cho . Một đường chỉ đi xuống một nhánh dài vẫn ngắn hơn.
Ví dụĐồng Nhất Màu Trên Cây
Đề. Cho cây đỉnh, mỗi đỉnh có màu hoặc . Một thao tác chọn một đỉnh và đảo màu toàn bộ thành phần đơn sắc đang chứa , tức tập các đỉnh nối với bằng đường đi chỉ gồm đỉnh cùng màu với . Tìm số thao tác ít nhất để mọi đỉnh cùng màu.
Lời giải. Gán cho mỗi cạnh độ dài nếu hai đầu khác màu và độ dài nếu cùng màu. Khi đó độ dài của một đường đi là số lần màu đổi dọc đường. Gọi là đường kính theo độ dài này. Ta sẽ chứng minh đáp án là , tức với phép chia nguyên.
Chứng minh
Gộp mỗi thành phần đơn sắc thành một đỉnh, ta được một cây mà hai đỉnh kề luôn khác màu; đường kính của cây này, tính theo số cạnh, đúng bằng . Một thao tác đảo một đỉnh của cây gộp làm nó nhập với tất cả các đỉnh kề, nên đường kính giảm không quá . Cần đưa đường kính về , nên cần ít nhất thao tác. Ngược lại, cứ đảo đỉnh ở tâm của cây gộp thì bán kính giảm đúng sau mỗi thao tác, và bán kính của cây gộp là , nên đủ thao tác.
Còn lại là tính bằng quy hoạch động ở trên. Với trong ví dụ mẫu (màu trên đường , màu đổi ba lần), đáp án là .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, c[N], par[N], ord[N], h[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> c[i];
for (ll i = 1; i < n; i++) {
ll u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
ll m = 0;
ord[++m] = 1;
for (ll i = 1; i <= m; i++) {
ll u = ord[i];
for (ll v : g[u])
if (v != par[u]) par[v] = u, ord[++m] = v;
}
ll d = 0;
for (ll i = n; i >= 1; i--) {
ll u = ord[i], m1 = 0, m2 = 0;
for (ll v : g[u]) {
if (v == par[u]) continue;
ll x = h[v] + (c[u] != c[v]);
if (x > m1) m2 = m1, m1 = x;
else m2 = max(m2, x);
}
h[u] = m1;
d = max(d, m1 + m2);
}
cout << (d + 1) / 2 << '\n';
}
6
0 0 1 1 0 1
1 2
2 3
3 4
4 5
5 6
2
Độ phức tạp: , với cần khoảng phép tính.
IVHai cây con có giống nhau không
Hai cây con có gốc được gọi là đẳng cấu nếu ta có thể đổi tên các đỉnh của cây này thành đỉnh của cây kia sao cho gốc ứng với gốc và quan hệ cha con được giữ nguyên; nói cách khác chúng giống hệt nhau khi bỏ qua số hiệu đỉnh và thứ tự các con. Muốn so sánh nhiều cây con, ta gán cho mỗi cây con một mã: hai cây con có cùng mã khi và chỉ khi chúng đẳng cấu.
Cây con của hoàn toàn được xác định bởi các cây con của các con của , và thứ tự các con không quan trọng. Vậy mã của được xác định bởi đa tập mã của các con (tập có thể lặp phần tử). Ta lấy danh sách mã của các con, sắp xếp tăng dần để thứ tự không ảnh hưởng, rồi tra trong một bảng map từ danh sách sang mã: danh sách đã có thì dùng lại mã cũ, chưa có thì cấp mã mới. Lá có danh sách rỗng nên mọi lá cùng một mã.
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProVí dụPhân Lớp Các Cây Con Có Gốc
Đề. Cho cây có gốc gồm đỉnh, đỉnh có cha . Hãy chia các cây con có gốc tại từng đỉnh thành các lớp đẳng cấu: in số lớp , rồi số với sao cho khi và chỉ khi hai cây con tại và đẳng cấu.
Lời giải. Vì cha có chỉ số nhỏ hơn con, đi từ xuống thì mọi con của đã xong. Mỗi đỉnh xong thì mã của nó được đẩy vào danh sách của cha. Tổng độ dài các danh sách là , bảng map có khoá là danh sách nên mỗi lần tra tốn thời gian tỉ lệ với độ dài danh sách nhân .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 5e5 + 5;
ll n, p[N], c[N];
vector<ll> ch[N];
map<vector<ll>, ll> id;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i < n; i++) cin >> p[i];
for (ll v = n - 1; v >= 0; v--) {
sort(ch[v].begin(), ch[v].end());
if (!id.count(ch[v])) {
ll k = id.size();
id[ch[v]] = k;
}
c[v] = id[ch[v]];
if (v > 0) ch[p[v]].push_back(c[v]);
}
cout << id.size() << '\n';
for (ll v = 0; v < n; v++) cout << c[v] << ' ';
cout << '\n';
}
7
0 0 1 1 2 2
3
2 1 1 0 0 0 0
Độ phức tạp: , với cần khoảng phép tính.
Bài nâng cao
Xem 1 bài nâng cao
Ví dụKhung Phân Nhánh Bậc Bốn
Đề. Cho cây gồm đỉnh. Chọn một số đỉnh và cạnh của để được cây con liên thông mà mọi đỉnh của có bậc đúng hoặc đúng (tính theo cạnh của ) và có ít nhất một đỉnh bậc . Tìm số đỉnh lớn nhất của , hoặc in nếu không có.
Lời giải. Treo cây từ đỉnh . Trong , đỉnh cao nhất không có cha trong , nên bậc của nó chính là số con của nó trong . Với mỗi đỉnh ta tính , số đỉnh lớn nhất của một “mảnh” gốc trong đó đã có cạnh nối lên cha: hoặc là lá của (mảnh chỉ có , kích thước ), hoặc có bậc , nghĩa là ba con của cùng với mảnh của chúng. Gọi $q[u] = 1 + $ tổng ba giá trị lớn nhất của các con của (cần có ít nhất ba con), ta có . Đỉnh cao nhất của rơi vào một trong hai trường hợp: nó có bậc , cho tổng bốn giá trị lớn nhất của các con; hoặc nó là lá của với một con có bậc , cho . Đáp án là giá trị lớn nhất qua mọi trong các trường hợp có xảy ra; nếu không trường hợp nào xảy ra thì in . Việc lấy các giá trị lớn nhất ở mỗi đỉnh là sắp xếp danh sách của các con giảm dần.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, par[N], ord[N], g[N], q[N];
vector<ll> adj[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i < n; i++) {
ll u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
ll m = 0;
ord[++m] = 1;
for (ll i = 1; i <= m; i++) {
ll u = ord[i];
for (ll v : adj[u])
if (v != par[u]) par[v] = u, ord[++m] = v;
}
ll ans = -1;
for (ll i = n; i >= 1; i--) {
ll u = ord[i];
vector<ll> val;
for (ll v : adj[u])
if (v != par[u]) val.push_back(g[v]);
sort(val.begin(), val.end(), greater<ll>());
q[u] = -1e18;
if (val.size() >= 3) q[u] = 1 + val[0] + val[1] + val[2];
g[u] = max(1LL, q[u]);
if (val.size() >= 4) ans = max(ans, 1 + val[0] + val[1] + val[2] + val[3]);
for (ll v : adj[u])
if (v != par[u] && q[v] > 0) ans = max(ans, 1 + q[v]);
}
cout << ans << '\n';
}
10
1 2
1 3
1 4
1 5
5 6
5 7
5 8
5 9
9 10
8
Độ phức tạp: cho các lần sắp xếp danh sách con, với cần khoảng phép tính.
Khi đi thi
- Viết rõ trạng thái là “đáp án cho cây con của ” cộng thêm những cờ nào về chính (được chọn hay không, đã ghép hay chưa), rồi mới viết công thức.
- Cây có thể là một đường dài đỉnh thì đệ quy có thể tràn ngăn xếp; dùng thứ tự BFS và đi ngược.
- Khi đáp án ở một đỉnh ghép từ nhiều con (đường kính, ghép cặp), giữ một hai giá trị tốt nhất của các con thay vì thử mọi cặp con.
Tóm tắt
- Quy hoạch động trên cây tính đáp án của một đỉnh từ đáp án của các con; thứ tự duy nhất cần giữ là con xong trước cha, làm được bằng cách đi ngược thứ tự BFS.
- Khi mỗi đỉnh có hai lựa chọn, ta giữ hai con số, mỗi lựa chọn một số; tập độc lập và cặp ghép lớn nhất đều theo khuôn này.
- Đường kính cho một đường có hai nhánh đi xuống từ đỉnh cao nhất; giữ nhánh tốt nhất và nhánh tốt nhì khi lướt qua các con.
- Phủ đỉnh nhỏ nhất bằng trừ kích thước tập độc lập lớn nhất.
- Hai cây con đẳng cấu khi đa tập mã các con trùng nhau; sắp xếp danh sách mã rồi tra bảng
mapđể gán mã.
Bài tập
Đọc thêm
- Quy hoạch động trên câyVNOI Wiki
- DP on Trees - IntroductionUSACO Guide
- 树形 DPOI Wiki