I. Cấu trúc dữ liệu deque
- Hàng đợi hai đầu
1. Giới thiệu chung
Khi học lập trình C++, có lẽ chúng ta đều đã biết đến hai container stack và queue - hai cấu trúc dữ liệu ngăn xếp và hàng đợi được xây dựng sẵn trong thư viện STL C++. Nếu tưởng tượng cả hàng đợi và ngăn xếp được biểu diễn bằng mảng, thì hàng đợi sẽ hỗ trợ chúng ta lấy phần tử ra từ đầu mảng và thêm phần tử vào cuối mảng, còn ngăn xếp hỗ trợ chúng ta thêm và lấy ra phần tử từ cuối mảng.
Để khắc phục nhược điểm của stack và queue, người ta nghĩ ra một cấu trúc dữ liệu mới là hàng đợi hai đầu (deque). Hàng đợi hai đầu là sự kết hợp giữa stack và queue, hỗ trợ tất cả các thao tác thêm, xóa phần tử từ hai đầu hàng đợi với độ phức tạp vẫn là . Sử dụng cấu trúc dữ liệu này sẽ thay thế được cả stack và queue, đồng thời lại rất dễ tưởng tượng hình vẽ.
2. Sử dụng hàng đợi hai đầu trong C++
Hàng đợi hai đầu đã được xây dựng sẵn trong STL C++, nên việc cài đặt lại là không cần thiết. Để sử dụng, trước tiên ta khai báo header queue cùng với không gian tên chuẩn (đối với các ngôn ngữ khác sẽ có các thư viện tương ứng):
#include <queue>
using namespace std;
Sau đó, khai báo theo cú pháp:
deque <{Kiểu_phần_tử}> {Tên_hàng_đợi};
Ví dụ: Khai báo một hàng đợi hai đầu chứa số nguyên:
deque < int > qu;
Bảng dưới đây sẽ cung cấp các phương thức được hỗ trợ của container deque:
Tên phương thức | Tác dụng | Độ phức tạp |
---|---|---|
push_front(x) |
Thêm phần tử vào đầu hàng đợi | |
pop_front() |
Xóa một phần tử ở đầu hàng đợi | |
push_back(x) |
Thêm phần tử vào cuối hàng đợi | |
pop_back() |
Xóa một phần tử ở cuối hàng đợi | |
size() |
Trả về kích thước hiện tại của hàng đợi | |
empty() |
Trả về true nếu hàng đợi rỗng, ngược lại trả về false |
|
front() |
Trả về phần tử ở đầu hàng đợi | |
back() |
Trả về phần tử ở cuối hàng đợi |
II. Kĩ thuật Tìm min - max trên đoạn tịnh tiến
Trong nhiều bài toán, các bạn sẽ cần phải tìm giá trị lớn nhất - nhỏ nhất trên một đoạn liên tục. Có nhiều phương pháp để làm việc này, chẳng hạn như sử dụng các cấu trúc dữ liệu Cây phân đoạn (Segment Tree) hay ***Cây chỉ số nhị phân (Binary Indexed Tree)***,...Tuy nhiên, nếu như đoạn cần kiểm tra là một đoạn dịch chuyển liên tục, thì kĩ thuật Deque Tìm min - max trên đoạn tịnh tiến thường được ưu tiên sử dụng vì sự đơn giản và độ phức tạp nhỏ.
Cấu trúc dữ liệu hàng đợi hai đầu sẽ được sử dụng trong kĩ thuật này. Tư tưởng của phương pháp dựa trên kĩ thuật Stack đơn điệu, chỉ lưu trữ các phần tử trong hàng đợi theo thứ tự tăng hoặc giảm, sao cho phần tử ở đầu hàng đợi luôn luôn là min hoặc max của đoạn đang xét. Lược đồ của phương pháp có thể mô tả như sau:
{Xét_đoạn_[l, r], định_thêm_r_vào_đoạn_đang_xét}
{
// Lọc lại hàng đợi từ đầu.
// Loại bỏ toàn bộ các phần tử ở đầu hàng đợi không thuộc đoạn [l, r].
while (!deque.empty() && deque.front() not in [l, r])
deque.pop_front();
// Lọc lại hàng đợi từ cuối.
// Hàm comp(): Kiểm tra việc thêm r vào cuối hàng đợi có phá vỡ trật tự hay không.
// Nếu có thì xóa các phần tử ở cuối hàng đợi tới khi không vi phạm nữa.
while (!deque.empty() && !comp(deque.back(), r))
deque.pop_back();
// Thêm phần tử r vào cuối hàng đợi.
deque.push_back(r);
}
Bây giờ, mình sẽ phân tích một số ví dụ cụ thể để giúp các bạn hiểu rõ hơn về kĩ thuật và cách áp dụng nó trong các bài toán!
Bài toán 1
Đề bài
Cho một mảng gồm số nguyên cùng với một số nguyên .
Yêu cầu: Hãy tìm giá trị lớn nhất trong các đoạn liên tiếp:
- .
- .
- .
Input:
- Dòng đầu tiên chứa hai số nguyên dương và .
- Dòng thứ hai chứa các số nguyên .
Output:
- In ra số nguyên là giá trị lớn nhất của từng đoạn số
Sample Input
4 2
3 2 4 1
Sample Output
3 4 4
Ý tưởng
Cách làm đơn giản của bài toán này có độ phức tạp ta sẽ xét mọi đoạn con độ dài của dãy số và tìm ra giá trị lớn nhất của các đoạn con đó:
for (int i = k; i <= n; ++i)
{
int max_range = a[i];
for (int j = i - 1; j >= i - k + 1; --j)
max_range = max(max_range, a[j]);
cout << max_range << ' ';
}
Tất nhiên phương pháp này không thể giải quyết bài toán, ta cần một cách làm tốt hơn. Nhận xét thấy, khi xét một đoạn thì giá trị lớn nhất của đoạn đó sẽ chỉ phụ thuộc vào những ô có giá trị giảm ngặt và vị trí tăng dần trong đoạn (các ô màu đỏ):
Nhận xét trên cho ta một ý tưởng về việc xây dựng một hàng đợi hai đầu chỉ lưu các ô có vị trí tăng dần và giá trị giảm dần trong đoạn đang xét. Nhờ thế, thông tin về phần tử lớn nhất trong đoạn sẽ được lưu ở vị trí front() của hàng đợi. Vậy ta thực hiện theo bốn bước như sau:
- Đầu tiên khởi tạo một hàng đợi rỗng. Hàng đợi này dùng để lưu vị trí của các phần tử trong đoạn .
- Khi chuẩn bị đẩy một phần tử vào cuối hàng đợi, cần loại bỏ tất cả các phần tử ở cuối hàng đợi mà có giá trị nhỏ hơn hoặc bằng (để đảm bảo tính giảm ngặt về giá trị của các phần tử trong hàng đợi).
- Loại bỏ toàn bộ các phần tử ở đầu hàng đợi có vị trí nhỏ hơn vì ta đang xét đoạn . Thực tế, do việc tịnh tiến đoạn chỉ dịch về phía sau một vị trí, nên chỉ có tối đa một phần tử ở đầu hàng đợi bị loại đi.
- Cuối cùng, phần tử ở đầu hàng đợi sẽ là vị trí của phần tử có giá trị lớn nhất trong đoạn. Lưu ý rằng, ta nên lưu trữ vị trí của các phần tử thay vì giá trị của chúng, như vậy sẽ dễ dàng hơn trong việc quản lý đoạn đang xét.
Lấy ví dụ với mảng ở hình vẽ bên trên, ta sẽ có quy trình lọc hàng đợi như sau (bạn đọc tự vẽ hình dựa theo mô tả để tưởng tượng):
- Ban đầu khởi tạo hàng đợi rỗng. Đưa vị trí và vào hàng đợi.
- Xét vị trí ta có vì vậy loại bỏ phần tử trong deque đi rồi mới đưa vào.
- Đưa vị trí vào hàng đợi.
- Xét vị trí phần tử ở đầu hàng đợi sẽ bị loại đi do đoạn đang xét lúc này đã là vị trí không còn thuộc đoạn nữa. Ngoài ra, do lớn hơn tất cả các phần tử trong hàng đợi hiện tại, nên toàn bộ hàng đợi sẽ bị loại đi từ cuối, rồi mới đưa vị trí vào.
- Cứ tiếp tục thực hiện thuật toán như vậy mỗi khi xét tới một giá trị mới. Nếu như thì đoạn đang xét đã có đủ phần tử, lúc này in ra giá trị của phần tử ở đầu hàng đợi.
Cài đặt
#include <bits/stdc++.h>
using namespace std;
int main()
{
ios_base:sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
cin >> n >> k;
int a[n + 1];
for (int i = 1; i <= n; ++i)
cin >> a[i];
deque < int > qu_max;
for (int i = 1; i <= n; ++i)
{
// Lọc các phần tử không thuộc đoạn [i - k + 1, i].
while (!qu_max.empty() && a[qu_max.back()] <= a[i])
qu_max.pop_back();
// Lọc lại từ cuối deque để đảm bảo tính giảm ngặt.
while (!qu_max.empty() && qu_max.front() < i - k + 1)
qu_max.pop_front();
qu_max.push_back(i);
if (i >= k)
cout << a[qu_max.front()] << ' ';
}
return 0;
}
Đánh giá
Độ phức tạp thuật toán: do mỗi phần tử chỉ vào và ra khỏi deque không quá lần.
Chi phí lưu trữ: .
Bài toán 2
Đề bài
Cho mảng gồm số nguyên .
Yêu cầu: Ứng với mỗi hãy tìm đoạn dài nhất thỏa mãn:
- .
- là giá trị nhỏ nhất trong đoạn .
Input
- Dòng đầu tiên chứa số nguyên dương .
- Dòng thứ hai chứa số nguyên .
Output:
- Ứng với mỗi đưa ra cặp chỉ số tương ứng trên một dòng.
Sample Input
6
1 3 5 8 4 2
Sample Output
1 6
2 5
3 4
4 4
3 5
2 6
Ý tưởng
Xây dựng hai mảng sao cho ứng với mỗi thì:
Cách làm đơn giản nhất ta có thể nghĩ ra là xét từng phần tử i và mở rộng phạm vi của và về hai bên. Cách làm này độ phức tạp cài đặt như sau:
for (int i = 1; i <= n; ++i)
{
l[i] = r[i] = i;
while (l[i] > 0 && a[l[i] - 1] >= a[i])
--l[i];
while (r[i] < n && a[r[i] + 1] >= a[i])
++r[i];
}
Với giới hạn chắc chắn cách làm này không thể thỏa mãn yêu cầu đề bài. Ta cần một cách làm tốt hơn. Từ ý tưởng trên, các bạn có thể rút ra nhận xét như sau:
-
hoặc là số lớn nhất sao cho:
-
hoặc là số nhỏ nhất sao cho:
Từ hai nhận xét và ta sẽ cải tiến giải thuật bằng cách xây dựng hàng đợi hai đầu như sau: Trong khi duyệt các phần tử ta sẽ đưa vị trí vào cuối deque, tuy nhiên trước đó cần phải loại bỏ tất cả các vị trí ở cuối deque mà . Tức là, ta sẽ có một deque gồm các giá trị vị trí sao cho các phần tử tương ứng ở vị trí đó trên mảng luôn luôn là tăng dần, đồng nghĩa với việc, giá trị ở front() của deque chính là giá trị thỏa mãn: .
Lấy ví dụ, với mảng việc lọc deque sẽ diễn ra như sau:
Bước lọc lại hàng đợi có thể mô tả như sau:
for (int i = 1; i <= n; ++i)
{
while (!deque.empty() && a[deque.back()] >= a[i])
deque.pop_back();
}
Mỗi khi lọc xong hàng đợi với một vị trí ta có thể khẳng định hoặc bằng . Thật vậy, do nhận xét phía trên, ta thấy do đó trong quá trình lọc lại deque, trước khi đưa vào cuối deque thì giá trị sẽ không bị loại ra (vì ta chỉ loại các giá trị mà ). Vậy sau khi lọc xong deque, nó chỉ có thể ở một trong hai dạng: hoặc . Nếu ở dạng thì kết luận ngược lại thì chính là giá trị nhỏ nhất trong đoạn khi đó .
Cuối cùng, đưa vào cuối hàng đợi theo đúng quy trình. Để xây dựng mảng các bạn sẽ làm theo phương pháp tương tự, tuy nhiên cần duyệt các giá trị ngược từ về .
Cài đặt:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5 + 10;
int n, a[maxn], l[maxn], r[maxn];
void enter()
{
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; ++i)
cin >> a[i];
}
void solve()
{
deque < int > qu_min;
for (int i = 1; i <= n; ++i)
{
while (!qu_min.empty() && a[qu_min.back()] >= a[i])
qu_min.pop_back();
l[i] = (qu_min.empty()) ? 1 : qu_min.back() + 1;
quMin.push_back(i);
}
qu_min = deque < int > (); // reset lại deque về rỗng.
for (int i = n; i >= 1; --i)
{
while (!qu_min.empty() && a[qu_min.back()] >= a[i])
quMin.pop_back();
r[i] = (qu_min.empty()) ? n : qu_min.back() - 1;
quMin.push_back(i);
}
// In ra l[i], r[i] ứng với mỗi a[i].
for (int i = 1; i <= n; ++i)
cout << l[i] << ' ' << r[i] << endl;
}
int main()
{
enter();
solve();
return 0;
}
Đánh giá
Độ phức tạp tính toán: Do mỗi phần tử sẽ được đẩy vào hàng đợi một lần và lấy ra khỏi hàng đợi cũng không quá một lần, nên độ phức tạp của quá trình lọc hàng đợi chỉ là . Độ phức tạp tính toán tổng quát là .
Chi phí lưu trữ: .
Bài toán 3
Đề bài
Cho trước danh sách gồm quả bóng, mỗi quả bóng có số điểm là và được cấp lượt chơi bắn bóng. Ở mỗi lượt chơi, người chơi được quyền chọn một quả bóng để bắn. Gọi là số hiệu của quả bóng bị bắn ở lượt thứ thì số điểm người chơi nhận được ở lượt đó sẽ là . Tuy nhiên, trò chơi có một quy tắc lựa chọn bong bóng để bắn như sau: Ở lượt chơi thứ thì .
Yêu cầu: Hãy tính tổng số điểm lớn nhất mà người chơi có thể giành được sau lượt chơi?
Input
- Dòng đầu tiên chứa hai số nguyên dương và – số quả bóng, hằng số và số lượt chơi người chơi được cung cấp.
- Dòng thứ hai chứa số nguyên dương – số điểm được ghi trên các quả bóng.
Output
- Một số nguyên duy nhất là tổng số điểm lớn nhất người chơi giành được.
Sample Input
7 1 3
1 9 2 4 5 3
Sample Output
32
Ý tưởng
Đây là một bài toán sử dụng quy hoạch động. Đặt là số điểm lớn nhất dành được khi chơi lượt, chọn quả bóng thứ để bắn ở lượt thứ . Theo đề bài, do lượt thứ đã chọn quả bóng thứ nên lượt thứ chỉ được phép chọn các quả bóng trong đoạn . Từ đây dễ dàng hình thành công thức truy hồi:
Nếu như dùng hai vòng lặp để tính các thì độ phức tạp thuật toán sẽ lên tới không thể đáp ứng được yêu cầu thời gian. Ta cải tiến như sau: Nhận thấy luôn được cập nhật từ những đoạn tịnh tiến trên sử dụng kĩ thuật deque min - max trên đoạn tịnh tiến để tìm ra các lớn nhất với rồi cập nhật cho .
Về cơ sở quy hoạch động, ta khởi tạo trước thể hiện rằng không tồn tại và các bài toán được cập nhật từ cũng sẽ là các bài toán không tồn tại cách chơi.
Cài đặt
#include <bits/stdc++.h>
#define int long long
#define inf 1e15
using namespace std;
const int maxn = 2e5 + 10, maxk = 201;
int n, m, k, a[maxn], dp[maxk][maxn];
void enter()
{
cin >> n >> m >> k;
for (int i = 1; i <= n; ++i)
cin >> a[i];
}
void solution()
{
dp[1][0] = -inf;
for (int j = 1; j <= n; ++j) // Cơ sở quy hoạch động dp[1][j] = a[j];
dp[1][j] = a[j];
for (int i = 2; i <= k; ++i)
{
dp[i][0] = -inf;
deque < int > qu_max;
for (int j = 1; j <= n; ++j)
{
while (!qu_max.empty() && qu_max.front() < j - m)
qu_max.pop_front();
while (!qu_max.empty() && dp[i - 1][qu_max.back()] <= dp[i - 1][j - 1])
qu_max.pop_back();
qu_max.push_back(j - 1);
dp[i][j] = dp[i - 1][qu_max.front()] + a[j] * i;
}
}
int res = *max_element(dp[K], dp[K] + 1 + N);
cout << res;
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
enter();
solution();
return 0;
}
Đánh giá
Độ phức tạp thuật toán: .
Chi phí lưu trữ: .
III. Tổng kết
Như vậy, mình đã hướng dẫn các bạn toàn bộ lý thuyết cũng như các bài tập tiêu biểu của kĩ thuật Deque tìm Min - Max trên đoạn tịnh tiến. Ưu điểm của kĩ thuật này là cài đặt dễ, tốc độ thực hiện nhanh và có thể áp dụng vào cải tiến tốc độ của rất nhiều bài toán khác nhau, đặc biệt là các bài toán quy hoạch động có công thức truy hồi liên quan tới các đoạn tịnh tiến.
Tuy nhiên, kĩ thuật này cũng có một nhược điểm, đó là không thể áp dụng với các bài toán mà dãy số bị cập nhật liên tục (nói cách khác, kĩ thuật chỉ sử dụng cho các bài toán xử lý offline). Lí do là vì, giả sử có thao tác cập nhật dãy số, thì mỗi lần cập nhật ta lại phải tính lại của từng đoạn, dẫn đến độ phức tạp trở thành . Trong trường hợp này, các bạn cần áp dụng cấu trúc dữ liệu Cây phân đoạn (Segment Tree).
Ngoài ra, có một lỗi nho nhỏ mà các bạn có thể mắc phải khi cài đặt kĩ thuật, đó là không kiểm tra hàng đợi có rỗng hay không (empty). Nếu hàng đợi rỗng mà vẫn lấy các vị trí front() hoặc back() thì sẽ bị lỗi. Hãy lưu ý điều này khi lập trình!
Các bạn có thể luyện tập thêm kĩ thuật này thông qua một số bài tập dưới đây: