Duyệt phân đôi tập hợp (Meet In The Middle)
I. Giới thiệu chung
1. Phát biểu bài toán
Trong các kì thi lập trình, những bài toán liên quan tới duyệt toàn bộ nghiệm không hề hiếm. Đôi khi, chúng ta sẽ gặp phải những bài toán mà bắt buộc phải duyệt toàn bộ các cấu hình nghiệm để tìm ra các nghiệm thỏa mãn yêu cầu đề bài. Cùng xét một bài toán ví dụ sau:
Cho một tập hợp gồm phần tử . Hãy xác định có bao nhiêu tập hợp con của tập có tổng đúng bằng giá trị cho trước?
Input:
- Dòng đầu tiên chứa hai số nguyên dương và .
- Dòng thứ hai chứa số nguyên dương phân tách nhau bởi dấu cách.
Ràng buộc:
- .
- .
- .
Output:
- Một số nguyên là số lượng tập hợp con tìm được.
Sample Input:
5 10
1 9 8 2 1
Sample Output:
4
2. Kĩ thuật Duyệt phân đôi tập hợp
Ý tưởng
Đối với những bài toán như trên, cách làm quen thuộc nhất là duyệt quay lui thử chọn ra tất cả các tập hợp khác nhau và tính tổng của chúng. Mô hình như sau (sử dụng kĩ thuật duyệt nhị phân ):
void backtrack(int i, int subset_sum, vector < int > &a, int s, int &res)
{
for (int j = 0; j <= 1; ++j)
{
subset_sum += a[i] * j;
if (i == n)
res += (subset_sum == s);
else
backtrack(i + 1, subset_sum a, s, res);
subset_sum -= a[i] * j;
}
}
Tuy nhiên, giới hạn của bài toán là đồng nghĩa với việc thuật toán trên sẽ có độ phức tạp lên tới máy tính thông thường khó có thể chạy được trong thời gian cho phép.
Kĩ thuật Duyệt phân đôi tập hợp ra đời để giải quyết những bài toán như vậy. Đây là kĩ thuật sử dụng cho các bài toán có giới hạn nhỏ, tuy nhiên lại không đủ nhỏ để áp dụng kĩ thuật duyệt toàn bộ nghiệm thông thường. Trong kĩ thuật này, chúng ta chia tập hợp ban đầu thành hai phần, duyệt sinh toàn bộ các cấu hình ở mỗi phần rồi kết hợp hai bên lại để tạo thành nghiệm của bài toán. Ý tưởng sẽ như sau:
- Bước : Chia tập hợp ban đầu thành hai tập con: và .
- Bước : Sử dụng quay lui để sinh tất cả các tổng ở mỗi tập con, lưu vào hai mảng là và . Chú ý, ta sẽ lưu cả tổng (không chọn phần tử nào) để phục vụ cho quá trình xử lý tiếp theo.
- Bước : Sắp xếp lại các giá trị trong mảng tăng dần.
- Bước : Duyệt các tổng trên dãy tổng . Ứng với mỗi tổng ta cần xác định có bao nhiêu tổng trên dãy tổng thỏa mãn: . Vì dãy tổng đã sắp xếp tăng dần, nên bước này có thể làm bằng tìm kiếm nhị phân. Ngoài ra ta đã lưu cả cách lựa chọn tập rỗng ở hai dãy, nên sẽ tính được cả những trường hợp từng giá trị của mỗi bên bằng đúng .
Như vậy, độ phức tạp của giải thuật sẽ chỉ còn là hoàn toàn đáp ứng được thời gian chạy của các kì thi lập trình.
Cài đặt
#include <bits/stdc++.h>
#define int long long
using namespace std;
void enter(int &n, int &s, vector < int > &a)
{
cin >> n >> s;
a.resize(n + 1);
for (int i = 1; i <= n; ++i)
cin >> a[i];
}
void generate_set(int i, int limit, int sum, vector < int > &a, vector < int > &subset_sum)
{
for (int j = 0; j <= 1; ++j)
{
sum += a[i] * j;
if (i == limit)
subset_sum.push_back(sum);
else
generate_set(i + 1, limit, sum, a, subset_sum);
sum -= a[i] * j;
}
}
void solution(int n, int s, vector < int > &a)
{
vector < int > sum_left, sum_right;
generate_set(1, (n + 1) / 2, 0, a, sum_left);
generate_set((n + 1) / 2 + 1, n, 0, a, sum_right);
sort(sum_left.begin(), sum_left.end());
int res = 0;
for (int cur_s: sum_right)
{
if (cur_s > s)
continue;
auto p = equal_range(sum_left.begin(), sum_left.end(), s - cur_s);
if (*p.first == s - cur_s)
res += distance(p.first, p.second);
}
cout << res;
}
main()
{
int n, s;
vector < int > a;
enter(n, s, a);
solution(n, s, a);
return 0;
}
II. Một số bài toán minh họa
1. Tổng vector
Đề bài
Trên mặt phẳng tọa độ cho trước vectors. Mỗi một vectors được cho bởi hai chỉ số và . Ta định nghĩa vector tổng của hai vector và là vector . Bây giờ, cần chọn ra một số vectors trong số vectors cho trước, sao cho vector tổng của chúng là vector .
Yêu cầu: Biết trước vector tổng hãy đếm số cách chọn thỏa mãn yêu cầu trên?
Input:
- Dòng đầu tiên chứa số nguyên dương .
- dòng tiếp theo, dòng thứ chứa hai số nguyên thể hiện một vector trên mặt phẳng tọa độ.
- Dòng cuối cùng chứa hai số nguyên - vector tổng.
Ràng buộc:
- .
- .
- .
Output:
- In ra một số nguyên là số lượng cách chọn thỏa mãn.
Sample Input:
4
0 0
-1 2
2 5
3 3
2 5
Sample Output:
4
Ý tưởng
Sử dụng quay lui thông thường trong bài này sẽ không thể đáp ứng thời gian chạy, vì độ phức tạp sẽ lên tới .
Ta sử dụng kĩ thuật duyệt phân đôi tập hợp để giải quyết bài toán này. Chia tập các vector ban đầu thành hai phần, duyệt quay lui để sinh ra tất cả các vector tổng trên mỗi phần. Bước này ta sẽ kết hợp thêm map
để đếm số lần xuất hiện của các vector tổng trên từng phần tạo ra, lưu vào hai map
là và .
Kế đến, duyệt qua tất cả các vector tổng trên và đi tìm xem có bao nhiêu vector bằng trên . Bước này ta làm dễ dàng bằng map
. Kết quả sẽ tăng thêm một lượng là .
Độ phức tạp: .
Cài đặt
#include <bits/stdc++.h>
#define int long long
#define task "vector_sum."
using namespace std;
void enter(int &n, vector < pair < int, int > > &vectors, int &u, int &v)
{
cin >> n;
vectors.resize(n + 1);
for (int i = 1; i <= n; ++i)
cin >> vectors[i].first >> vectors[i].second;
cin >> u >> v;
}
void generate_set(int i, int limit, pair < int, int > vector_sum,
vector < pair < int, int > > &vectors,
map < pair < int, int >, int > &cnt)
{
for (int j = 0; j <= 1; ++j)
{
vector_sum.first += vectors[i].first * j;
vector_sum.second += vectors[i].second * j;
if (i == limit)
++cnt[vector_sum];
else
generate_set(i + 1, limit, vector_sum, vectors, cnt);
vector_sum.first -= vectors[i].first * j;
vector_sum.second -= vectors[i].second * j;
}
}
void solution(int n, vector < pair < int, int > > &vectors, int u, int v)
{
// Duyệt sinh tất cả các vector tổng từ hai nửa tập hợp, đếm phân phối bằng map.
map < pair < int, int >, int > cnt_left, cnt_right;
generate_set(1, (n + 1) / 2, {0, 0}, vectors, cnt_left);
generate_set((n + 1) / 2 + 1, n, {0, 0}, vectors, cnt_right);
int res = 0;
for (auto v1: cnt_right)
{
// Xét vector tổng tạo ra là (x, y) trên tập bên phải, thì vector
// cần tìm trên tập bên trái là (u - x, v - y).
pair < int, int > v2 = {u - v1.first.first, v - v1.first.second};
if (cnt_left.count(v2))
res += (cnt_left[v2] * v1.second);
}
cout << res;
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int n, u, v;
vector < pair < int, int > > vectors;
enter(n, vectors, u, v);
solution(n, vectors, u, v);
return 0;
}
2. Ước số
Đề bài
Một số nguyên dương được phân tích thành thừa số nguyên tố như sau:
Yêu cầu: Cho hai số nguyên không âm đếm số lượng ước của thuộc đoạn
Input:
- Dòng đầu tiên chứa số nguyên dương .
- Tiếp theo là dòng, dòng thứ chứa hai số nguyên dương và thể hiện phân tích nguyên tố của trong đó các giá trị đôi một khác nhau.
- Ba dòng cuối cùng tương ứng với ba câu hỏi, mỗi dòng chứa hai số nguyên không âm tương ứng với một câu hỏi.
Ràng buộc:
- .
- .
- .
Output:
- Trên ba dòng, mỗi dòng ghi một số nguyên là đáp án tương ứng với câu hỏi ở đầu vào.
Sample Input:
3
2 4
3 4
5 4
1 5
1 10
1 5
Sample Output:
5
9
5
Ý tưởng
Đây là một bài không đơn giản, cần sử dụng kĩ thuật duyệt phân đôi tập hợp, kết hợp cùng kĩ thuật nhánh và cận. Về cơ bản, chúng ta vẫn tiến hành chia đôi tập các số nguyên tố ra và tạo tất cả các tích có thể giữa chúng, rồi ghép các tập con của hai bên lại để tạo thành các ước trong đoạn .
Đối với công việc đầu tiên là sinh mọi tập con của tập các số nguyên tố ban đầu, ta sẽ có ba bước:
- Bước 1: Tạo một
vector
: Là lũy thừa lớn nhất của số nguyên tố thứ và không vượt quá giá trị (có chứa cả giá trị ). - Bước 2: Chia tập các số nguyên tố ra làm hai nửa. Áp dụng kĩ thuật duyệt phân đôi tập hợp, sinh ra mọi tập con của hai nửa đó và lưu tích của tập con vào hai tập hợp là và . Kĩ thuật ở đây là với mỗi số nguyên tố ta sẽ lựa chọn lần lượt các lũy thừa của nó: các lũy thừa này đều đã được tính sẵn bằng
vector
mà bước vừa tính. Thêm một lưu ý, là mặc dù số lượng tập con có thể rất lớn, nhưng khoảng giá trị [A, B] thì chỉ là 10^9. Vì thế ta áp dụng thêm kĩ thuật nhánh cận trong quá trình sinh, nếu thấy tích hiện tại đã vượt quá B thì không cần nhân thêm các lũy thừa của các số nguyên tố sau vào nữa. - Bước 3: Sắp xếp lại hai tập hợp lớn và giờ hai tập này chứa các tích sinh ra của hai nửa.
Công việc cuối cùng là ghép các giá trị của hai tập. Ứng với mỗi tích trên tập chặt nhị phân tìm đoạn vị trí trên tập sao cho tích mul nhân với bất kỳ tích nào từ tới đều không bị nằm ngoài đoạn . Khi đó, số lượng ước của trong đoạn sinh ra bởi là: . Chúng ta cũng không cần xét riêng các tích trên từng tập hợp, vì mỗi tập hợp đều chứa một giá trị nên sẽ xét được luôn các tập con thuộc từng tập có là ước của thuộc đoạn hay không.
Cài đặt
#include <bits/stdc++.h>
#define task "divisors."
#define inf 1e9 + 7
#define x first
#define y second
using namespace std;
const int maxm = 26;
int M;
long long product = 1;
pair < int, int > prime[maxm], q[4];
vector < long long > expo[maxm], left_set, right_set;
void enter()
{
cin >> M;
for (int i = 1; i <= M; ++i)
cin >> prime[i].first >> prime[i].second;
for (int i = 1; i <= 3; ++i)
cin >> q[i].first >> q[i].second;
}
void create_exponentiation(int max_value)
{
for (int i = 1; i <= M; ++i)
{
expo[i].push_back(1);
for (int j = 1; j <= prime[i].second && expo[i][j - 1] * prime[i].first <= max_value; ++j)
expo[i].push_back(expo[i][j - 1] * prime[i].first);
}
}
void recursion(int i, int limit, int max_value, vector < long long > ¤t_set)
{
if (i > limit)
return;
if (product * expo[i][0] > max_value)
return;
for (int j = 0; j < (int)expo[i].size(); ++j)
{
product *= expo[i][j];
if (i == limit)
current_set.push_back(product);
else
recursion(i + 1, limit, max_value, current_set);
product /= expo[i][j];
}
}
void solution()
{
// Create all exponentiations p[i]^j such that p[i]^j does not
// exceed the greatest value of B.
int max_value = max(q[1].second, max(q[2].second, q[3].second));
create_exponentiation(max_value);
// Generate two sets of products can be formed by the prime factorization
recursion(1, (M + 1) / 2, max_value, left_set);
recursion((M + 1) / 2 + 1, M, max_value, right_set);
sort(left_set.begin(), left_set.end());
sort(right_set.begin(), right_set.end());
// Separately handle the case that the prime factorization only
// consists of 1 prime.
if (right_set.empty())
right_set.push_back(1);
// Answer the 3 queries.
for (int i = 1; i <= 3; ++i)
{
int res = 0;
// For each generated product in the left set, find the position range [pos_l, pos_r]
// in the right set that can be paired with it to create a product belonging to the
// range [A,B]. The product has a form of A <= x * y <= B.
for (int j = 0; j < (int)left_set.size(); ++j)
{
int mul = left_set[j];
if (mul > max_value)
break;
auto it1 = lower_bound(right_set.begin(), right_set.end(), (q[i].first + mul - 1) / mul);
auto it2 = upper_bound(right_set.begin(), right_set.end(), q[i].second / mul) - 1;
// If at least one position exists, get the length of the segment which can be paired.
if (it1 != right_set.end())
{
int pos_l = it1 - right_set.begin(), pos_r = it2 - right_set.begin();
res += (pos_r - pos_l + 1);
}
}
cout << res << endl;
}
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
enter();
solution();
return 0;
}
III. Tài liệu tham khảo
All rights reserved