Rời rạc hóa (Nén số)
I. Giới thiệu chung
Trong lập trình thi đấu hoặc trong môn Toán, có thể các bạn đã được nghe nhiều tới khái niệm Rời rạc hóa. Ở lĩnh vực Toán học ứng dụng và Khoa học máy tính, Rời rạc hóa là quá trình chuyển các hàm, mô hình, biến hay phương trình thành các đối số rời rạc, giúp cho dữ liệu trở nên phù hợp trong việc đánh giá và thao tác trên máy tính. Bản chất của phương pháp có thể hiểu nôm na là đưa vùng dữ liệu lớn về vùng dữ liệu nhỏ để dễ xử lý hơn mà vẫn không làm thay đổi kết quả của bài toán.
Trong phạm vi chuyên đề, chúng ta sẽ nghiên cứu một kĩ thuật bổ trợ của phương pháp Rời rạc hóa là kĩ thuật Nén số, hay còn gọi là Đánh lại số thứ tự. Kĩ thuật này sẽ giúp chúng ta đưa được một dãy số từ vùng giá trị lớn về vùng giá trị nhỏ mà vẫn đảm bảo được tính tương quan lớn - nhỏ của các phần tử trong dãy số. Bài toán cơ sở có thể phát biểu như sau:
Cho dãy số gồm phần tử . Cần đưa các giá trị trong mảng về đoạn mà vẫn đảm bảo được quan hệ lớn - bé giữa các phần tử trong mảng.
Yêu cầu: Hãy biến đổi mảng trở thành thỏa mãn điều kiện trên?
Input:
- Dòng đầu tiên chứa số nguyên dương .
- Dòng thứ hai chứa số nguyên phân tách nhau bởi dấu cách.
Ràng buộc:
- .
- .
Output:
- In ra mảng sau khi đã biến đổi theo yêu cầu.
Sample Input:
5
100 100 2000 1500 900000
Sample Output:
1 1 3 2 4
II. Kĩ thuật Rời rạc hóa
Để giải quyết bài toán nói trên, ta có hai cách cài đặt là Sắp xếp và Sử dụng ánh xạ (Map). Dưới đây tôi sẽ trình bày cả hai cách làm, vì chúng đều có độ phức tạp như nhau.
Cách 1: Sắp xếp
Trước tiên, ta sử dụng một mảng để lưu lại các phần tử của mảng với hai thông số:
Tức là với mỗi giá trị ta sẽ lưu lại cả giá trị lẫn vị trí của nó vào mảng tương ứng.
Kế đến, sắp xếp tăng dần mảng theo giá trị mà nó đang lưu. Trong C++ ta có thể dễ dàng xử lý công đoạn này bằng kiểu pair
, còn trong Python ta có thể sử dụng tuple
. Lấy ví dụ: Với mảng thì ta tạo được mảng .
Kế đến, duyệt qua các phần tử của mảng . Giả sử một phần tử của mảng có dạng thì:
-
Gán . Nghĩa là phần tử nhỏ nhất trong mảng sẽ được gán giá trị mới là .
-
Tạo một biến - đây sẽ là biến lưu giá trị nén lại của các số mới. Sau đó duyệt qua các phần tử nếu thì nghĩa là ta đã sang một cụm giá trị mới lớn hơn, tiến hành cập nhật:
Ngược lại nếu thì tức là giá trị tương ứng trên mảng vẫn sẽ được nén lại với giá trị mới bằng . Ta cập nhật:
Độ phức tạp thuật toán: .
Cài đặt
#include <bits/stdc++.h>
using namespace std;
void discretizing(int n, vector < int > &a, vector < pair < int, int > > &b)
{
sort(b.begin() + 1, b.end());
a[b[1].second] = 1;
int counter = 1;
for (int i = 2; i <= n; ++i)
if (b[i].first != b[i - 1].first)
a[b[i].second] = ++counter;
else
a[b[i].second] = counter;
}
main()
{
int n;
cin >> n;
vector < int > a(n + 1);
vector < pair < int, int > > b(n + 1);
for (int i = 1; i <= n; ++i)
{
cin >> a[i];
b[i] = {a[i], i};
}
discretizing(n, a, b);
for (int i = 1; i <= n; ++i)
cout << a[i] << ' ';
}
Cách 2: Sử dụng Ánh xạ (Map)
Ý tưởng ở đây là ta sẽ lưu lại các phần tử trong mảng với các vị trí của chúng. Tạo một map
với khóa là giá trị và giá trị là một mảng lưu các vị trí chứa những giá trị bằng trong mảng .
Sau đó, với một cặp trong map
, ta biết rằng các vị trí xuất hiện giá trị trong mảng đang được lưu trong mảng . Tạo một biến tương tự với cách làm số và duyệt qua tất cả các vị trí xuất hiện của trong mảng cập nhật lại toàn bộ các vị trí đó bằng .
Mỗi khi chuyển sang một cặp phần tử mới trong map
thì ta tăng lên đơn vị để chuẩn bị giá trị nén tiếp theo lớn hơn, do các phần tử trong map
được sắp xếp tăng dần theo khóa.
Độ phức tạp: .
#include <bits/stdc++.h>
using namespace std;
main()
{
int n;
cin >> n;
vector < int > a(n + 1);
map < int, vector < int > > cnt;
for (int i = 1; i <= n; ++i)
{
cin >> a[i];
cnt[a[i]].push_back(i);
}
int counter = 0;
for (auto e: cnt)
{
++counter;
for (int p: e.second)
a[p] = counter;
}
for (int i = 1; i <= n; ++i)
cout << a[i] << ' ';
}
III. Bài tập áp dụng
Bài 1: Phát đồng xu (HSG 12 Hà Nội 2020 - 2021)
Đề bài
Trong một trò chơi, có người chơi xếp thành một vòng tròn và được đánh số từ tới theo chiều kim đồng hồ. Trước khi trò chơi bắt đầu, sẽ có lượt phát đồng xu cho người chơi với nguyên tắc như sau: Mỗi lượt, chọn ngẫu nhiên hai số nguyên dương và rồi phát một đồng xu cho những người chơi mang số hiệu từ tới theo chiều kim đồng hồ.
Yêu cầu: Cho trước và các cặp số . Tìm số đồng xu lớn nhất mà người chơi được phát và số lượng người chơi đạt được số lượng đồng xu như vậy?
Input:
- Dòng đầu tiên chứa hai số nguyên dương - số lượng người chơi và số lượt phát đồng xu.
- dòng tiếp theo, mỗi dòng gồm hai số nguyên dương và mô tả một lượt phát đồng xu.
Ràng buộc:
- .
- .
Subtasks:
- Subtask ( số điểm): .
- Subtask ( số điểm): .
- Subtask ( số điểm): Không có ràng buộc gì thêm.
Output:
- Gồm hai số nguyên dương là số đồng xu lớn nhất mà người chơi được phát và số lượng người chơi đạt được số đồng xu như vậy.
Sample Input:
5 2
1 5
4 2
Sample Output:
2 4
Giải thích:
Số đồng xu của mỗi người ở mỗi lượt phát đồng xu:
- Ban đầu: .
- Lượt thứ nhất: .
- Lượt thứ hai: .
Vậy số lượng đồng xu lớn nhất là và có người được đồng xu.
Ý tưởng
Subtask 1
Ứng với mỗi truy vấn, các bạn duyệt từng vị trí từ tới và tăng số lượng đồng xu nhận được ở các vị trí đó lên đơn vị trên một mảng. Sau đó tìm ra phần tử lớn nhất trong mảng đó và số lượng phần tử lớn nhất.
Độ phức tạp: .
Subtask 2
Ứng với mỗi truy vấn, thay vì duyệt từ tới thì ta áp dụng kĩ thuật update tăng đoạn kiểu "lười" trên một mảng như sau:
Riêng đối với các truy vấn cập nhật có ta có thể tách làm 2 đoạn là và rồi làm theo cách trên. Các bạn có thể đọc về kĩ thuật này thêm một lần nữa tại đây.
Sau khi update xong toàn bộ các truy vấn theo cách trên, ta duyệt lại một lần các vị trí từ tới và cập nhật:
Như vậy, toàn bộ mảng sẽ được cập nhật chính xác. Nhiệm vụ cuối cùng ta chỉ cần đi tìm số lớn nhất trên mảng và số lượng số như vậy.
Độ phức tạp: .
Subtask 3
Cách làm ở subtask 2 sẽ không thực hiện được với vì vừa gây tràn bộ nhớ mảng vừa không thể duyệt bằng một vòng lặp. Để cải tiến, thay vì duyệt toàn bộ các vị trí từ tới thì ta sẽ sử dụng cấu trúc dữ liệu map
để đánh số lại các đầu mút của truy vấn cập nhật tăng.
Để dễ hình dung, xét một ví dụ như sau: Giả sử ta có các truy vấn lần lượt là . Nếu như làm theo cách ở subtask 2 thì ta phải duyệt từ tới để update lại các vị trí trên mảng hoàn toàn không khả thi. Thay vào đó, ta sẽ ném tất cả các đầu mút của các truy vấn cập nhật vào một mảng mới và đánh số lại chúng. Ta biết rằng, một truy vấn cập nhật thì thao tác thay đổi sẽ diễn ra ở và vì thế ta sẽ ném hai giá trị và vào một mảng mới là rồi sắp xếp chúng lại (lưu ý lọc các giá trị trong mảng tránh để bị trùng nhau, ví dụ hai lần đầu mút bị lặp lại). Hai mảng và được mô tả như hình bên dưới, kèm theo chỉ số của các phần tử:
Và sau khi được update lại bằng một vòng lặp (giả định là ta có thể duyệt như vậy) thì trạng thái sẽ như thế này:
Ý nghĩa của bảng trên sẽ là:
- Vị trí nhận được đồng xu.
- Vị trí nhận được đồng xu.
- Các vị trí từ tới nhận được đồng xu.
- Các vị trí từ tới nhận được đồng xu.
- Các vị trí từ tới nhận được đồng xu.
- Các vị trí từ tới nhận được đồng xu.
- Các vị trí từ tới nhận được đồng xu.
Như vậy, với một cặp vị trí trên mảng thì rõ ràng ta biết rằng, toàn bộ những người từ vị trí tới đều sẽ nhận được số đồng xu cùng bằng hoàn toàn không cần phải duyệt toàn bộ các vị trí để update lại. Vì vậy, ta chỉ cần cộng dồn từ trước ra sau trên mảng để có được số đồng xu ở các đoạn vị trí liên tiếp nhau, đồng nghĩa với việc ta chỉ cần update ở các vị trí tương ứng với từng cặp trên mảng .
Vấn đề đặt ra bây giờ là, ứng với một truy vấn thì làm sao để biết và tương ứng với vị trí số mấy trên mảng Cách làm là ta sử dụng map
để đánh số lại các vị trí thành các tương ứng trên mảng . Chẳng hạn, Sau bước này, thì với mỗi cặp ta chỉ cần tiến hành update như sau:
Công việc còn lại chỉ là cộng dồn từ đầu tới cuối mảng và tiến hành xử lý tìm kết quả dựa vào các gợi ý đã cho ở trên.
Độ phức tạp: .
Bài 2: Dãy số
Đề bài
Cho một dãy số nguyên gồm phần tử . Một cặp vị trí trong dãy số được gọi là cặp vị trí đặc biệt nếu như:
Yêu cầu: Cho trước dãy số và hai số nguyên . Hãy đếm số cặp vị trí đặc biệt trong dãy số?
Input:
- Dòng đầu tiên chứa ba số nguyên và .
- Dòng thứ hai chứa số nguyên phân tách nhau bởi dấu cách.
Ràng buộc:
- .
- .
- .
Subtasks:
- Subtask ( số điểm): .
- Subtask ( số điểm): Không có ràng buộc gì thêm.
Output:
- In ra số cặp vị trí đặc biệt tìm được.
Sample Input:
4 2 4
1 2 3 4
Sample Output:
4
Giải thích:
Các cặp vị trí đặc biệt là: .
Ý tưởng
Subtask 1
Hướng làm đơn giản nhất là sử dụng hai vòng lặp, xét mọi cặp vị trí và xem tổng của đoạn đó có thỏa mãn yêu cầu của đề bài không. Mô hình thuật toán có thể mô tả như dưới đây:
int res = 0;
for (int i = 1; i <= n; ++i)
{
int sum = 0;
for (int j = i; j <= n; ++j)
{
sum += a[j];
res += (L <= sum && sum <= R);
}
}
Độ phức tạp cách làm này là không thể thỏa mãn ràng buộc của bài toán.
Subtask 2
Trước tiên xây dựng mảng là tổng tiền tố của mảng từ vị trí tới vị trí . Coi .
Nhận định rằng, một đoạn con sẽ thỏa mãn yêu cầu đề bài nếu như:
Nói cách khác, với mỗi giá trị ta cần đếm số lượng giá trị . Bài toán này ta có thể xử lý bằng cách dùng một Binary Indexed Tree để kiểm soát tổng số lần xuất hiện của các giá trị và .
Tuy nhiên, ta thấy rằng các giá trị có thể lên tới vì vậy nếu dùng BIT lưu trực tiếp các giá trị này thì sẽ bị tràn mảng. Thay vào đó, ta sẽ nén các giá trị và lại.
Sử dụng một mảng để lưu toàn bộ các giá trị rồi sắp xếp mảng này lại và loại bỏ các phần tử trùng nhau. Rồi ứng với mỗi vị trí ta sẽ tìm kiếm nhị phân vị trí của các giá trị và trên mảng dùng map
để gán luôn giá trị nén của các giá trị đó bằng vị trí của chúng.
Giả sử một giá trị được nén lại bằng và hai giá trị được nén lại bằng và . Ta sẽ cập nhật kết quả tăng thêm một lượng bằng tổng của đoạn trên BIT, rồi tăng vị trí lên đơn vị trên BIT. Cần lưu ý, muốn tính tổng đoạn thì phải lấy tổng trừ đi tổng do đó để đơn giản, thay vì lưu giá trị thì ta sẽ lưu giá trị .
Độ phức tạp: vì tối đa có thể có giá trị số nén lại.
Cài đặt
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int maxn = 1e5 + 10;
int sum[maxn];
int bit[3 * maxn];
// Rời rạc hóa các giá trị sum[i], sum[i] - l và sum[i] - r - 1.
void discretizing(int n, int l, int r, vector < int > &s, map < int, int > &compress)
{
// Sắp xếp và xóa giá trị trùng lặp trong mảng s.
sort(s.begin(), s.end());
s.erase(unique(s.begin(), s.end()), s.end());
for (int i = 0; i <= n; ++i)
{
int p0 = lower_bound(s.begin(), s.end(), sum[i]) - s.begin() + 1;
int p1 = lower_bound(s.begin(), s.end(), sum[i] - l) - s.begin() + 1;
int p2 = lower_bound(s.begin(), s.end(), sum[i] - r - 1) - s.begin() + 1;
compress[sum[i]] = p0;
compress[sum[i] - l] = p1;
compress[sum[i] - r - 1] = p2;
}
}
void update(int pos, int max_id)
{
while (pos <= max_id)
{
++bit[pos];
pos += pos & -pos;
}
}
int get_sum(int pos)
{
int ans = 0;
while (pos > 0)
{
ans += bit[pos];
pos -= pos & -pos;
}
return ans;
}
// Đếm số cặp vị trí đặc biệt.
void solution(int n, int l, int r, map < int, int > &compress)
{
int res = 0, max_id = compress.size();
update(compress[0], max_id); // Update trước giá trị sum[0] = 0 lên cây BIT.
for (int i = 1; i <= n; ++i)
{
// Tính tổng số lần xuất hiện của các giá trị [sum[i] - r, sum[i] - l] bằng
// cách truy vấn tổng đoạn đó trên cây BIT. Sau đó update vị trí sum[i] trên
// BIT tăng thêm 1 lần xuất hiện.
res += get_sum(compress[sum[i] - l]) - get_sum(compress[sum[i] - r - 1]);
update(compress[sum[i]], max_id);
}
cout << res;
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int n, l, r;
cin >> n >> l >> r;
vector < int > s;
s.push_back(0); // Đưa trước giá trị sum[0] = 0 vào mảng s.
for (int i = 1; i <= n; ++i)
{
int a;
cin >> a;
sum[i] = sum[i - 1] + a;
// Mảng s lưu toàn bộ các giá trị sum[i], sum[i] - l và sum[i] - r - 1.
s.push_back(sum[i]);
s.push_back(sum[i] - l);
s.push_back(sum[i] - r - 1);
}
map < int, int > compress;
discretizing(n, l, r, s, compress);
solution(n, l, r, compress);
return 0;
}
IV. Tài liệu tham khảo
- https://vnoi.info/wiki/algo/trick/Roi-rac-hoa-va-ung-dung.md.
- Competitive Programming 3 - Steven Halim & Felix Halim.
All rights reserved