Đề hỏi đối tượng hợp lệ thứ theo thứ tự từ điển, hoặc thứ hạng của một đối tượng cho trước, mà số đối tượng lớn tới : không thể liệt kê. Cách làm chung là dựng đối tượng từ trái sang phải. Ở mỗi vị trí, các đối tượng chia thành từng khối theo ký tự đặt tại đó, khối của ký tự nhỏ đứng trước. Biết kích thước mỗi khối (bằng công thức tổ hợp hoặc quy hoạch động đếm phần đuôi), ta biết thứ rơi vào khối nào, trừ đi các khối đứng trước, rồi đi tiếp. Xếp hạng làm ngược lại: cộng kích thước các khối đứng trước.
Chia khối và giải mã thứ K
Các chuỗi hợp lệ sắp theo từ điển thì những chuỗi cùng ký tự đầu đứng liền nhau thành một khối, khối của ký tự nhỏ đứng trước. Gọi là số chuỗi bắt đầu bằng , tức số cách hoàn thiện phần còn lại sau khi đặt . Duyệt các ký tự từ nhỏ đến lớn: nếu thì chuỗi cần tìm nằm trong khối của , đặt và tìm chuỗi thứ trong khối đó; ngược lại và thử ký tự kế tiếp. Lặp lại cho từng vị trí. Mọi khó khăn dồn vào việc tính .
Ví dụChuỗi aab, aba, baa
Đề. Có chữ a và chữ b với . In chuỗi thứ theo thứ tự từ điển trong mọi chuỗi dùng đúng số chữ đó.
Lời giải. Còn chữ a và chữ b thì số chuỗi bắt đầu bằng a là : sắp chữ b vào vị trí còn lại. không quá số đó thì đặt a, không thì trừ đi và đặt b. vẫn vừa ll; dựng bảng tổ hợp bằng tam giác Pascal để khỏi chia.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll C[65][65];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
for (ll i = 0; i < 65; i++) {
C[i][0] = 1;
for (ll j = 1; j <= i; j++) C[i][j] = C[i - 1][j - 1] + C[i - 1][j];
}
ll a, b, k;
cin >> a >> b >> k;
string res;
while (a + b > 0) {
ll c = a ? C[a + b - 1][b] : 0;
if (k <= c) {
res += 'a';
a--;
} else {
k -= c;
res += 'b';
b--;
}
}
cout << res << '\n';
}
2 2 4
baab
Xếp hạng: cộng các khối đứng trước
Thứ hạng của một đối tượng là 1 cộng số đối tượng đứng trước nó. Một đối tượng đứng trước khi chúng giống nhau tới vị trí rồi . Vậy duyệt từng vị trí , với mỗi ký tự đặt được ở đó (cùng tiền tố của ), cộng số cách hoàn thiện. Đây đúng là quá trình giải mã chạy ngược, và hai hàm luôn kiểm được lẫn nhau: giải mã thứ hạng vừa tính phải ra lại đối tượng ban đầu.
Với hoán vị của : sau khi đã dùng các số ở vị trí trước, mỗi số chưa dùng nhỏ hơn mở một khối gồm hoán vị. Đó là khai triển Cantor: thứ hạng là với là số số chưa dùng nhỏ hơn .
Hỏi nhanh
Hoán vị của có thứ hạng bao nhiêu (đánh số từ 1)?
Ở vị trí 1, hai số chưa dùng nhỏ hơn 3 là 1 và 2, mỗi số mở một khối hoán vị: 4 hoán vị đứng trước. Ở vị trí 2, không số chưa dùng nào nhỏ hơn 1. Thứ hạng là .
Ví dụSố hiệu hoán vị
Đề. Một hoán vị của với (độ dài lấy từ dòng đầu) và một thứ hạng . In thứ hạng của hoán vị đã cho và hoán vị ở thứ hạng .
Lời giải. Xếp hạng bằng khai triển Cantor: ở vị trí , mỗi số chưa dùng nhỏ hơn mở ra một khối hoán vị đứng trước hoán vị đã cho. Giải mã làm ngược lại với hạng tính từ 0: thương cho là chỉ số của số cần đặt trong các số chưa dùng, phần dư đi tiếp cho các vị trí sau.
Bước 1: đọc hoán vị không biết trước độ dài. Đề không cho riêng: độ dài là số số trên dòng đầu, nên đọc cả dòng bằng getline rồi tách số.
string line;
getline(cin, line);
stringstream ss(line);
ll x;
while (ss >> x) a[++n] = x;
Bước 2: xếp hạng. f[i] , tới . c đếm số chưa dùng nhỏ hơn a[i].
ll rk = 1;
for (ll i = 1; i <= n; i++) {
ll c = 0;
for (ll v = 1; v < a[i]; v++) c += !used[v];
rk += c * f[n - i];
used[a[i]] = 1;
}
Bước 3: giải mã. Làm việc với . c-- == 0 tìm số chưa dùng thứ c (đếm từ 0).
p--;
for (ll i = 1; i <= n; i++) {
ll c = p / f[n - i];
p %= f[n - i];
for (ll v = 1; v <= n; v++)
if (!used[v] && c-- == 0) {
used[v] = 1;
cout << v << ' ';
break;
}
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, a[15], f[15], used[15];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
string line;
getline(cin, line);
stringstream ss(line);
ll x;
while (ss >> x) a[++n] = x;
ll p;
cin >> p;
f[0] = 1;
for (ll i = 1; i <= n; i++) f[i] = f[i - 1] * i;
ll rk = 1;
for (ll i = 1; i <= n; i++) {
ll c = 0;
for (ll v = 1; v < a[i]; v++) c += !used[v];
rk += c * f[n - i];
used[a[i]] = 1;
}
cout << rk << '\n';
for (ll v = 1; v <= n; v++) used[v] = 0;
p--;
for (ll i = 1; i <= n; i++) {
ll c = p / f[n - i];
p %= f[n - i];
for (ll v = 1; v <= n; v++)
if (!used[v] && c-- == 0) {
used[v] = 1;
cout << v << ' ';
break;
}
}
cout << '\n';
}
3 1 2
6
5
3 2 1
.
Đếm đuôi bằng quy hoạch động
Khi ràng buộc phức tạp hơn “dùng mỗi thứ một số lần”, số cách hoàn thiện không còn công thức gọn. Nhưng nó thường chỉ phụ thuộc vào vị trí và một ít trạng thái của tiền tố: chiều cao hiện tại của đường đi, số ngoặc đang mở, số bit 0 liên tiếp ở cuối. Quy hoạch động là số cách đi từ vị trí với trạng thái tới hết, tính từ cuối về đầu. Giải mã và xếp hạng giữ nguyên khuôn, chỉ thay bằng với là trạng thái sau khi đặt .
Khi điều kiện lại nói về cả đối tượng, như “độ sâu lớn nhất đúng bằng ”, thêm vào trạng thái phần tiền tố đã đạt (độ sâu lớn nhất tới giờ), hoặc đếm bằng hiệu: số đối tượng có độ sâu không quá trừ số có độ sâu không quá .
Hỏi nhanh
Đếm dãy ngoặc có độ sâu lớn nhất đúng bằng , vì sao lấy hiệu “không quá ” trừ “không quá ” thay vì đếm thẳng?
Với ngưỡng trên, mỗi bước chỉ cần biết số ngoặc mở có vượt không. Muốn đếm “đúng ” trực tiếp phải thêm vào trạng thái độ sâu lớn nhất đã đạt; hiệu hai lần đếm tránh được việc đó, vì dãy độ sâu đúng là dãy không quá mà không phải không quá .
Ví dụDãy Catalan Xếp Hạng Và Truy Ngược
Đề. Dãy Catalan cấp dài , đầu và cuối bằng 0, các số không âm, hai số kề hơn kém đúng 1. In thứ hạng của một dãy cho trước và dãy ở thứ hạng .
Lời giải. Khuôn chung của xếp hạng theo thứ tự từ điển: đếm số cách hoàn thành dãy từ mỗi trạng thái, rồi đi dọc dãy, mỗi lần dãy chọn một nhánh lớn hơn thì cộng số dãy của các nhánh nhỏ hơn. Ở đây từ giá trị chỉ đi tới (nhỏ hơn, đứng trước) hoặc .
Bước 1: số cách hoàn thành. ways[i][h] là số cách điền từ vị trí đang có giá trị tới cuối; chỉ có đúng một cách kết thúc là ways[L][0] = 1. Mọi giá trị dưới số Catalan .
ways[L][0] = 1;
for (ll i = L - 1; i >= 1; i--)
for (ll h = 0; h <= n; h++) ways[i][h] = (h ? ways[i + 1][h - 1] : 0) + ways[i + 1][h + 1];
Bước 2: xếp hạng. Ở mỗi vị trí dãy đi lên trong khi đi xuống cũng hợp lệ (giá trị hiện tại dương), mọi dãy đi xuống tại đó đứng trước; cộng số cách của chúng.
ll rk = 1;
for (ll i = 1; i < L; i++)
if (c[i + 1] == c[i] + 1 && c[i] > 0) rk += ways[i + 1][c[i] - 1];
Bước 3: giải mã. Thử đi xuống trước vì giá trị nhỏ hơn đứng trước. không quá số cách đi xuống thì đi xuống, ngược lại trừ đi và đi lên.
for (ll i = 1; i < L; i++) {
ll down = h ? ways[i + 1][h - 1] : 0;
if (k <= down) h--;
else {
k -= down;
h++;
}
cout << ' ' << h;
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, L, c[35], ways[35][20];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
L = 2 * n + 1;
for (ll i = 1; i <= L; i++) cin >> c[i];
ll k;
cin >> k;
ways[L][0] = 1;
for (ll i = L - 1; i >= 1; i--)
for (ll h = 0; h <= n; h++) ways[i][h] = (h ? ways[i + 1][h - 1] : 0) + ways[i + 1][h + 1];
ll rk = 1;
for (ll i = 1; i < L; i++)
if (c[i + 1] == c[i] + 1 && c[i] > 0) rk += ways[i + 1][c[i] - 1];
cout << rk << '\n';
ll h = 0;
cout << 0;
for (ll i = 1; i < L; i++) {
ll down = h ? ways[i + 1][h - 1] : 0;
if (k <= down) h--;
else {
k -= down;
h++;
}
cout << ' ' << h;
}
cout << '\n';
}
3
0 1 2 1 2 1 0
3
4
0 1 2 1 0 1 0
cho bảng, cho mỗi lượt đi.
Ví dụDãy ngoặc cấp k
Đề. Các dãy ngoặc đúng độ dài có độ sâu lớn nhất đúng bằng , sắp theo từ điển với ( đứng trước ). In số dãy như vậy và thứ hạng của một dãy cho trước.
Lời giải. “Độ sâu lớn nhất đúng bằng ” khó đếm thẳng, nhưng “không vượt ” thì dễ: số dãy độ sâu đúng là số dãy không vượt trừ số dãy không vượt . Xếp hạng theo khuôn từ điển: ở vị trí dãy đặt ), mọi dãy đặt ( tại đó đứng trước.
Bước 1: bảng “không vượt ”. dp[L][r][b] là số cách viết ký tự còn lại từ số ngoặc mở , về 0 mà không lúc nào vượt . Mỗi bước mở thêm (nếu chưa vượt ) hoặc đóng (nếu còn ngoặc mở).
for (ll L = 0; L <= k; L++) {
dp[L][0][0] = 1;
for (ll r = 1; r <= n; r++)
for (ll b = 0; b <= L; b++) dp[L][r][b] = (b + 1 <= L ? dp[L][r - 1][b + 1] : 0) + (b ? dp[L][r - 1][b - 1] : 0);
}
cout << dp[k][n][0] - dp[k - 1][n][0] << '\n';
Bước 2: xếp hạng. bal, mx theo dõi số mở và độ sâu lớn nhất của tiền tố thật. Ở vị trí dãy đặt ), thử đặt ( thay vào: số mở thành nb, độ sâu tiền tố thành nm. Nếu nm > k thì không có dãy nào; nếu tiền tố đã chạm thì đuôi chỉ cần không vượt ; nếu chưa chạm thì đuôi phải chạm đúng , đếm bằng hiệu hai bảng.
for (ll i = 0; i < n; i++) {
if (s[i] == ')') {
ll nb = bal + 1, nm = max(mx, nb), r = n - i - 1;
if (nm == k) rk += dp[k][r][nb];
else if (nm < k) rk += dp[k][r][nb] - dp[k - 1][r][nb];
}
bal += (s[i] == '(' ? 1 : -1);
mx = max(mx, bal);
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, k, dp[32][62][32];
string s;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k >> s;
for (ll L = 0; L <= k; L++) {
dp[L][0][0] = 1;
for (ll r = 1; r <= n; r++)
for (ll b = 0; b <= L; b++) dp[L][r][b] = (b + 1 <= L ? dp[L][r - 1][b + 1] : 0) + (b ? dp[L][r - 1][b - 1] : 0);
}
cout << dp[k][n][0] - dp[k - 1][n][0] << '\n';
ll rk = 1, bal = 0, mx = 0;
for (ll i = 0; i < n; i++) {
if (s[i] == ')') {
ll nb = bal + 1, nm = max(mx, nb), r = n - i - 1;
if (nm == k) rk += dp[k][r][nb];
else if (nm < k) rk += dp[k][r][nb] - dp[k - 1][r][nb];
}
bal += (s[i] == '(' ? 1 : -1);
mx = max(mx, bal);
}
cout << rk << '\n';
}
6 2
(()())
3
1
cho bảng, cho xếp hạng.
Khi số đếm rất lớn
Thứ tới mà số đối tượng có thể còn lớn hơn: số cách hoàn thiện tràn ll. Chỉ cần biết số cách có lớn hơn hay không, nên chặn trần: mọi phép cộng và nhân dừng ở một ngưỡng như . Khối có kích thước bị chặn vẫn được so sánh đúng với . Cũng hay gặp: số đối tượng tăng như dãy Fibonacci hay lũy thừa của 2, nên chỉ vài chục vị trí đầu cần đếm thật, còn lại số cách đã vượt mọi .
Ví dụKyoya Và Hoán Vị
Đề. Chia () thành các khối liên tiếp dài 1 hoặc 2, khối dài 2 bị đảo. In hoán vị thứ theo từ điển trong các hoán vị tạo được.
Lời giải. Số cách chia phần tử là Fibonacci , với ; nên không cần chặn trần. Ở vị trí , khối dài 1 bắt đầu bằng , nhỏ hơn khối dài 2 bắt đầu bằng ; khối dài 1 có đuôi. không quá số đó thì đặt , không thì trừ đi và đặt .
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, k, f[55];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
f[0] = f[1] = 1;
for (ll i = 2; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
for (ll i = 1; i <= n;) {
if (k <= f[n - i]) {
cout << i << ' ';
i++;
} else {
k -= f[n - i];
cout << i + 1 << ' ' << i << ' ';
i += 2;
}
}
cout << '\n';
}
3 3
2 1 3
Nhỏ nhất theo từ điển bằng quy hoạch động từ cuối
Khi đề hỏi đối tượng nhỏ nhất theo từ điển trong một tập phức tạp, quy hoạch động có thể lưu thẳng chuỗi tốt nhất làm giá trị. Nhưng hướng duyệt quan trọng: so sánh từ điển do ký tự đầu quyết định, nên tiền tố tốt nhất chưa chắc mở rộng thành chuỗi tốt nhất, còn hậu tố tốt nhất thì luôn đúng khi nối thêm ở phía trước. Vì vậy quy hoạch động từ cuối về đầu: là chuỗi nhỏ nhất dựng từ phần , rồi .
Ví dụThẻ Chuỗi
Đề. thẻ ghi chuỗi chữ thường dài tới 50. Chọn đúng thẻ và nối theo thứ tự tùy ý để được chuỗi nhỏ nhất theo từ điển.
Lời giải. Hai ý. Thứ nhất, với một tập thẻ đã chọn, nối theo thứ tự sắp bằng phép so sánh là tốt nhất: đổi hai thẻ kề nhau sai thứ tự không bao giờ làm chuỗi lớn hơn (bài 03). Thứ hai, sau khi sắp mọi thẻ theo thứ tự đó, chọn tập bằng quy hoạch động từ cuối về đầu: là chuỗi nhỏ nhất dùng đúng thẻ trong các thẻ . Từ đầu về cuối thì sai: tiền tố nhỏ nhất có thể dẫn tới chuỗi lớn hơn, còn hậu tố nhỏ nhất thêm một thẻ vào đầu vẫn là ứng viên tốt nhất.
Bước 1: sắp thẻ. Phép so sánh bắc cầu, nên sort dùng được.
bool cmp(string a, string b) {
return a + b < b + a;
}
Bước 2: chọn từ cuối về. ok[i][j] cho biết có cách chọn đúng thẻ không. Chuyển trạng thái: bỏ thẻ , hoặc lấy thẻ đứng đầu rồi nối với chuỗi tốt nhất của phần sau. Thẻ đứng trước mọi thẻ sau nó theo thứ tự đã sắp, nên đặt nó ở đầu là đúng thứ tự nối.
for (ll i = n; i >= 1; i--) {
ok[i][0] = 1;
for (ll j = 1; j <= n - i + 1; j++) {
if (ok[i + 1][j]) {
dp[i][j] = dp[i + 1][j];
ok[i][j] = 1;
}
if (ok[i + 1][j - 1]) {
string t = s[i] + dp[i + 1][j - 1];
if (!ok[i][j] || t < dp[i][j]) dp[i][j] = t;
ok[i][j] = 1;
}
}
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, k;
string s[55], dp[55][55];
bool ok[55][55];
bool cmp(string a, string b) {
return a + b < b + a;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
for (ll i = 1; i <= n; i++) cin >> s[i];
sort(s + 1, s + n + 1, cmp);
ok[n + 1][0] = 1;
for (ll i = n; i >= 1; i--) {
ok[i][0] = 1;
for (ll j = 1; j <= n - i + 1; j++) {
if (ok[i + 1][j]) {
dp[i][j] = dp[i + 1][j];
ok[i][j] = 1;
}
if (ok[i + 1][j - 1]) {
string t = s[i] + dp[i + 1][j - 1];
if (!ok[i][j] || t < dp[i][j]) dp[i][j] = t;
ok[i][j] = 1;
}
}
}
cout << dp[1][k] << '\n';
}
4 3
b
a
baa
ba
abaab
Mỗi ô là một chuỗi dài tới ký tự, so sánh và nối tốn : tổng khoảng phép.
Khi đi thi
- Thứ hay thứ hạng trong một tập sắp từ điển: dựng từng vị trí, đếm số đuôi sau mỗi lựa chọn. Viết hàm đếm trước, kiểm bằng vét cạn với kích thước nhỏ, rồi mới viết giải mã và xếp hạng.
- Kiểm tra chéo: giải mã thứ hạng vừa tính phải ra lại đối tượng; tổng các khối ở vị trí đầu phải bằng tổng số đối tượng.
- Số đếm có thể vượt
ll: chặn trần ở khoảng , hoặc nhận ra số đếm tăng theo hàm mũ và chỉ vài vị trí đầu cần tính thật. Đề cho là số hàng trăm chữ số thì phải dùng số lớn.
Tóm tắt
- Đối tượng cùng tiền tố đứng liền nhau thành khối; giải mã thứ bằng cách trừ các khối đứng trước, xếp hạng bằng cách cộng chúng.
- Hoán vị: khai triển Cantor với giai thừa; tổ hợp, chuỗi có số ký tự cố định: hệ số nhị thức.
- Ràng buộc phức tạp: quy hoạch động đếm đuôi theo (vị trí, trạng thái); điều kiện trên cả đối tượng thì thêm trạng thái hoặc đếm bằng hiệu.
- Số đếm lớn: chặn trần.
- Nhỏ nhất theo từ điển: quy hoạch động lưu chuỗi, duyệt từ cuối về đầu.
Bài tập
b; chữ b đầu ở vị trí từ phải sang mở một khối chuỗi, tìm khối chứa rồi đặt chữ b thứ hai.* liền nhau thành một nhóm cho tới chữ b; thứ đọc như số trong hệ cơ số hỗn hợp từ nhóm cuối lên.set.a ít nhất trên đường tới mỗi ô, các ô đạt không quá coi như a; rồi lan từng đường chéo, mỗi bước chỉ giữ các ô có chữ nhỏ nhất.Đọc thêm
- Quy hoạch động cơ bản (Phần 1)VNOI WikiCác bài đếm làm nền cho đếm khối, viết bằng tiếng Việt.
- Generating all K-combinationscp-algorithmsSinh và đánh thứ tự các tổ hợp.
- 置换和排列OI WikiHoán vị, khai triển Cantor cho thứ hạng và giải mã ngược.