I. Bài toán mở đầu
Bài toán chia kẹo Euler là một bài toán tổ hợp xuất hiện từ thời xa xưa, đây là một bài toán rất hay và có nhiều ứng dụng trong Toán học. Xuất phát từ một vấn đề rất đơn giản, nhà bác học Leohard Euler đã phát biểu nó thành một bài toán như sau:
"Có chiếc kẹo giống nhau, cần chia chúng cho em bé. Hỏi có bao nhiêu cách chia kẹo như vậy?"
Bài toán tưởng chừng như đơn giản, nhưng nó lại gây khó khăn cho không ít học sinh. Từ bài toán này, người ta đã phát triển ra cách giải cho vô số bài toán đếm khác nhau. Trong bài viết này, tôi sẽ giới thiệu tới các bạn phương pháp giải bài toán chia kẹo Euler và một số ứng dụng từ cơ bản tới nâng cao của nó. Tất nhiên, nội dung các bài toán sẽ liên quan nhiều tới lập trình, do đó tôi sẽ bỏ qua những bài toán quá khó (mà chỉ dành cho học sinh chuyên Toán).
1. Phương pháp giải bài toán cơ bản
Nếu gọi số kẹo mà mỗi em bé nhận được lần lượt là . Bài toán khi đó sẽ trở thành: Đếm số nghiệm nguyên không âm của phương trình:
Sử dụng kĩ thuật song ánh, coi rằng giữa mỗi em bé có một số và số kẹo của em bé thứ nhận được sẽ biểu diễn bằng một dãy gồm số thì bài toán trở thành đếm số cấu hình có dạng:
<center>Với số và số
</center>Như vậy, thực tế ta đang đếm số cách đặt số vào một dãy gồm vị trí, còn lại sẽ là các số . Theo quy tắc tổ hợp không lặp, số nghiệm của bài toán sẽ là:
Tuy nhiên, khi lập trình các bạn sẽ cần lưu ý cả tới giới hạn dữ liệu của bài toán. Nếu như bài toán yêu cầu đưa ra kết quả là phần dư sau khi chia cho một giá trị nào đó thì cần sử dụng các phương pháp phù hợp như Nghịch đảo modulo, Thuật toán bình phương và nhân hay Phép nhân Ấn Độ tương ứng. Lập trình ở phần này không khó nên tôi sẽ không viết lại code nữa!
2. Nếu các em bé đều phải nhận được ít nhất 1 chiếc kẹo?
Bài toán có thể lắt léo hơn một chút, nếu như đề bài yêu cầu rằng khi chia kẹo, mỗi em bé đều phải được nhận ít nhất chiếc kẹo. Khi đó, bài toán sẽ trở thành: Đếm số nghiệm nguyên dương của phương trình:
Đối với bài toán này, ta giải như sau: Đặt . Khi đó ta có:
Lúc này phương trình xảy ra hai trường hợp kết quả:
- Nếu thì phương trình vô nghiệm.
- Nếu thì bài toán lại trở thành dạng giống với bài toán cơ bản, lúc này số nghiệm của phương trình chính là số bộ giá trị thỏa mãn phương trình đó là:
3. Phát triển bài toán tổng quát
Các bài toán có dạng như hai bài toán nói trên đều có thể phát biểu thành dạng tổng quát như sau: Đếm số nghiệm nguyên của phương trình với .
Lời giải của bài toán này tương tự với bài toán số 2, ta gọi và . Phương trình đã cho sẽ trở thành:
Giờ ta cần xét tới ba trường hợp kết quả:
- Nếu thì phương trình đã cho sẽ vô nghiệm.
- Nếu thì phương trình đã cho có duy nhất một nghiệm là .
- Nếu thì ta cần đếm số bộ giá trị thỏa mãn phương trình đó là:
Bây giờ chúng ta hãy cùng xét một vài bài toán ứng dụng của công thức chia kẹo Euler trong lập trình thi đấu, để hiểu rõ hơn về ứng dụng của công thức này trong các kì thi lập trình!
II. Một số bài toán minh họa
1. Đếm đường đi
Đề bài
Cho một lưới gồm các ô vuông. Các ô được đánh số từ đến theo chiều từ trái sang phải và từ đến theo chiều từ dưới lên trên. Giả sử ta đang ở ô ta chỉ có thể di chuyển trên cạnh các ô vuông theo chiều từ trái sang phải hoặc từ dưới lên trên.
Yêu cầu: Hãy tính số đường đi khác nhau từ ô đến ô của lưới ô vuông?
Input:
- Một dòng duy nhất chứa hai số nguyên và .
Ràng buộc:
- .
- .
Output:
- In ra số dư của kết quả của bài toán sau khi chia cho .
Sample Input:
2 3
Sample Output:
10
Ý tưởng
Nhận xét thấy, một đường đi thỏa mãn gồm bước đi (mỗi bước đi là một cạnh ô vuông). Tại mỗi bước đi chỉ được chọn một trong hai giá trị đi lên (ta đặt là ) hoặc đi sang phải (ta đặt là ). Số bước đi lên đúng bằng và số bước sang phải đúng bằng . Bài toán lúc này dẫn đến việc tìm xem có bao nhiêu dãy nhị phân có độ dài trong đó có đúng thành phần có giá trị bằng .
Dựa trên bài toán chia kẹo Euler, kết quả cần tìm lúc này là .
Ta có thể tính tổ hợp bằng quy hoạch động dựa trên tính chất sau của tổ hợp:
Độ phức tạp: . Các bạn cần kết hợp với việc tính toán modulo liên tục để tránh gây ra tràn số nếu như sử dụng ngôn ngữ C++.
Cài đặt
#include <bits/stdc++.h> using namespace std; const int MOD = 1e9 + 7;
long long ncr[5005][5005], n, m; void pre_compute() { int k; for (int i = 0; i < 5001; i++) { ncr[i][0] = ncr[i][i] = 1; // Chỉ tính đến vị trí cột thứ i / 2 là đủ cho hàng i. k = i >> 1; for (int j = 1; j < k + 1; j++) ncr[i][j] = ncr[i][i - j] = (ncr[i - 1][j] + ncr[i - 1][j - 1]) % MOD; }
} int main() { int m, n; cin >> m >> n; pre_compute(); cout << ncr[m + n][m]; return 0;
}
2. Hợp nhất danh sách
Đề bài
Hôm nay Tí vừa học xong về danh sách liên kết trên trường. Cậu ấy đã được học về cách hợp nhất hai danh sách liên kết. Khi ta hợp nhất hai danh sách liên kết, thứ tự của các phần tử của mỗi danh sách không thay đổi. Ví dụ, nếu ta hợp nhất và , ta sẽ thu được danh sách mới là , tuy nhiên không hợp lệ vì đứng sau .
Yêu cầu: Tí có hai danh sách liên kết gồm và phần tử, hãy giúp cậu ấy tính xem có bao nhiêu cách để hợp nhất cả hai danh sách. Dữ liệu đảm bảo rằng phần tử trong hai danh sách đều phân biệt.
Input:
- Dòng đầu tiên chứa số nguyên chỉ số lượng truy vấn.
- dòng tiếp theo, mỗi dòng chứa một truy vấn gồm hai số nguyên là và .
Ràng buộc:
- .
- .
Output:
- In ra đáp án của bài toán sau khi chia cho và lấy số dư làm kết quả.
Sample Input:
1
2 2
Sample Output:
6
Giải thích:
Giả sử hai danh sách là và , các cách khác nhau để hợp nhất các danh sách này là:
- .
- .
- .
- .
- .
- .
Ý tưởng
Bạn phải hợp nhất hai danh sách gồm và phần tử lại với nhau. Điều này tương đương với việc sắp xếp vật thuộc cùng một loại với vật cùng thuộc loại khác trên cùng một hàng. Đây chính là bài toán chia kẹo Euler!
Tổng số cách để hợp nhất hai danh sách sẽ là . Lí do là vì ta cần chọn ra vị trí trong vị trí để đặt vật thuộc loại thứ vào. Kết quả không có gì thay đổi nếu như bạn chọn nó bằng .
Ta có thể tính tổ hợp bằng quy hoạch động dựa trên tính chất sau (chính là tam giác Pascal, nếu các bạn chưa nhớ về công thức này thì hãy tìm đọc lại trên internet):
Độ phức tạp: . Các bạn nên khởi tạo trước mảng hai chiều lưu tam giác Pascal rồi đưa ra kết quả của mỗi test case trong .
Cài đặt
#include <bits/stdc++.h>
#define int long long using namespace std; const int MOD = 1e9 + 7; void pre_compute(vector < vector < int > > &ncr, int max_size)
{ ncr = vector < vector < int > >(max_size + 1, vector < int >(max_size + 1)); for (int i = 0; i <= max_size; ++i) { ncr[i][0] = ncr[i][i] = 1; for (int j = 1; j < i; ++j) ncr[i][j] = (ncr[i - 1][j] + ncr[i - 1][j - 1]) % MOD; }
} main()
{ vector < vector < int > > ncr; pre_compute(ncr, 200); int t; cin >> t; while (t--) { int n, m; cin >> n >> m; cout << c[n + m][m] << endl; } return 0;
}