Đây là bài viết số thuộc series Chia căn, thuộc danh sách bài viết về Cấu trúc dữ liệu nâng cao và Các kĩ thuật tối ưu hóa. Trước khi đọc bài viết này, các bạn cần nắm vững cơ bản về kĩ thuật Chia căn. Các bạn có thể xem lại bài viết phần về Chia căn tại đây.
I. Giới thiệu chung
Ở bài viết phần chúng ta đã cùng thảo luận về kĩ thuật Chia căn cơ bản - là kĩ thuật rất hiệu quả để giải quyết các bài toán truy vấn và cập nhật đoạn với input không quá lớn (khoảng ). Chia căn còn có những ứng dụng to lớn hơn nữa, cộng thêm việc cài đặt đơn giản, khiến cho nó trở thành một kĩ thuật vô cùng hữu ích trong phòng thi.
Tiếp nối các ứng dụng của Chia căn, trong bài viết này chúng ta sẽ cùng bàn về kĩ thuật tăng tốc độ trả lời các truy vấn bằng cách sắp xếp chúng theo một thứ tự nhất định, còn gọi là thuật toán Mo's (Mo's Algorithm).
Để làm rõ ý tưởng thuật toán, ta sẽ cùng xem xét bài toán minh họa sau:
Cho dãy số gồm phần tử . Với một đoạn con của dãy, ta định nghĩa là giá trị xuất hiện nhiều nhất trong đoạn con đó.
Yêu cầu: Với mỗi truy vấn hãy xác định
Input:
- Dòng đầu tiên chứa hai số nguyên dương và - độ dài dãy số và số lượng truy vấn.
- Dòng thứ hai chứa số nguyên dương phân tách nhau bởi dấu cách.
- Trên dòng tiếp theo, mỗi dòng chứa hai số nguyên dương thể hiện một truy vấn.
Ràng buộc:
- .
- .
Output:
- Với mỗi truy vấn, in ra kết quả trên một dòng. Nếu có nhiều giá trị thỏa mãn thì chọn giá trị nhỏ nhất.
Sample Input:
6 3
1 1 5 3 3 6
1 3
1 5
2 5
Sample Output:
1
1
3
II. Ý tưởng
Đối với bài toán trên, có thể các bạn sẽ nghĩ ngay tới Segment Tree. Tuy nhiên, nếu nhìn nhận thật kĩ thì ta thấy rằng: Khi có thông tin của hai nút con kiểm soát đoạn và ta hoàn toàn không tạo ra được thông tin hữu ích nào cho đoạn .
Cùng xem thuật toán Mo's sẽ hoạt động hiệu quả như thế nào cho bài toán này.
1. Thuật toán ngây thơ
Cách dễ nhất là vận dụng kĩ thuật Đếm phân phối để giải quyết các truy vấn. Gọi là số lần xuất hiện của phần tử với mỗi truy vấn, ta sẽ tính mảng với tất cả các giá trị thuộc đoạn :
int maxv = *max_element(a.begin() + 1, a.end()); int query(int l, int r, int maxv)
{ int res = -1; vector < int > cnt(maxv + 1); for (int i = l; i <= r; ++i) { ++cnt[a[i]]; if (res == -1 || cnt[a[i]] > cnt[res]) res = a[i]; } return res;
}
Thuật toán trên có độ phức tạp lên tới do hàm query(l, r, maxv)
đã mất độ phức tạp lên tới . Ta có thể cải tiến một chút vận dụng cấu trúc dữ liệu set
như sau:
-
Khi chuyển từ truy vấn sang truy vấn thì ta thay đổi lại mảng một cách phù hợp:
- Nếu thì ta giảm số lần xuất hiện của các giá trị .
- Nếu thì ta tăng số lần xuất hiện của các giá trị .
- Xử lý tương tự với và .
-
Để tìm được phần tử nhỏ nhất có số lần xuất hiện nhiều nhất, ta vận dụng thêm cấu trúc dữ liệu
set
với kiểupair < int, int >
:- Giả sử phần tử ở truy vấn có và ở truy vấn có .
- Ta sẽ xóa cặp giá trị khỏi
set
và thêm cặp giá trị vàoset
(viết lại hàm so sánh sắp xếp tăng các phần tử theo trườngfirst
, nếufirst
bằng nhau thì sắp tăng theosecond
). - Phần tử xuất hiện nhiều nhất trong truy vấn sẽ là phần tử ở đầu
set
. Do sử dụng thêmset
nên bước này tốn thêm .
-
Sau khi cải tiến, thuật toán sẽ có độ phức tạp là . Rõ ràng thuật toán này không thể đáp ứng ràng buộc của bài toán.
2. Thuật toán Mo's
Sắp xếp lại các truy vấn
Ý tưởng của thuật toán Mo's trong các bài toán trả lời truy vấn đoạn là sắp xếp lại các truy vấn sao cho tổng . Hàm so sánh dưới đây sẽ thực hiện điều đó:
int block_size = ceil(sqrt(n)); bool cmp(Query a, Query b)
{ if (a.l / block_size != b.l / block_size) return a.l / block_size < b.l / block_size; return a.r < b.r;
}
Cách sắp xếp này bản chất là vận dụng kĩ thuật Chia căn:
- Chia dãy số thành các khối có độ dài .
- Nếu đầu trái của hai truy vấn và nằm ở hai khối khác nhau thì ta sắp xếp tăng dần theo đầu trái.
- Ngược lại (đầu trái của hai truy vấn thuộc cùng một khối) thì ta sắp xếp tăng dần theo đầu phải.
- Thực tế, để tìm ra chỉ số khối của một vị trí ta dùng công thức (các khối đánh số từ ). Tuy nhiên ở đây ta chỉ cần so sánh hai đầu mút của hai truy vấn có thuộc cùng khối không nên bỏ hằng số đi vẫn cho ra kết quả đúng.
Tuy nhiên, do các truy vấn trong thuật toán Mo's bị sắp xếp lại, nên ta chỉ có thể áp dụng thuật toán này khi bài toán có thể xử lý offline, nghĩa là các truy vấn có thể được thực hiện lần lượt rồi mới in ra kết quả ở cuối.
Áp dụng vào việc chuyển truy vấn
Ta xây dựng mảng là một mảng gồm set
, set
thứ lưu các giá trị có tần suất cùng là (tức là ). Khi chuyển truy vấn từ sang , sẽ có những bị thay đổi (giảm đi hoặc tăng lên), đồng nghĩa với việc các cũng sẽ bị thay đổi theo - do sẽ có những phần tử mới được thêm vào truy vấn nhưng cũng có những phần tử bị mất đi.
Gọi là chỉ số lớn nhất của mảng thỏa mãn - đây cũng chính là số lần xuất hiện của phần tử xuất hiện nhiều nhất. Ta sẽ thiết kế hai thao tác thêm và xóa một phần tử khỏi truy vấn như sau:
- Thêm một số :
- Xóa khỏi .
- Tăng thêm .
- Thêm vào .
- Nếu như thì cập nhật lại và giá trị mode sẽ là phần tử đầu tiên của .
- Xóa một số :
- Xóa khỏi .
- Giảm đi .
- Thêm vào .
- Nếu như rỗng tức là phần tử vừa bị xóa đi chính là mode của truy vấn trước khi xóa, nên ta giảm đi và cập nhật lại mode của truy vấn này là phần tử đầu tiên của .
Cuối cùng, ta tiến hành xử lý các truy vấn như sau:
- Các truy vấn cần được lưu lại vị trí do đã sắp xếp theo Mo's algorithm. Gọi truy vấn thứ sau khi sắp xếp là một bản ghi và vị trí chính xác của nó là .
- Gọi là đáp án của truy vấn có vị trí ban đầu là . Ban đầu ta khởi tạo bằng cách gọi thao tác thêm tất cả các phần tử trong đoạn và sẽ gán bằng mode của truy vấn này.
- Xét các truy vấn từ tới ta sẽ cập nhật kết quả cho từ như sau:
- Đặt là truy vấn trước. Ban đầu .
- Nếu thì thêm các phần tử trong đoạn đồng thời giảm xuống tới khi .
- Nếu thì thêm các phần tử trong đoạn đồng thời tăng lên tới khi .
- Nếu thì xóa các phần tử trong đoạn đồng thời tăng lên tới khi .
- Nếu thì xóa các phần tử trong đoạn đồng thời giảm đi tới khi .
- Gán mode của truy vấn hiện tại. Lưu ý thứ tự thực hiện bắt buộc phải là thêm trước - xóa sau thì thuật toán mới chính xác.
Cài đặt
#include <bits/stdc++.h>
#define int long long using namespace std; const int maxn = 200010; struct QueryType
{ int l, r, pos;
} query[maxn]; int block_size, mode_val, mode_cnt;
int cnt[maxn], a[maxn];
set < int > s[maxn]; // Sắp xếp các truy vấn theo Mo's Algorithm.
bool cmp(QueryType a, QueryType b) { if (a.l / block_size != b.l / block_size) return a.l < b.l; else return a.r < b.r;
} // Thêm 1 phần tử.
void add_value(int pos) { int val = a[pos]; s[cnt[val]].erase(val); ++cnt[val]; s[cnt[val]].insert(val); // nếu thêm một phần tử vào và cnt của nó > mode_cnt trước đó, thì phần tử vừa thêm vào này chính là Mode mới của // đoạn đang xét. Cập nhật mode mới và mode_cnt. if (cnt[val] > mode_cnt) mode_cnt = cnt[val]; // Cập nhật giá trị mode của đoạn mới, chọn phần tử nhỏ nhất. mode_val = *s[mode_cnt].begin(); } // Xóa 1 phần tử.
void remove_value(int pos) { int val = a[pos]; s[cnt[val]].erase(val); if (cnt[val] > 0) // Nếu phần tử val đã xuất hiện rồi thì mới xóa nó đi được. { --cnt[val]; s[cnt[val]].insert(val); } // Mode trước đó có số lần xuất hiện là mode_cnt, nếu sau bước này mà s[mode_cnt] rỗng tức là mode mới có số lần // xuất hiện là (mode_cnt - 1). if (s[mode_cnt].empty()) --mode_cnt; // Cập nhật mode của đoạn mới, chọn giá trị nhỏ nhất. mode_val = *s[mode_cnt].begin(); } void solution(int n, int t)
{ // Sort lại các truy vấn. Tính trước truy vấn 1 và cập nhật dần lên các truy vấn sau. block_size = ceil(sqrt(n * 1.0)); sort(query + 1, query + t + 1, cmp); // Khởi tạo truy vấn đầu tiên. Ban đầu tất cả các phần tử đều có tần suất là 0. s[0] = unordered_set < int >(a + 1, a + n + 1); for (int i = query[1].l; i <= query[1].r; ++i) add_value(i); vector < int > res(n + 1); res[query[1].pos] = mode_val; // Gọi l1, r1 là (l, r) của truy vấn trước; (l2, r2) là của truy vấn hiện tại. int l1 = query[1].l, r1 = query[1].r; for (int k = 2; k <= t; ++k) { while (l1 > query[k].l) // l1 > l2: Tăng cnt[a[l2] -> a[l1 - 1]]. { --l1; add_value(l1); } while (r1 < query[k].r) // r1 < r2: Tăng cnt[a[r1 + 1] -> a[r2]]. { ++r1; add_value(r1); } while (l1 < query[k].l) // l1 < l2: Giảm cnt[a[l1] -> a[l2 - 1]]. { remove_value(l1); ++l1; } while (r1 > query[k].r) // r1 > r2: Giảm cnt[a[r2 + 1] -> a[r1]]. { remove_value(r1); --r1; } res[query[k].pos] = mode_val; } for (int i = 1; i <= t; ++i) cout << res[i] << '\n';
} main()
{ ios_base::sync_with_stdio(false); cin.tie(nullptr); int n, t; cin >> n >> t; for (int i = 1; i <= n; ++i) cin >> a[i]; for (int i = 1; i <= t; ++i) { cin >> query[i].l >> query[i].r; query[i].pos = i; } solution(n, t); return 0;
}
Đánh giá độ phức tạp
Độ phức tạp thời gian
- Khi di chuyển từ sang :
- Nếu và thuộc cùng một khối: Với mỗi thao tác, do độ dài khối là nên số thao tác không vượt quá . Có tổng cộng truy vấn nên tổng ộộ phức tạp là .
- Nếu và thuộc hai khối khác nhau: Vì ta đã ưu tiên sắp xếp các khối theo tăng dần, nên trường hợp này xảy ra không quá lần. Mỗi khi xảy ra trường hợp này tốn độ phức tạp tối đa là nên độ phức tạp là .
- Khi di chuyển từ sang :
- Nếu và thuộc cùng một khối: Vì trong cùng một khối, các giá trị sắp xếp tăng dần nên với mỗi khối của ta chỉ mất độ phức tạp tổng là . Có tổng cộng khối khác nhau nên tổng độ phức tạp là .
- Nếu và thuộc hai khối khác nhau: Do chỉ có tối đa lần đổi khối và mỗi lần đổi mất tối đa để di chuyển nên ta mất độ phức tạp tổng là .
Tổng hợp lại, ta có độ phức tạp thời gian .
Độ phức tạp không gian
Ta dùng tới các mảng có độ dài nên độ phức tạp không gian tổng quát là .
3. Cải tiến thời gian thực thi cho Mo's Algorithm
Trên thực tế, việc đặt kích thước của các khối bằng chính xác không phải luôn luôn tạo ra thời gian thực thi tối ưu. Chẳng hạn, nếu như thì đặt kích thước khối bằng hoặc sẽ chạy tốt hơn.
Hãy luôn luôn đặt kích thước của các khối là giá trị const
, chứ không nên tính toán nó trong Runtime, bởi vì các giá trị hằng số sẽ được chương trình biên dịch tối ưu tốt hơn khi thực hiện phép chia.
Hàm sắp xếp có thể được cải tiến hơn một chút ở trường hợp chỉ số của hai truy vấn thuộc cùng một khối:
- Nếu khối mang chỉ số lẻ, ta sắp xếp tăng dần đầu bên phải của truy vấn.
- Nếu khối mang chỉ số chẵn, ta sắp xếp giảm dần đầu bên phải của truy vấn.
- Điều này sẽ giảm thiểu chuyển động của con trỏ bên phải, vì việc sắp xếp thông thường sẽ di chuyển con trỏ bên phải từ cuối trở lại đầu ở đầu mỗi khối (do cài đặt trong thuật toán sắp xếp của STL C++). Với phiên bản cải tiến, việc khởi tạo lại này không còn cần thiết nữa.
bool cmp(QueryType a, QueryType b) // sắp xếp các truy vấn theo Mo's Algorithm.
{ if (a.l / block_size != b.l / block_size) return a.l < b.l; else { int id = (a.l + block_size - 1) / block_size; return (id & 1) ? a.r < b.r : a.r > b.r; }
}
Thuật toán Mo's có thể được cải tiến tốc độ nhiều hơn nữa nếu sử dụng TSP và Hilbert curve. Tuy nhiên đây là kĩ thuật khó nên trong bài viết này không giới thiệu, các bạn có thể tìm đọc thêm tại đây.
III. Bài tập minh họa
1. D - Query
Đề bài
Cho dãy số gồm phần tử và truy vấn. Mỗi truy vấn có dạng yêu cầu bạn phải trả về số lượng phần tử phân biệt trong đoạn vị trí của dãy .
Yêu cầu: Hãy trả lời các truy vấn và đưa ra đáp án?
Input:
- Dòng đầu tiên chứa số nguyên dương .
- Dòng thứ hai chứa số nguyên dương .
- Dòng thứ ba chứa số nguyên dương .
- Trên dòng tiếp theo, mỗi dòng chứa một cặp số nguyên dương thể hiện một truy vấn.
Ràng buộc:
- .
- .
- .
- .
Output:
- Với mỗi truy vấn, in ra kết quả trên một dòng.
Sample Input:
5
1 1 2 1 3
3
1 5
2 4
3 5
Sample Output:
3
2
3
Ý tưởng
Đầu tiên ta sắp xếp lại các truy vấn theo Mo's Algorithm.
Để đếm số lượng phần tử phân biệt trong một truy vấn, ta sẽ duy trì mảng đếm phân phối để đếm số lượng giá trị trong đoạn hiện tại.
Khi chuyển đoạn từ sang ta sẽ xử lý thêm - xóa phần tử theo như Mo's Algorithm, và lưu ý:
- Nếu một phần tử đang có và sau khi thực hiện thao tác thêm phần tử, nó trở thành thì số lượng phần tử phân biệt của đoạn sẽ tăng thêm .
- Nếu một phần tử đang có và sau khi thực hiện thao tác xóa phần tử, nó trở thành thì số lượng phần tử phân biệt của đoạn sẽ giảm đi .
Tuy nhiên, các giá trị nên muốn thực hiện đếm phân phối thì phải rời rạc hóa các giá trị về đoạn . Các bạn có thể xem lại kĩ thuật rời rạc hóa tại đây.
Độ phức tạp: .
Cài đặt
#pragma GCC optimize("O3","unroll-loops")
#pragma GCC target("avx2") #include <bits/stdc++.h> using namespace std; const int maxn = 30001, maxv = 1e6 + 1, maxq = 200001; struct Query
{ int l, r, pos;
} query[maxq]; int block_size;
int cnt[maxv], a[maxn]; bool cmp(Query a, Query b)
{ if (a.l / block_size != b.l / block_size) return a.l < b.l; else { int id = (a.l + block_size - 1) / block_size; return (id & 1) ? a.r < b.r : a.r > b.r; }
} // Rời rạc hóa mảng A về các giá trị [1...n].
void discretizing(int n)
{ map < int, vector < int > > m; for (int i = 1; i <= n; ++i) m[a[i]].push_back(i); int d = 0; for (auto e: m) { ++d; for (int p: e.second) a[p] = d; }
} // Thêm 1 phần tử.
void add_value(int pos, int& distinct_cnt)
{ int val = a[pos]; ++cnt[val]; if (cnt[val] == 1) ++distinct_cnt;
} // Xóa 1 phần tử.
void remove_value(int pos, int& distinct_cnt)
{ int val = a[pos]; if (cnt[val] > 0) { --cnt[val]; if (cnt[val] == 0) --distinct_cnt; }
} void solution(int n, int q)
{ block_size = ceil(sqrt(n * 1.0)); sort(query + 1, query + q + 1, cmp); discretizing(n); int distinct_cnt = 0; for (int i = query[1].l; i <= query[1].r; ++i) add_value(i, distinct_cnt); vector < int > res(q + 1); res[query[1].pos] = distinct_cnt; int l1 = query[1].l, r1 = query[1].r; for (int i = 2; i <= q; ++i) { while (l1 > query[i].l) --l1, add_value(l1, distinct_cnt); while (r1 < query[i].r) ++r1, add_value(r1, distinct_cnt); while (l1 < query[i].l) remove_value(l1, distinct_cnt), ++l1; while (r1 > query[i].r) remove_value(r1, distinct_cnt), --r1; res[query[i].pos] = distinct_cnt; } for (int i = 1; i <= q; ++i) cout << res[i] << '\n';
} signed main()
{ ios_base::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; for (int i = 1; i <= n; ++i) cin >> a[i]; int q; cin >> q; for (int i = 1; i <= q; ++i) cin >> query[i].l >> query[i].r, query[i].pos = i; solution(n, q); return 0;
}
2. XOR and Favorite Number
Đề bài
Cho một mảng gồm số nguyên và một số nguyên .
Bạn cần trả lời truy vấn, mỗi truy vấn là một cặp số nguyên yêu cầu đếm số cặp thỏa mãn:
- .
- với là phép toán .
Yêu cầu: Hãy đưa ra đáp án của các truy vấn?
Input:
- Dòng đầu tiên chứa ba số nguyên - độ dài dãy số lượng truy vấn và số nguyên .
- Dòng thứ hai chứa số nguyên dương .
- Trên dòng tiếp theo, mỗi dòng chứa một cặp số nguyên dương thể hiện một truy vấn.
Ràng buộc:
- .
- .
- .
- .
Output:
- Với mỗi truy vấn, đưa ra đáp án trên một dòng.
Sample Input:
6 2 3
1 2 1 1 0 3
1 6
3 5
Sample Output:
7
0
Ý tưởng
Gọi là tổng tiền tố của các giá trị . Tức là:
Khi đó, ta có tính chất tổng của một đoạn sẽ là:
Như vậy, một truy vấn có thể đưa về bài toán đếm số cặp thỏa mãn:
Ta sắp xếp lại các truy vấn theo Mo's Algorithm. Thuật toán khi chuyển truy vấn sẽ như sau:
- Khi thêm một phần tử (tương ứng với một hoặc một mới) vào cấu trúc dữ liệu, ta cần đếm số giá trị (tương ứng với các hoặc ở trước đó) đã có sẵn, cộng thêm lượng đó vào kết quả.
- Khi xóa một phần tử (tương ứng với một hoặc một bị xóa đi), ta cần đếm số giá trị (tương ứng với các hoặc ở trước đó) đã có sẵn, trừ đi lượng đó khỏi kết quả.
- Cả hai thao tác trên dễ dàng thực hiện bằng một mảng đếm phân phối.
Độ phức tạp: .
Cài đặt
#pragma GCC optimize("O3","unroll-loops")
#pragma GCC target("avx2") #include <bits/stdc++.h>
#define int long long using namespace std; const int maxn = 1e5 + 1, maxv = 2e6 + 1; struct Query
{ int l, r, pos;
} query[maxn]; int block_size;
int cnt[maxv], a[maxn], pref[maxn]; bool cmp(Query a, Query b)
{ if (a.l / block_size != b.l / block_size) return a.l < b.l; else return a.r < b.r;
} // Thêm 1 phần tử.
void add_value(int pos, int k, int& pair_cnt)
{ int val = pref[pos]; pair_cnt += cnt[k ^ val]; ++cnt[val];
} // Xóa 1 phần tử.
void remove_value(int pos, int k, int& pair_cnt)
{ int val = pref[pos]; --cnt[val]; pair_cnt -= cnt[k ^ val];
} void data_preparation(int n, int q, int k, int& pair_cnt)
{ pref[0] = 0; for (int i = 1; i <= n; ++i) pref[i] = pref[i - 1] ^ a[i]; block_size = ceil(sqrt(n * 1.0)); sort(query + 1, query + q + 1, cmp); for (int i = query[1].l; i <= query[1].r; ++i) add_value(i, k, pair_cnt);
} void solution(int n, int q, int k)
{ int pair_cnt = 0; data_preparation(n, q, k, pair_cnt); int l1 = query[1].l, r1 = query[1].r; vector < int > res(q + 1); res[query[1].pos] = pair_cnt; for (int i = 2; i <= q; ++i) { while (l1 > query[i].l) --l1, add_value(l1, k, pair_cnt); while (r1 < query[i].r) ++r1, add_value(r1, k, pair_cnt); while (l1 < query[i].l) remove_value(l1, k, pair_cnt), ++l1; while (r1 > query[i].r) remove_value(r1, k, pair_cnt), --r1; res[query[i].pos] = pair_cnt; } for (int i = 1; i <= q; ++i) cout << res[i] << '\n';
} signed main()
{ ios_base::sync_with_stdio(false); cin.tie(nullptr); int n, q, k; cin >> n >> q >> k; for (int i = 1; i <= n; ++i) cin >> a[i]; for (int i = 1; i <= q; ++i) { cin >> query[i].l >> query[i].r; --query[i].l; query[i].pos = i; } solution(n, q, k); return 0;
}