Hướng dẫn giải của 3.Hoán Vị
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.
Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.
Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.
Bạn phải đăng nhập để mở lời giải này.
Đăng nhậpTác giả: , , ,
Hiểu bài toán
Cho một dãy số A gồm N phần tử. Đếm số lượng cặp chỉ số (i, j) sao cho đoạn con A[i..j] là một hoán vị liên tiếp của 1, 2, ..., k (với k = j - i + 1). Nói cách khác, đoạn con này phải chứa k số nguyên phân biệt liên tiếp bắt đầu từ 1.
Ví dụ: Dãy [1, 3, 2] là hợp lệ (chứa 1, 2, 3). Dãy [1, 2, 4] không hợp lệ (thiếu 3). Dãy [2, 3, 1] hợp lệ.
Đề bài yêu cầu in ra số lượng các đoạn con thỏa mãn và liệt kê tất cả các đoạn con đó (i, j) cùng độ dài k.
Các cách tiếp cận
Cách Brute Force (Duyệt tất cả các đoạn con)
#include <bits/stdc++.h>
using namespace std;
bool kt(vector<int> a) {
if (a.empty()) return false;
sort(a.begin(), a.end());
if (a[0] != 1) return false;
for (int i = 1; i < a.size(); i++) {
if (a[i] - a[i-1] != 1) return false;
}
return true;
}
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
vector<tuple<int, int, int>> results;
for (int i = 0; i < n; i++) {
vector<int> sub;
for (int j = i; j < n; j++) {
sub.push_back(a[j]);
if (kt(sub)) {
results.push_back({i + 1, j + 1, sub.size()});
}
}
}
cout << results.size() << "\n";
for (auto t : results) {
cout << get<0>(t) << " " << get<1>(t) << " " << get<2>(t) << "\n";
}
return 0;
}
- Time Complexity: O(N^3 log N)
- Space Complexity: O(N)
Phương pháp này sử dụng hai vòng lặp để duyệt qua tất cả các đoạn con (i, j). Với mỗi đoạn con, ta kiểm tra xem nó có phải là hoán vị của 1..k hay không bằng cách sao chép đoạn con đó, sắp xếp và kiểm tra điều kiện.
- Vòng lặp ngoài: i từ 0 đến N-1.
- Vòng lặp trong: j từ i đến N-1.
- Hàm
kt: tạo vector con, sắp xếp (O(k log k)), sau đó kiểm tra xem phần tử đầu có phải 1 không và các phần tử tiếp theo có tăng dần 1 đơn vị không.
Độ phức tạp: Có tổng cộng O(N^2) đoạn con. Với mỗi đoạn con độ dài k, việc sắp xếp tốn O(k log k). Tổng độ phức tạp xấp xỉ O(N^3 log N) (vì trung bình k ~ N/2). Đây là cách tiếp cận chậm nhất, chỉ phù hợp với N rất nhỏ.
Cách Tối ưu bằng Kiểm tra Tính Liên tiếp (Set & Max)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
vector<tuple<int, int, int>> ans;
for (int i = 0; i < n; i++) {
unordered_set<int> s;
int max_val = 0;
for (int j = i; j < n; j++) {
// Kiểm tra số có nằm ngoài giới hạn [1, n] hoặc đã xuất hiện chưa
if (a[j] > n || s.count(a[j])) break;
s.insert(a[j]);
max_val = max(max_val, a[j]);
// Điều kiện vàng: Nếu giá trị lớn nhất bằng độ dài đoạn con,
// và các số đều phân biệt (đã check bằng set),
// thì đoạn con đó là hoán vị 1..k.
// Ta không cần sort, chỉ cần max == length.
if (max_val == j - i + 1) {
ans.push_back({i + 1, j + 1, max_val});
}
}
}
cout << ans.size() << "\n";
// Sắp xếp theo độ dài (k) để in ra theo yêu cầu đề bài (nếu có)
sort(ans.begin(), ans.end(), [](auto a, auto b) {
return get<2>(a) < get<2>(b);
});
for (auto t : ans) {
cout << get<0>(t) << " " << get<1>(t) << " " << get<2>(t) << "\n";
}
return 0;
}
- Time Complexity: O(N^2)
- Space Complexity: O(N)
Đây là cách tiếp cận tối ưu hơn. Thay vì sắp xếp, ta sử dụng một Set (hoặc mảng đánh dấu) để đảm bảo các số trong đoạn con đều phân biệt.
Định lý: Một tập hợp các số nguyên dương phân biệt có tổng bằng tổng dãy 1 đến k (tức k(k+1)/2) thì nó chính xác là tập hợp {1, 2, ..., k}. Tuy nhiên, để kiểm tra nhanh, ta chỉ cần quan tâm đến giá trị lớn nhất (max). Nếu ta có một đoạn con độ dài k, chứa các số phân biệt và giá trị lớn nhất trong đoạn là k, thì chắc chắn đoạn con đó là hoán vị của 1..k.
Tại sao? Vì để có giá trị lớn nhất là k, ta cần ít nhất k số (từ 1 đến k). Nếu các số phân biệt và chỉ có k số, thì chúng phải là chính xác 1, 2, ..., k.
Độ phức tạp: O(N^2) do duyệt i, j. Với mỗi j, thao tác với set là O(1) trung bình. Tối ưu hơn đáng kể so với Brute Force.
Cách Tối ưu bằng Segment Tree (RMQ)
#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 30005;
const int MAXLG = 16;
int n;
int arr[MAXN];
int pos[MAXN]; // Lưu vị trí của các số
int rgMin[MAXLG][MAXN];
int rgMax[MAXLG][MAXN];
// Phép tiền xử lý cho RMQ (Range Minimum/Maximum Query)
void preprocess() {
// Xây dựng cây Segment Tree cho Min
for (int i = 1; i <= n; ++i) rgMin[0][i] = arr[i];
for (int j = 1; j < MAXLG; ++j) {
for (int i = 1; i + (1 << j) - 1 <= n; ++i) {
rgMin[j][i] = min(rgMin[j - 1][i], rgMin[j - 1][i + (1 << (j - 1))]);
}
}
// Xây dựng cây Segment Tree cho Max
for (int i = 1; i <= n; ++i) rgMax[0][i] = arr[i];
for (int j = 1; j < MAXLG; ++j) {
for (int i = 1; i + (1 << j) - 1 <= n; ++i) {
rgMax[j][i] = max(rgMax[j - 1][i], rgMax[j - 1][i + (1 << (j - 1))]);
}
}
}
int queryMin(int l, int r) {
int k = __lg(r - l + 1);
return min(rgMin[k][l], rgMin[k][r - (1 << k) + 1]);
}
int queryMax(int l, int r) {
int k = __lg(r - l + 1);
return max(rgMax[k][l], rgMax[k][r - (1 << k) + 1]);
}
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> arr[i];
pos[arr[i]] = i;
}
preprocess();
int count = 0;
for (int i = 1; i <= n; ++i) {
// Chỉ xét các đoạn con bắt đầu bằng 1 để giảm số lượng
if (arr[i] != 1) continue;
int max_len = 0;
for (int j = i; j <= n; ++j) {
int len = j - i + 1;
int val = arr[j];
// Dừng sớm nếu giá trị hiện tại quá lớn so với độ dài mong muốn
if (val > len) continue;
// Cập nhật độ dài lớn nhất có thể của đoạn con này
if (val > max_len) max_len = val;
// Nếu độ dài đoạn con (len) bằng max_len,
// và min >= 1, max == len, thì thỏa mãn.
// Cần check min >= 1.
if (len == max_len) {
if (queryMin(i, j) == 1 && queryMax(i, j) == len) {
count++;
cout << i << " " << j << " " << len << "\n";
}
}
}
}
cout << count;
return 0;
}
- Time Complexity: O(N^2)
- Space Complexity: O(N log N)
Đây là cách tiếp cận phức tạp hơn, sử dụng RMQ (Range Minimum/Maximum Query) để kiểm tra đoạn con.
- Tiền xử lý: Xây dựng Sparse Table (hoặc Segment Tree) để query Min và Max trong O(1) sau O(N log N).
- Tối ưu vòng lặp: Chỉ bắt đầu các đoạn con từ các vị trí có giá trị là 1 (vì hoán vị 1..k luôn bắt đầu bằng 1).
- Kiểm tra: Duyệt j từ i đến hết. Nếu đoạn con A[i..j] là hoán vị 1..k, thì:
Mincủa đoạn phải là 1.Maxcủa đoạn phải bằng độ dài k.- Tổng số lượng phần tử phải là k (tự động thỏa mãn nếu Min=1, Max=k và không có số bị lặp).
Để đảm bảo không có số bị lặp, ta có thể sử dụng mảng đánh dấu hoặc Set (như Approach 2), hoặc kết hợp với điều kiện "vị trí của các số nằm trong đoạn".
Phương pháp này giúp kiểm tra điều kiện hoán vị nhanh chóng (O(1) mỗi đoạn con), nhưng vẫn cần duyệt O(N^2) đoạn con. Nó phù hợp khi kết hợp với các điều kiện biên để prune (cắt tỉa) vòng lặp.
Phân tích độ phức tạp
| Cách tiếp cận | Time | Space | Tên |
|---|---|---|---|
| 1 | O(N^3 log N) | O(N) | Brute Force (Duyệt tất cả các đoạn con) |
| 2 | O(N^2) | O(N) | Tối ưu bằng Kiểm tra Tính Liên tiếp (Set & Max) |
| 3 | O(N^2) | O(N log N) | Tối ưu bằng Segment Tree (RMQ) |
Bài học kinh nghiệm
- Đặc điểm của hoán vị 1..k: Gồm k số phân biệt, giá trị nhỏ nhất là 1, giá trị lớn nhất là k (Max == Length).
- Cắt tỉa (Pruning): Trong vòng lặp duyệt j, nếu gặp số bị trùng hoặc số quá lớn (a[j] > n), có thể break ngay lập tức vì đoạn con dài hơn không thể thỏa mãn.
- Chỉ cần bắt đầu từ 1: Một hoán vị 1..k luôn chứa số 1. Do đó, ta chỉ cần xét các đoạn con bắt đầu tại các vị trí i sao cho A[i] = 1.
Lỗi thường gặp
- Quên kiểm tra số bị trùng (duplicates). Nếu chỉ kiểm tra Max == Length mà không kiểm tra tính phân biệt, sẽ bị sai.
- Sử dụng sort trong vòng lặp lồng nhau (Approach 1) gây quá thời gian chạy (Time Limit Exceeded) cho N lớn.
- Lỗi index: Đề bài thường yêu cầu in ra chỉ số 1-based, nhưng code C++ mặc định 0-based. Cần chú ý cộng/trừ 1 khi in/đọc.
Bình luận