Perfect Sum Problem (GFG perfect-sum)
Đề bài
Perfect Sum Problem – Bài toán tổng hoàn hảo (GeeksforGeeks, Medium)
Cho một mảng arr[] gồm các số nguyên không âm và một số nguyên sum, nhiệm vụ là đếm tất cả các tập con của mảng đã cho có tổng bằng sum cho trước.
Lưu ý: Đáp án có thể rất lớn, nên hãy in ra đáp án chia lấy dư cho $10^9 + 7$.
Ví dụ 1:
Input: N = 6, arr[] = {2, 3, 5, 6, 8, 10}
sum = 10
Output: 3
Giải thích: {2, 3, 5}, {2, 8}, {10}
Ví dụ 2:
Input: N = 5, arr[] = {1, 2, 3, 4, 5}
sum = 10
Output: 3
Giải thích: {1, 2, 3, 4}, {1, 4, 5}, {2, 3, 5}
Nhiệm vụ của bạn: Bạn không cần đọc dữ liệu vào hay in gì ra. Hãy hoàn thiện hàm perfectSum() nhận N, mảng arr[] và sum làm tham số đầu vào và trả về một giá trị số nguyên.
Độ phức tạp thời gian mong muốn: $O(N \cdot sum)$.
Bộ nhớ phụ mong muốn: $O(N \cdot sum)$.
Ràng buộc:
- $1 \le N \cdot sum \le 10^6$
- $0 \le arr[i] \le 10^6$
Perfect Sum Problem (GeeksforGeeks, Medium)
Given an array arr[] of non-negative integers and an integer sum, the task is to count all subsets of the given array with a sum equal to a given sum.
Note: Answer can be very large, so, output answer modulo $10^9 + 7$.
Example 1:
Input: N = 6, arr[] = {2, 3, 5, 6, 8, 10}
sum = 10
Output: 3
Explanation: {2, 3, 5}, {2, 8}, {10}
Example 2:
Input: N = 5, arr[] = {1, 2, 3, 4, 5}
sum = 10
Output: 3
Explanation: {1, 2, 3, 4}, {1, 4, 5},
{2, 3, 5}
Your Task: You don't need to read input or print anything. Complete the function perfectSum() which takes N, array arr[] and sum as input parameters and returns an integer value.
Expected Time Complexity: $O(N \cdot sum)$
Expected Auxiliary Space: $O(N \cdot sum)$
Constraints:
- $1 \le N \cdot sum \le 10^6$
- $0 \le arr[I] \le 10^6$
Để chạy thử trên máy (và trong các khung chạy tay bên dưới), mình dùng định dạng nhập: dòng đầu $N$ và $sum$, dòng sau $N$ số của mảng. Ví dụ 1 sẽ là:
6 10
2 3 5 6 8 103Lời mở đầu
Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇. Bài này chính là bài mở rộng từ bài nuôi bò 1 mình đã giải trước đó, nên anh em nào chưa quen cách quay lui (vét cạn bằng đệ quy) thì nên đọc qua bài đó trước nha.
perfectSum() mà bài cho thôi: không cần đọc đầu vào, đầu vào đã có sẵn trong tham số, và hàm phải trả về kết quả. Code bên dưới mình có thêm hàm main chỉ để các bạn chạy thử trên máy, khi nộp thì bỏ phần đó đi. GFG thỉnh thoảng đổi khung code (ví dụ tham số thành vector<int>& arr, target), gặp vậy thì các bạn sửa phần khai báo hàm cho khớp, còn ruột thuật toán giữ nguyên.Về giới hạn thời gian: bài này không ghi thời gian chạy giới hạn, nhưng có đưa ra độ phức tạp mong muốn là $O(N \cdot sum)$ mà $N \cdot sum \le 10^6$, nên anh em cứ cố code bài này chạy dưới 1 giây nha. Nhắc lại: với 1 giây thì toàn bài chỉ nên lặp cỡ $(3 \text{ đến } 5) \cdot 10^7$ lần là an toàn (với C/C++, các ngôn ngữ chậm hơn như Python thì ngưỡng còn thấp hơn).
Tóm tắt lại đề cho dễ hiểu: cho số nguyên dương $N$, $sum$ và mảng arr gồm $N$ số nguyên không âm ($0 \le arr[i] \le 10^6$), biết $1 \le N \cdot sum \le 10^6$. Hãy đếm số tập con của mảng có tổng bằng $sum$ (bản thân cả mảng cũng là một tập con của chính nó), trả về kết quả chia dư cho $10^9 + 7$.
Với ví dụ 1: $N = 6$, $sum = 10$, arr = {2, 3, 5, 6, 8, 10}. Có 3 tập con có tổng bằng 10 là $\{2, 3, 5\}$, $\{2, 8\}$, $\{10\}$ ⇒ kết quả là 3.
arr = {0, 0, 5}, $sum = 5$ thì có tới 4 tập con: $\{5\}$, $\{0_1, 5\}$, $\{0_2, 5\}$, $\{0_1, 0_2, 5\}$. Tất cả các cách bên dưới đều xử lý đúng chuyện này.Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.
Nộp bài trên CLUE OJLộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Quay lui: chọn hay không chọn từng phần tử – O(2^N)
Cách dễ nhất chính là vét cạn, thử mọi trường hợp có thể. Ở bài nuôi bò 1, hàm Try() đi thử chọn từng phần tử rồi cộng vào một biến lưu tổng của tập con, sau đó đem so với tổng cần tạo. Ở đây mình viết một hàm Try() có trả về theo hướng ngược lại: tổng cần tạo ra là $sum$, vậy mỗi khi chọn một phần tử thì ta đem nó trừ vào biến $sum$; đến cuối mà $sum = 0$ thì chứng tỏ ta đã có 1 tập con có tổng bằng $sum$ ban đầu.
- Hàm
Try(i, sum, arr, n)trả về số tập con lấy từ các phần tử ở vị trí $i$ tới $n - 1$ có tổng bằngsum(mảng lưu từ $0$ tới $n - 1$).arrvànlà 2 biến đi kèm để mang theo mảng và số phần tử: vì ta đang viết hàm cho bài nên không khai báo chúng là biến toàn cục được, phải truyền vào. - Điều kiện dừng: khi
i == nlà đã xét hết các phần tử. Nếusum == 0thì tập con vừa lựa có tổng đúng bằng yêu cầu ⇒return 1; ngược lạireturn 0. - Nếu
arr[i] > sum: không thể chọnarr[i]được, nên kết quả chính là số tập con trong khoảng $i + 1$ tới $n - 1$ có tổng bằngsum:return Try(i + 1, sum, arr, n). - Ngược lại
arr[i] <= sum: ta có thể chọn hoặc không chọnarr[i]. Nếu chọn thì phần còn lại chỉ cần có tổngsum - arr[i]; nếu không chọn thì phần còn lại vẫn cần tổngsum. Cộng 2 trường hợp:return Try(i + 1, sum - arr[i], arr, n) + Try(i + 1, sum, arr, n).
int (tối đa $\approx 2.147 \cdot 10^9$).Hàm perfectSum chỉ việc gọi Try(0, sum, arr, n), vì ta cần số tập con có tổng bằng sum trong khoảng từ $0$ tới $n - 1$. Source code cho anh em nào cần tham khảo ý tưởng trên:
#include <bits/stdc++.h>
using namespace std;
const int mod = (int)1e9 + 7;
int Try(int i, int sum, int arr[], int n){
if(i == n){
if(sum == 0){
return 1;
}
return 0;
}
if(arr[i] > sum){
return Try(i + 1, sum, arr, n) % mod;
}
return (Try(i + 1, sum - arr[i], arr, n) + Try(i + 1, sum, arr, n)) % mod;
}
class Solution{
public:
int perfectSum(int arr[], int n, int sum){
return Try(0, sum, arr, n);
}
};
// Phần main chỉ để chạy thử trên máy, nộp GFG thì chỉ cần phần ở trên
int main(){
int n, sum;
cin >> n >> sum;
int arr[n];
for(int i = 0; i < n; ++i){
cin >> arr[i];
}
Solution ob;
cout << ob.perfectSum(arr, n, sum);
return 0;
}
import sys
mod = 10**9 + 7
def Try(i, s, arr, n):
if i == n:
if s == 0:
return 1
return 0
if arr[i] > s:
return Try(i + 1, s, arr, n) % mod
return (Try(i + 1, s - arr[i], arr, n) + Try(i + 1, s, arr, n)) % mod
class Solution:
def perfectSum(self, arr, n, s):
return Try(0, s, arr, n)
# Phần dưới chỉ để chạy thử trên máy
if __name__ == '__main__':
sys.setrecursionlimit(10000)
data = sys.stdin.read().split()
n, s = int(data[0]), int(data[1])
arr = list(map(int, data[2:2 + n]))
print(Solution().perfectSum(arr, n, s))
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N)$, vì ta chỉ dùng mỗi mảng
arr(cộng độ sâu đệ quy tối đa $N$ tầng). - Thời gian: $O(2^N)$, vì ở mỗi phần tử ta đều có 2 lựa chọn, $N$ phần tử thì có tới $2^N$ lựa chọn.
Cách 2Đệ quy có nhớ (QHĐ top-down) – O(N·sum)
Giờ ta cải tiến cách trên. Ta nhận ra bài toán có rất nhiều bài toán con trùng lặp: ví dụ một lần ta đã tính ra Try(1, 1, arr, n), lần sau gặp lại Try(1, 1, arr, n) ta vẫn đi tính lại, rất tốn thời gian. Và mỗi lần gọi Try(i, sum, arr, n) thì arr, n không đổi, chỉ có i và sum thay đổi.
Nên ta tạo một mảng 2 chiều F[n][sum + 1] lưu kết quả các lần gọi Try(i, sum, arr, n): $i$ nằm trong khoảng $0$ tới $n - 1$ nên kích thước là [n]; còn trong quá trình đệ quy biến sum chỉ giảm dần, luôn nằm trong khoảng $0$ tới $sum$ ban đầu, nên [sum + 1] là đủ.
- Lúc đầu lấp đầy
Fbằng $-1$. NếuF[i][sum] != -1thìTry(i, sum, arr, n)đã tính rồi, chỉ việcreturn F[i][sum]. - Ngược lại thì gọi đệ quy để tính, nhớ gán vào
F[i][sum]rồi hẵng return, để lần sau gặp lại thì trả về luôn mà không phải tính.
#include <bits/stdc++.h>
using namespace std;
const int mod = (int)1e9 + 7;
int Try(int i, int sum, int arr[], int n, vector<vector<int>> &f){
if(i == n){
if(sum == 0){
return 1;
}
return 0;
}
if(f[i][sum] != -1){
return f[i][sum];
}
if(arr[i] > sum){
return f[i][sum] = Try(i + 1, sum, arr, n, f) % mod;
}
return f[i][sum] = (Try(i + 1, sum - arr[i], arr, n, f) + Try(i + 1, sum, arr, n, f)) % mod;
}
class Solution{
public:
int perfectSum(int arr[], int n, int sum){
vector<vector<int>> f(n, vector<int>(sum + 1, -1));
return Try(0, sum, arr, n, f);
}
};
// Phần main chỉ để chạy thử trên máy, nộp GFG thì chỉ cần phần ở trên
int main(){
int n, sum;
cin >> n >> sum;
int arr[n];
for(int i = 0; i < n; ++i){
cin >> arr[i];
}
Solution ob;
cout << ob.perfectSum(arr, n, sum);
return 0;
}
import sys
import threading
mod = 10**9 + 7
def Try(i, s, arr, n, f):
if i == n:
if s == 0:
return 1
return 0
if f[i][s] != -1:
return f[i][s]
if arr[i] > s:
f[i][s] = Try(i + 1, s, arr, n, f) % mod
return f[i][s]
f[i][s] = (Try(i + 1, s - arr[i], arr, n, f) + Try(i + 1, s, arr, n, f)) % mod
return f[i][s]
class Solution:
def perfectSum(self, arr, n, s):
f = [[-1] * (s + 1) for _ in range(n)]
return Try(0, s, arr, n, f)
# Phần dưới chỉ để chạy thử trên máy (đệ quy sâu tới n tầng nên nới giới hạn)
def main():
data = sys.stdin.read().split()
n, s = int(data[0]), int(data[1])
arr = list(map(int, data[2:2 + n]))
print(Solution().perfectSum(arr, n, s))
if __name__ == '__main__':
sys.setrecursionlimit(1 << 21)
threading.stack_size(1 << 29)
t = threading.Thread(target=main)
t.start()
t.join()
Giải thích một số chỗ trong code:
vector<vector<int>> f(n, vector<int>(sum + 1, -1));mình dùng vector cho tiện truyền vào hàm, và gán mọi phần tử bằng $-1$ cũng rất dễ. Anh em cứ xem nó như mảng 2 chiều nha.vector<vector<int>> &fở phần khai báo hàm: nhớ thêm tham chiếu&. Thường mình khai báoflà mảng toàn cục, nhưng ở đây ta đang viết hàm và $N \cdot sum$ có thể tới $10^6$, nên khai báo trong hàm rồi truyền tham chiếu, vừa cập nhật đượcfnhư biến toàn cục, vừa không phải copy lại cả mảng mỗi lần gọi.return f[i][sum] = …;nghĩa là gánf[i][sum] = …rồi mới returnf[i][sum].
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N \cdot sum)$ cho vector
f(thêm mảngarrnữa nhưng theo quy tắc Big-O vẫn là $O(N \cdot sum)$). - Thời gian: $O(N \cdot sum)$, vì trường hợp xấu nhất ta chỉ lấp đầy toàn bộ mảng
F, mỗi ô tính đúng một lần với chi phí $O(1)$.
Vậy với cải tiến từ vét cạn thành đệ quy có nhớ, ta đã AC (Accepted) bài này rồi 🎉.
Cách 3QHĐ bottom-up, rồi rút gọn còn mảng 1 chiều – O(N·sum)
Ngoài cách trên, ta còn có thể dùng quy hoạch động bottom-up, tức là xây công thức truy hồi rồi lấp bảng bằng vòng lặp. Cho anh em đã biết cơ bản về quy hoạch động thì ta có 4 bước:
Bước 1 – Bài toán con: gọi F[i][j] ($0 \le i \le n$, $0 \le j \le sum$) là số tập con có tổng bằng $j$ khi xét $i$ phần tử đầu tiên. Lưu ý là xét $i$ phần tử đầu tiên nha: $i = 1$ tức là đang xét phần tử arr[0].
Bước 2 – Bài toán cơ sở: nếu không xét phần tử nào ($i = 0$) thì ta chỉ có 1 tập rỗng, mà tập rỗng thì có tổng bằng $0$ ⇒ F[0][0] = 1, còn F[0][1] tới F[0][sum] đều bằng $0$.
Bước 3 – Đáp án: hiển nhiên là F[n][sum], số tập con có tổng bằng $sum$ khi xét cả $n$ phần tử.
Bước 4 – Công thức truy hồi: khi xét phần tử thứ $i$ (trong mảng là arr[i - 1] vì mảng lưu từ $0$) với tổng cần là $j$:
arr[i - 1] > j: không thể chọn phần tử thứ $i$, nên số tập con giống hệt khi xét $i - 1$ phần tử:F[i][j] = F[i - 1][j].arr[i - 1] <= j: có thể không lấy (giống trên,F[i - 1][j]tập) hoặc có lấy phần tử thứ $i$. Nếu lấy thì $i - 1$ phần tử trước đó cần có tổng $j - arr[i - 1]$, cộng thêmarr[i - 1]mới bằng $j$ ⇒F[i - 1][j - arr[i - 1]]tập. VậyF[i][j] = F[i - 1][j] + F[i - 1][j - arr[i - 1]].
Nếu $arr[i - 1] \le j$: $F[i][j] = F[i - 1][j] + F[i - 1][j - arr[i - 1]]$ (nhớ chia dư cho $10^9 + 7$).
#include <bits/stdc++.h>
using namespace std;
const int mod = (int)1e9 + 7;
class Solution{
public:
int perfectSum(int arr[], int n, int sum){
vector<vector<int>> f(n + 1, vector<int>(sum + 1));
f[0][0] = 1;
for(int j = 1; j <= sum; ++j){
f[0][j] = 0;
}
for(int i = 1; i <= n; ++i){
for(int j = 0; j <= sum; ++j){
if(arr[i - 1] > j){
f[i][j] = f[i - 1][j];
}
else{
f[i][j] = f[i - 1][j] + f[i - 1][j - arr[i - 1]];
}
f[i][j] %= mod;
}
}
return f[n][sum];
}
};
// Phần main chỉ để chạy thử trên máy, nộp GFG thì chỉ cần phần ở trên
int main(){
int n, sum;
cin >> n >> sum;
int arr[n];
for(int i = 0; i < n; ++i){
cin >> arr[i];
}
Solution ob;
cout << ob.perfectSum(arr, n, sum);
return 0;
}
import sys
mod = 10**9 + 7
class Solution:
def perfectSum(self, arr, n, s):
f = [[0] * (s + 1) for _ in range(n + 1)]
f[0][0] = 1
for i in range(1, n + 1):
a = arr[i - 1]
for j in range(s + 1):
if a > j:
f[i][j] = f[i - 1][j]
else:
f[i][j] = (f[i - 1][j] + f[i - 1][j - a]) % mod
return f[n][s]
# Phần dưới chỉ để chạy thử trên máy
if __name__ == '__main__':
data = sys.stdin.read().split()
n, s = int(data[0]), int(data[1])
arr = list(map(int, data[2:2 + n]))
print(Solution().perfectSum(arr, n, s))
int f[n + 1][sum + 1]; ngay trong hàm. Nhưng khi $N = 1$, $sum = 10^6$ thì mảng đó cỡ 8 MB nằm trên stack, dễ bị tràn stack, nên ở đây mình đổi sang vector (cấp phát trên heap) cho an toàn, logic giữ nguyên.So với đệ quy có nhớ thì cách này nhanh hơn và đỡ tốn bộ nhớ hơn vì không mất không gian và thời gian cho đệ quy nữa, nhưng theo Big-O thì độ phức tạp vẫn là $O(N \cdot sum)$.
Rút gọn còn mảng 1 chiều – nhớ duyệt ngược!
Thậm chí bài này ta còn cải tiến cho ăn ít bộ nhớ hơn nữa: chỉ dùng một mảng 1 chiều F[sum + 1], với F[j] là số tập con có tổng bằng $j$ (tính tới các phần tử đã xét). Ban đầu F[0] = 1 (có sẵn 1 tập rỗng tổng bằng 0), F[1] tới F[sum] bằng $0$ vì chưa xét phần tử nào.
Ta vẫn đi lần lượt từng phần tử arr[i]. Nếu $j \ge arr[i]$ thì số tập con có tổng bằng $j$ được cộng thêm số tập con có tổng bằng $j - arr[i]$, vì mỗi tập như vậy thêm phần tử arr[i] vào là có tổng $j$: F[j] += F[j - arr[i]].
arr[i] tăng lên tới $sum$ thì sai. Ví dụ $n = 2$, $sum = 6$, arr = {2, 3}: ban đầu F = {1, 0, 0, 0, 0, 0, 0}. Duyệt tăng thì sau khi xét xong arr[0] = 2, mảng thành {1, 0, 1, 0, 1, 0, 1}. Anh em thấy cấn rồi chứ: mới xét một phần tử, làm sao có tập con tổng bằng 4 hay bằng 6 được? Chỉ có đúng một tập tổng bằng 2 thôi. Lý do: vừa cho F[2] = 1 xong, tới F[4] lại cộng F[4 - 2] = F[2] (đã chứa phần tử thứ nhất), tức là lấy phần tử thứ nhất 2 lần.Cách khắc phục: duyệt ngược $j$ từ $sum$ về
arr[i]. Khi đó F[j - arr[i]] (chỉ số nhỏ hơn) chưa bị cập nhật ở lượt này, vẫn là giá trị "trước khi xét arr[i]", đúng như F[i - 1][j - arr[i - 1]] của bảng 2 chiều.Minh hoạ: duyệt ngược và duyệt xuôi
Em nhập mảng, $sum$ và chọn chiều duyệt rồi bấm Tiếp: ô cam là F[j] đang cập nhật, ô xanh dương là F[j - arr[i]] được cộng vào. Chọn "Duyệt xuôi (sai)" với ví dụ {2, 3}, $sum = 6$ để thấy lỗi lấy một phần tử nhiều lần (ô đỏ).
Source code cho anh em nào cần tham khảo cách mảng 1 chiều:
#include <bits/stdc++.h>
using namespace std;
const int mod = (int)1e9 + 7;
class Solution{
public:
int perfectSum(int arr[], int n, int sum){
vector<int> f(sum + 1);
f[0] = 1;
for(int i = 1; i <= sum; ++i){
f[i] = 0;
}
for(int i = 0; i < n; ++i){
for(int j = sum; j >= arr[i]; --j){
f[j] += f[j - arr[i]];
f[j] %= mod;
}
}
return f[sum];
}
};
// Phần main chỉ để chạy thử trên máy, nộp GFG thì chỉ cần phần ở trên
int main(){
int n, sum;
cin >> n >> sum;
int arr[n];
for(int i = 0; i < n; ++i){
cin >> arr[i];
}
Solution ob;
cout << ob.perfectSum(arr, n, sum);
return 0;
}
import sys
mod = 10**9 + 7
class Solution:
def perfectSum(self, arr, n, s):
f = [0] * (s + 1)
f[0] = 1
for i in range(n):
a = arr[i]
for j in range(s, a - 1, -1):
f[j] = (f[j] + f[j - a]) % mod
return f[s]
# Phần dưới chỉ để chạy thử trên máy
if __name__ == '__main__':
data = sys.stdin.read().split()
n, s = int(data[0]), int(data[1])
arr = list(map(int, data[2:2 + n]))
print(Solution().perfectSum(arr, n, s))
Đánh giá độ phức tạp của cách làm này:
- Không gian: bảng 2 chiều tốn $O(N \cdot sum)$; bản mảng 1 chiều chỉ còn $O(sum)$.
- Thời gian: vẫn $O(N \cdot sum)$ cho cả hai bản, với $N \cdot sum \le 10^6$ thì chạy vèo vèo.
- Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
- Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
- Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
- Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
- Dễ nản giữa chừng khi không có ai theo sát.
- Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
- Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
- Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
- Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.
Nhận xét
Đăng nhận xét