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
. Chúc các bạn học tốt và gặt hái nhiều thành công
Trước khi đi vào vấn đề chính của bài thì mình muốn các bạn phải đảm bảo biết qua điều này để về sau các bạn xem mới hiểu:
Với các hệ thống web chấm bài giới hạn thời gian chạy của bài tối đa chỉ được 1 giây thì các bạn sẽ rất dễ bị rơi vào lỗi Time Limit Exceeded (TLE: Chạy quá giới hạn thời gian cho phép). Thì như mình đã nói rất nhiều lần là với các hệ thống web chấm bài hiện nay nếu không muốn bị TLE thì tối đa số phép toán trên toàn bài của các bạn chỉ nên ở ngưỡng (3 đến 5)*10^7 là ngưỡng an toàn với phần lớn hệ thống chấm bài, hoặc với một số hệ thống chấm server mạnh hơn thì có thể nâng ngưỡng lên 10^8 nhưng cũng hên xui nhé. Và thực ra ta không phải đếm chi tiết từng số phép toán trên toàn bài mà ta dựa theo số lần vòng lặp chạy bởi bên trong vòng lặp là nó xử lý lặp đi lặp lại nhiều phép toán nên cứ canh theo đó, các bạn cứ hiểu là tối đa toàn bài chỉ nên lặp (3 đến 5)*10^7 lần là an toàn hoặc tệ nhất là 10^8 (lúc này thì hên xui nhé) còn vượt ngưỡng này là khả năng TLE rất cao nhe. Lưu ý là ngưỡng mình vừa nói ở trên là với ngôn ngữ C/C++ thôi nhé chứ nếu với những ngôn ngữ khác như Python, C# hay Java nó chạy chậm hơn C/C++ thì ngưỡng đó phải thấp hơn, hoặc người ra đề sẽ chủ động đưa ra giới hạn nếu nộp với C/C++ thì thời gian chạy quy định tối đa 1 giây, các ngôn ngữ khác thì 2 giây. Và ngưỡng mình đưa ra ở trên nếu với đề cho thời gian 2 giây thì các bạn cứ tỷ lệ thuận nhân 2 lên với ngưỡng, 3 giây thì nhân 3, nếu 100 ms tức là 1/10 giây thì các bạn chia 10 cho ngưỡng đó nhé, cứ thế thôi.
Rồi thì như bài này đưa ra thời gian chạy giới hạn là 1 giây nên cứ như ở trên mình đã nói nhé. Giờ ta đi phân tích cách giải quyết cho bài này.
---------------------------------------------------
Trước tiên mình sẽ tóm tắt lại đề bài một cách đơn giản nhất để bạn nào chưa hiểu như sau:
Cho 1 mảng a có n số nguyên (1 <= n <= 10^5) a[i] với (1 <= i <= 10^5; 1 <= a[i] <= 10^5). Ở mỗi bước ta có thể chọn phần tử a[k] và xoá nó, sau đó tất cả phần tử có giá trị a[k] + 1 và a[k] - 1 cũng sẽ bị xoá bỏ. Bước đó sẽ cho mình a[k] điểm. Hãy lấy càng nhiều điểm càng tốt; in ra số điểm lớn nhất có thể lấy được.
Ví dụ:
n = 9. Dãy là: 1, 2, 1, 3, 2, 2, 2, 2, 3. Ta sẽ thấy có 5 phần tử có giá trị = 2; 2 phần tử có giá trị = 1; 2 phần tử có giá trị = 3. Cách tối ưu nhất là đầu tiên ta sẽ chọn phần tử có giá trị = 2; sau đó mọi phần tử có giá trị = 1 và = 3 sẽ bị xoá; ta được 2 điểm; còn 4 phần tử có giá trị = 2. Và ta sẽ chọn 4 phần tử đó nữa và kết quả sẽ = 10 thôi.
---------------------------------------------------
Đầu tiên để thực hiện được bài này; ta sẽ cần phải thực hiện vài bước nho nhỏ. Ta sẽ dễ dàng nhận thấy là nếu ta chọn phần tử 2 thì ta sẽ lấy mọi phần tử 2; vì nếu ta chọn phần tử a[i] thì tất cả phần tử có giá trị a[i] - 1 và a[i] + 1 sẽ bị xoá rồi nên nếu còn phần tử a[i] thì ta lấy nữa cũng không vấn đề. Vậy với ví dụ trên có 5 phần tử 2 thì khi ta chọn phần tử 2 thì điểm ta nhận được sẽ là 2 * 5 = 10; vì có 5 phần tử 2 và ta sẽ lấy hết các phần tử 2.
Để thực hiện được điều trên thì ta sẽ phải đi đếm xem vỡi mỗi số ở đầu vào sẽ xuất hiện bao nhiêu lần. Ta sẽ làm việc đó bằng cách tạo một mảng cnt để đếm các phần tử xuất hiện bao lần. Và ta sẽ tạo 1 vector<int> a để lưu trữ các phần tử khác nhau. Ví dụ ta đọc vào được số 3; ta sẽ check cnt[3] có = 0 không; nếu có thì ta sẽ cho vào vector a vì đó là lần đầu nó xuất hiện; còn không thì chứng tỏ xuất hiện rồi thì ta sẽ không cho vào.
Và để có thể làm bài này dễ hơn thì sau khi thực hiện bước trên ta đã có vector a chứa các phần tử khác nhau ở đầu vào; giờ ta cần sắp xếp cho dãy tăng dần. Để khi ta lấy phần tử a[i] thì ta có thể chắc chắn rằng phần tử có giá trị a[i] - 1 nếu có thì sẽ nằm ở vị trí i - 1; phần tử có giá trị a[i] + 1 nếu có thì sẽ nằm ở vị trí i + 1. Điều này các bạn ngẫm chút là sẽ thấy; vì ta đang sắp xếp dãy các phần tử phân biệt tăng dần mà. Nên nếu ví dụ ta chọn phần tử 3 và phần tử 3 đang ở vị trí số 2 chẳng hạn; thì nếu có phần tử có giá trị = 3 - 1 = 2 thì nó phải nằm ở vị trí số 2 - 1 = 1; nếu có phần tử có giá trị = 3 + 1 = 4 thì nó phải nằm ở vị trí số 2 + 1 = 3.
Vậy tổng kết lại ở đầu vào ta sẽ cần đếm số lần xuất hiện của mỗi phần tử, cho cái phần tử khác nhau vào 1 vector và sau đó phải sắp xếp vector đó theo thứ tự tăng dần nha.
---------------------------------------------------
Cách làm đầu tiên của bài này sẽ là vét cạn nha, tức là ta sẽ thử mọi trường hợp đó. Cách làm này sẽ dùng tới đệ quy và sẽ khá là phức tạp cho bạn nào chưa quen với đệ quy, nhưng nó là cách dễ nghĩ tới nhất vì ý tưởng của nó là thử mọi trường hợp có thể xảy ra mà. Giờ mình sẽ phân tích rồi cài đặt để hi vọng các bạn sẽ học được thêm điều gì đó.
Ý tưởng là ở mỗi phần tử ta sẽ đứng giữa lựa chọn hoặc chọn hoặc không chọn; và khi chọn thì ta sẽ phải kiểm tra rằng có được chọn hay không. Lúc này ta sẽ gán n = số phần tử khác biệt trong dãy. Vậy thì ta sẽ đi từ phần tử 1 tới phần tử n; ở mỗi phần tử ta sẽ lần lượt không chọn rồi chọn; nếu chọn thì khi tới với phần tử tiếp theo mà ta muốn chọn nó thì ta phải kiểm tra xem nó có thoả mãn điều kiện đề bài không.
Nói cách khác giả sử ta chọn phần tử i, thì khi đi tới phần tử i + 1 mà ta muốn chọn phần tử i + 1 thì ta phải check a[i + 1] != a[i] + 1 thì ta mới được chọn. Vì ta chọn phần tử a[i] thì mọi phần tử a[i] + 1 sẽ bị xoá hay chính là không được chọn mà.
Cụ thể ta sẽ tạo 1 hàm Try(int i, int notThis, long long sum) sẽ giúp ta thử mọi trường hợp với i là vị trí phần tử ta đang xét; notThis chính là phần tử ta không được lấy; và sum là tổng điểm ta đang có được. Đầu tiên ta phải check i <= n để đảm bảo phần tử đang xét vẫn nằm trong mảng; sau đó ta sẽ đến với trường hợp ta không chọn phần tử i; đồng nghĩa với việc phần tử tiếp theo mà ta chuẩn bị xét không có điều kiện gì ràng buộc cả, vậy biến notThis ta sẽ cho = 0. Vì a[i] >= 1 nên sẽ không có phần tử 0 nên biến tiếp theo kiểu gì cũng khác 0 mà. Và vì ta không lấy nên số điểm ta đang có vẫn giữ nguyên. Vậy với trường hợp không chọn ta sẽ gọi đệ quy là Try(i + 1, 0, sum). Còn nếu chọn thì ta sẽ phải kiểm tra là a[i] != notThis; tức là khác phần tử ta không được lấy đó. Nếu khác thì ta sẽ gọi đệ quy tiếp nhưng lúc này notThis chính là a[i] + 1 và sum sẽ được + thêm là a[i] * cnt[a[i]] nha. Vậy nếu a[i] != notThis thì ta sẽ gọi Try(i + 1, a[i] + 1, sum + a[i] * cnt[a[i]]).
Và giờ ta cần có trường hợp để dừng đệ quy lại; ở trên là khi ta check i <= n; vậy khi i > n tức là trường hợp else đó thì chứng tỏ đã có 1 cách chọn trong n phần tử khác biệt đó vậy ta sẽ có một biến res để lưu kết quả là biến toàn cục; ở trường hơp này ta sẽ cập nhật biến res tức là res = max(res, sum).
Sau đây là source code cho các bạn tham khảo cách làm trên của mình nha:
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int N = (int)1e5;
int cnt[N + 5], n;
ll res = 0;
vector<int> a;
void Try(int i, int notThis, ll sum) {
if(i <= n) {
Try(i + 1, 0, sum);
if(a[i] != notThis) {
Try(i + 1, a[i] + 1, sum + a[i] * cnt[a[i]]);
}
}
else {
res = max(res, sum);
}
}
int main() {
ios_base::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cin >> n;
a.push_back(-1);
for(int i = 1; i <= n; ++i) {
int x;
cin >> x;
if(cnt[x] == 0) {
a.push_back(x);
}
cnt[x]++;
}
sort(a.begin(), a.end());
n = a.size() - 1;
Try(1, 0, 0);
cout << res;
return 0;
}
Giải thích 1 số chỗ trong cách làm trên:
1/ 3 dòng đầu tiên của hàm main (ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); ) sẽ giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn đang code theo ngôn ngữ C++ (để hiểu vì sao thì các bạn cứ gõ mấy từ này lên Google là sẽ ra rất nhiều bài viết giải thích nhé), nên kinh nghiệm trong các bài mà cần nhập xuất nhiều thì ta nên sử dụng 3 dòng trên. Ví dụ với bài này thì đầu vào có thể lên tới 10^5 số. Tất nhiên nếu các bạn code theo cú pháp của C thì chẳng cần mấy dòng đó làm gì, tuy nhiên thường khi code các bài lập trình thi đấu thế này ta dùng C++ vì cú pháp ngắn gọn cũng như nhiều hỗ trợ của nó, vậy nên bạn nào code C++ thì nhớ thêm 3 dòng này vào đầu hàm main nhé.
2/ a.push_back(-1); Đoạn này chính là vì ta đang xét các phần tử theo vị trí từ 1 tới n nên là ta sẽ thêm 1 phần tử vào trước để nó là phần tử thứ 0 cho các phần tử ta thêm vào sẽ bắt đầu từ phần tử 1.
3/ n = a.size() - 1; Đoạn này thì vì lúc đầu ta đã thêm phần tử -1 vào để làm phần tử thứ 0 nên số phần tử khác biệt của dãy sẽ chỉ là a.size() - 1 thôi.
4/ Try(1, 0, 0); Vì ta sẽ bắt đầu từ phần tử đầu tiên nên i = 1; lúc đầu ta không có điều kiện gì cả nên notThis = 0; và biến tổng tức là sum lúc đầu cũng = 0.
Đánh giá độ phức tạp của cách làm trên:
- Độ phức tạp không gian (Space Complexity):O(n). Vì ta đang dùng 1 mảng cnt và 1 vector để lưu số phần tử khác biệt; nên nếu ở trường hợp xấu nhất tất cả các phần tử đều khác nhau thì vector sẽ có độ lớn là n phần tử giống như đầu vào.
- Độ phức tạp thời gian (Time Complexity): Để mà tính chính xác thì mình cũng khó mà tính được cho các bạn; nhưng nếu mà nộp code trên thì sẽ bị TLE ở test 29 nha, mà bài trên có 53 test lận nha. Và các trường hợp mà AC thì giá trị N (hiểu ở đây là số phần tử khác biệt) chỉ <= 20 thôi.
Vậy cách trên không thể AC được rồi, tuy nhiên hãy vẫn nên cài đặt nó để sau này ta lấy nó làm công cụ kiểm tra tính chính xác cho cách làm tối ưu. Bạn nào có chưa biết cách lấy nó làm công cụ kiểm tra tính chính xác thì có thể xem qua bài viết này của mình nha:
https://www.facebook.com/100003824621962/posts/2303526743118124/?d=n
---------------------------------------------------
Giờ ta sẽ đến cách làm tối ưu hơn để AC bài này nha.
Để mà có thể AC được bài này thì ta sẽ dùng phương pháp quy hoạch động (Dynamic Programming) để giải quyết nha các bạn. Cho bạn nào đã biết cơ bản về quy hoạch động rồi thì ta có 4 bước như sau:
Bước 1: Xác định bài toán con:
Gọi F[i] (0 <= i <= n) là số điểm nhiều nhất ta có thể có được khi xét i phần tử đầu tiên.
Bước 2: Xác định bài toán cơ sở:
Khi ta xét 0 phần tử nào thì chắc chắn ta sẽ không có điểm nào rồi. Vậy F[0] = 0. Khi ta xét 1 phần tử đầu tiên thì số điểm lớn nhất ta lấy được chính là a[1] * cnt[a[1]] vì ta chỉ lấy được phần tử 1 thôi mà. Vậy F[1] = a[1] * cnt[a[1]].
Chốt lại ta sẽ có F[0] = 0; F[1] = a[1] * cnt[a[1]].
Bước 3: Xác định đáp án của bài toán:
Hiển nhiên sẽ là F[n] rồi; ở đây các bạn lưu ý n là số phần tử khác biệt trong vector mình lưu nha. Vì ta cần in ra số điểm nhiều nhất ta có thể có được khi xét n phần tử khác biệt nên đáp án chính là F[n].
Bước 4: Xác định công thức truy hồi:
Giờ giả sử ta đang xét phần tử i. Ta sẽ kiểm tra xem liệu ta có thể lấy được nó không bằng cách kiểm tra if(a[i] != a[i - 1] + 1) tức là phần tử ta đang xét thoả mãn điều kiện đề bài đó, vì nếu ta dù có chọn hay không chọn phần tử a[i - 1] thì phần tử a[i] cũng sẽ không bị xoá. Vậy ở trường hợp này (a[i] != a[i - 1] + 1) thì F[i] sẽ bằng số điểm nhiều nhất ta có thể có được khi xét i - 1 phần tử đầu tiên + số điểm ta nhận được khi lấy phần tử a[i]. Vậy ta sẽ có F[i] = F[i - 1] + a[i] * cnt[a[i]] khi a[i] != a[i - 1] + 1.
Vậy còn trường hợp mà a[i] == a[i - 1] + 1 thì sao. Ở đây phải nhớ rằng F[i] sẽ lưu số điểm lớn nhất mà ta có thể có được khi xét i phần tử đầu tiên nhé chứ không bắt buộc ta phải lấy phần tử thứ i hay không đâu. Vậy từ đó F[i - 1] có thể lấy hoặc không lấy phần tử i - 1 tuỳ vào trường hợp nào lớn hơn thôi.
Bạn nào mà thấy hơi lú thì đọc chút nữa rồi ngẫm là ra nha; lúc này ta sẽ đối diện 2 trường hợp đó là:
- Trường hợp 1 (gọi tắt là TH1): Ta có lấy phần tử a[i] nhưng chắc chắn ta sẽ không được lấy phần tử i - 1. Và để có số điểm lớn nhất thì ta sẽ lấy số điểm nhiều nhất ta có thể có được khi xét i - 1 phần tử đầu tiên mà không lấy phần tử thứ i - 1 rồi + với số điểm ta lấy được khi lấy phần tử a[i] thôi. Và để ý nếu ta xét i - 1 phần tử đầu tiên mà không lấy phần tử thứ i - 1 thì khác gì đang xét i - 2 phần tử đầu tiên đâu. Vậy đó chính là F[i - 2] đó; từ đó ở trường hợp mà ta có lấy phần tử a[i] thì số điểm lớn nhất ta nhận được chính là = số điểm lớn nhất ta có thể có được khi xét i - 2 phần tử đầu tiên + số điểm ta lấy được khi lấy phần tử a[i]. Vậy ở trường hợp này F[i] = F[i - 2] + a[i] * cnt[a[i]]
- Trường hợp 2 (gọi tắt là TH2): Ta không lấy phần tử a[i] nữa. Vậy đương nhiên là số điểm lớn nhất khi xét i phần tử đầu tiên sẽ = số điểm lớn nhất khi xét i - 1 phần tử đầu tiên rồi. Vậy F[i] = F[i - 1]. Và vì F[i] là số điểm lớn nhất có thể nên F[i] sẽ = max(TH1, TH2) và sẽ là F[i] = max(F[i - 1], F[i - 2] + a[i] * cnt[a[i]]) khi a[i] == a[i - 1] + 1.
Vậy từ đó ta có công thức truy hồi sau:
if(a[i] == a[i - 1] + 1) thì F[i] = max(F[i - 1], F[i - 2] + a[i] * cnt[a[i]]); else F[i] = F[i - 1] + a[i] * cnt[a[i]].
Sau đây là source code cho các bạn tham khảo cách làm trên của mình nha:
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int N = (int)1e5;
int cnt[N + 5];
ll f[N + 5];
int main() {
ios_base::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n;
cin >> n;
vector<ll> a;
a.push_back(-1);
for(int i = 1; i <= n; ++i) {
int x;
cin >> x;
if(cnt[x] == 0) {
a.push_back(x);
}
cnt[x]++;
}
n = a.size() - 1;
sort(a.begin(), a.end());
f[0] = 0;
f[1] = a[1] * cnt[a[1]];
for(int i = 2; i <= n; ++i) {
if(a[i] == a[i - 1] + 1) {
f[i] = max(f[i - 1], f[i - 2] + a[i] * cnt[a[i]]);
}
else {
f[i] = f[i - 1] + a[i] * cnt[a[i]];
}
}
cout << f[n];
return 0;
}
Đánh giá độ phức tạp của cách làm trên:
- Độ phức tạp không gian (Space Complexity): O(n). Chính xác thì mình sẽ dùng tận 3 mảng luôn đó nhưng theo quy tắc bigO thì vẫn là O(n) nha.
- Độ phức tạp thời gian (Time Complexity): O(nlogn) vì lớn nhất là từ bước gọi hàm sort.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ
vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa
. Vậy nên đừng tiếc gì cho mình một like nha để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công
.
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 😇. Chúc các bạn học tốt và gặt hái nhiều thành công 🥰
Trước khi đi vào vấn đề chính của bài thì mình muốn các bạn phải đảm bảo biết qua điều này để về sau các bạn xem mới hiểu:
Với các hệ thống web chấm bài giới hạn thời gian chạy của bài tối đa chỉ được 1 giây thì các bạn sẽ rất dễ bị rơi vào lỗi Time Limit Exceeded (TLE: Chạy quá giới hạn thời gian cho phép). Thì như mình đã nói rất nhiều lần là với các hệ thống web chấm bài hiện nay nếu không muốn bị TLE thì tối đa số phép toán trên toàn bài của các bạn chỉ nên ở ngưỡng (3 đến 5)*10^7 là ngưỡng an toàn với phần lớn hệ thống chấm bài, hoặc với một số hệ thống chấm server mạnh hơn thì có thể nâng ngưỡng lên 10^8 nhưng cũng hên xui nhé. Và thực ra ta không phải đếm chi tiết từng số phép toán trên toàn bài mà ta dựa theo số lần vòng lặp chạy bởi bên trong vòng lặp là nó xử lý lặp đi lặp lại nhiều phép toán nên cứ canh theo đó, các bạn cứ hiểu là tối đa toàn bài chỉ nên lặp (3 đến 5)*10^7 lần là an toàn hoặc tệ nhất là 10^8 (lúc này thì hên xui nhé) còn vượt ngưỡng này là khả năng TLE rất cao nhe. Lưu ý là ngưỡng mình vừa nói ở trên là với ngôn ngữ C/C++ thôi nhé chứ nếu với những ngôn ngữ khác như Python, C# hay Java nó chạy chậm hơn C/C++ thì ngưỡng đó phải thấp hơn, hoặc người ra đề sẽ chủ động đưa ra giới hạn nếu nộp với C/C++ thì thời gian chạy quy định tối đa 1 giây, các ngôn ngữ khác thì 2 giây. Và ngưỡng mình đưa ra ở trên nếu với đề cho thời gian 2 giây thì các bạn cứ tỷ lệ thuận nhân 2 lên với ngưỡng, 3 giây thì nhân 3, nếu 100 ms tức là 1/10 giây thì các bạn chia 10 cho ngưỡng đó nhé, cứ thế thôi.
Rồi thì như bài này đưa ra thời gian chạy giới hạn là 1 giây nên cứ như ở trên mình đã nói nhé. Giờ ta đi phân tích cách giải quyết cho bài này.
---------------------------------------------------
Trước tiên mình sẽ tóm tắt lại đề bài một cách đơn giản nhất để bạn nào chưa hiểu như sau:
Cho 1 mảng a có n số nguyên (1 <= n <= 10^5) a[i] với (1 <= i <= 10^5; 1 <= a[i] <= 10^5). Ở mỗi bước ta có thể chọn phần tử a[k] và xoá nó, sau đó tất cả phần tử có giá trị a[k] + 1 và a[k] - 1 cũng sẽ bị xoá bỏ. Bước đó sẽ cho mình a[k] điểm. Hãy lấy càng nhiều điểm càng tốt; in ra số điểm lớn nhất có thể lấy được.
Ví dụ:
n = 9. Dãy là: 1, 2, 1, 3, 2, 2, 2, 2, 3. Ta sẽ thấy có 5 phần tử có giá trị = 2; 2 phần tử có giá trị = 1; 2 phần tử có giá trị = 3. Cách tối ưu nhất là đầu tiên ta sẽ chọn phần tử có giá trị = 2; sau đó mọi phần tử có giá trị = 1 và = 3 sẽ bị xoá; ta được 2 điểm; còn 4 phần tử có giá trị = 2. Và ta sẽ chọn 4 phần tử đó nữa và kết quả sẽ = 10 thôi.
---------------------------------------------------
Đầu tiên để thực hiện được bài này; ta sẽ cần phải thực hiện vài bước nho nhỏ. Ta sẽ dễ dàng nhận thấy là nếu ta chọn phần tử 2 thì ta sẽ lấy mọi phần tử 2; vì nếu ta chọn phần tử a[i] thì tất cả phần tử có giá trị a[i] - 1 và a[i] + 1 sẽ bị xoá rồi nên nếu còn phần tử a[i] thì ta lấy nữa cũng không vấn đề. Vậy với ví dụ trên có 5 phần tử 2 thì khi ta chọn phần tử 2 thì điểm ta nhận được sẽ là 2 * 5 = 10; vì có 5 phần tử 2 và ta sẽ lấy hết các phần tử 2.
Để thực hiện được điều trên thì ta sẽ phải đi đếm xem vỡi mỗi số ở đầu vào sẽ xuất hiện bao nhiêu lần. Ta sẽ làm việc đó bằng cách tạo một mảng cnt để đếm các phần tử xuất hiện bao lần. Và ta sẽ tạo 1 vector<int> a để lưu trữ các phần tử khác nhau. Ví dụ ta đọc vào được số 3; ta sẽ check cnt[3] có = 0 không; nếu có thì ta sẽ cho vào vector a vì đó là lần đầu nó xuất hiện; còn không thì chứng tỏ xuất hiện rồi thì ta sẽ không cho vào.
Và để có thể làm bài này dễ hơn thì sau khi thực hiện bước trên ta đã có vector a chứa các phần tử khác nhau ở đầu vào; giờ ta cần sắp xếp cho dãy tăng dần. Để khi ta lấy phần tử a[i] thì ta có thể chắc chắn rằng phần tử có giá trị a[i] - 1 nếu có thì sẽ nằm ở vị trí i - 1; phần tử có giá trị a[i] + 1 nếu có thì sẽ nằm ở vị trí i + 1. Điều này các bạn ngẫm chút là sẽ thấy; vì ta đang sắp xếp dãy các phần tử phân biệt tăng dần mà. Nên nếu ví dụ ta chọn phần tử 3 và phần tử 3 đang ở vị trí số 2 chẳng hạn; thì nếu có phần tử có giá trị = 3 - 1 = 2 thì nó phải nằm ở vị trí số 2 - 1 = 1; nếu có phần tử có giá trị = 3 + 1 = 4 thì nó phải nằm ở vị trí số 2 + 1 = 3.
Vậy tổng kết lại ở đầu vào ta sẽ cần đếm số lần xuất hiện của mỗi phần tử, cho cái phần tử khác nhau vào 1 vector và sau đó phải sắp xếp vector đó theo thứ tự tăng dần nha.
---------------------------------------------------
Cách làm đầu tiên của bài này sẽ là vét cạn nha, tức là ta sẽ thử mọi trường hợp đó. Cách làm này sẽ dùng tới đệ quy và sẽ khá là phức tạp cho bạn nào chưa quen với đệ quy, nhưng nó là cách dễ nghĩ tới nhất vì ý tưởng của nó là thử mọi trường hợp có thể xảy ra mà. Giờ mình sẽ phân tích rồi cài đặt để hi vọng các bạn sẽ học được thêm điều gì đó.
Ý tưởng là ở mỗi phần tử ta sẽ đứng giữa lựa chọn hoặc chọn hoặc không chọn; và khi chọn thì ta sẽ phải kiểm tra rằng có được chọn hay không. Lúc này ta sẽ gán n = số phần tử khác biệt trong dãy. Vậy thì ta sẽ đi từ phần tử 1 tới phần tử n; ở mỗi phần tử ta sẽ lần lượt không chọn rồi chọn; nếu chọn thì khi tới với phần tử tiếp theo mà ta muốn chọn nó thì ta phải kiểm tra xem nó có thoả mãn điều kiện đề bài không.
Nói cách khác giả sử ta chọn phần tử i, thì khi đi tới phần tử i + 1 mà ta muốn chọn phần tử i + 1 thì ta phải check a[i + 1] != a[i] + 1 thì ta mới được chọn. Vì ta chọn phần tử a[i] thì mọi phần tử a[i] + 1 sẽ bị xoá hay chính là không được chọn mà.
Cụ thể ta sẽ tạo 1 hàm Try(int i, int notThis, long long sum) sẽ giúp ta thử mọi trường hợp với i là vị trí phần tử ta đang xét; notThis chính là phần tử ta không được lấy; và sum là tổng điểm ta đang có được. Đầu tiên ta phải check i <= n để đảm bảo phần tử đang xét vẫn nằm trong mảng; sau đó ta sẽ đến với trường hợp ta không chọn phần tử i; đồng nghĩa với việc phần tử tiếp theo mà ta chuẩn bị xét không có điều kiện gì ràng buộc cả, vậy biến notThis ta sẽ cho = 0. Vì a[i] >= 1 nên sẽ không có phần tử 0 nên biến tiếp theo kiểu gì cũng khác 0 mà. Và vì ta không lấy nên số điểm ta đang có vẫn giữ nguyên. Vậy với trường hợp không chọn ta sẽ gọi đệ quy là Try(i + 1, 0, sum). Còn nếu chọn thì ta sẽ phải kiểm tra là a[i] != notThis; tức là khác phần tử ta không được lấy đó. Nếu khác thì ta sẽ gọi đệ quy tiếp nhưng lúc này notThis chính là a[i] + 1 và sum sẽ được + thêm là a[i] * cnt[a[i]] nha. Vậy nếu a[i] != notThis thì ta sẽ gọi Try(i + 1, a[i] + 1, sum + a[i] * cnt[a[i]]).
Và giờ ta cần có trường hợp để dừng đệ quy lại; ở trên là khi ta check i <= n; vậy khi i > n tức là trường hợp else đó thì chứng tỏ đã có 1 cách chọn trong n phần tử khác biệt đó vậy ta sẽ có một biến res để lưu kết quả là biến toàn cục; ở trường hơp này ta sẽ cập nhật biến res tức là res = max(res, sum).
Sau đây là source code cho các bạn tham khảo cách làm trên của mình nha:
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int N = (int)1e5;
int cnt[N + 5], n;
ll res = 0;
vector<int> a;
void Try(int i, int notThis, ll sum) {
if(i <= n) {
Try(i + 1, 0, sum);
if(a[i] != notThis) {
Try(i + 1, a[i] + 1, sum + a[i] * cnt[a[i]]);
}
}
else {
res = max(res, sum);
}
}
int main() {
ios_base::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cin >> n;
a.push_back(-1);
for(int i = 1; i <= n; ++i) {
int x;
cin >> x;
if(cnt[x] == 0) {
a.push_back(x);
}
cnt[x]++;
}
sort(a.begin(), a.end());
n = a.size() - 1;
Try(1, 0, 0);
cout << res;
return 0;
}
Giải thích 1 số chỗ trong cách làm trên:
1/ 3 dòng đầu tiên của hàm main (ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); ) sẽ giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn đang code theo ngôn ngữ C++ (để hiểu vì sao thì các bạn cứ gõ mấy từ này lên Google là sẽ ra rất nhiều bài viết giải thích nhé), nên kinh nghiệm trong các bài mà cần nhập xuất nhiều thì ta nên sử dụng 3 dòng trên. Ví dụ với bài này thì đầu vào có thể lên tới 10^5 số. Tất nhiên nếu các bạn code theo cú pháp của C thì chẳng cần mấy dòng đó làm gì, tuy nhiên thường khi code các bài lập trình thi đấu thế này ta dùng C++ vì cú pháp ngắn gọn cũng như nhiều hỗ trợ của nó, vậy nên bạn nào code C++ thì nhớ thêm 3 dòng này vào đầu hàm main nhé.
2/ a.push_back(-1); Đoạn này chính là vì ta đang xét các phần tử theo vị trí từ 1 tới n nên là ta sẽ thêm 1 phần tử vào trước để nó là phần tử thứ 0 cho các phần tử ta thêm vào sẽ bắt đầu từ phần tử 1.
3/ n = a.size() - 1; Đoạn này thì vì lúc đầu ta đã thêm phần tử -1 vào để làm phần tử thứ 0 nên số phần tử khác biệt của dãy sẽ chỉ là a.size() - 1 thôi.
4/ Try(1, 0, 0); Vì ta sẽ bắt đầu từ phần tử đầu tiên nên i = 1; lúc đầu ta không có điều kiện gì cả nên notThis = 0; và biến tổng tức là sum lúc đầu cũng = 0.
Đánh giá độ phức tạp của cách làm trên:
- Độ phức tạp không gian (Space Complexity):O(n). Vì ta đang dùng 1 mảng cnt và 1 vector để lưu số phần tử khác biệt; nên nếu ở trường hợp xấu nhất tất cả các phần tử đều khác nhau thì vector sẽ có độ lớn là n phần tử giống như đầu vào.
- Độ phức tạp thời gian (Time Complexity): Để mà tính chính xác thì mình cũng khó mà tính được cho các bạn; nhưng nếu mà nộp code trên thì sẽ bị TLE ở test 29 nha, mà bài trên có 53 test lận nha. Và các trường hợp mà AC thì giá trị N (hiểu ở đây là số phần tử khác biệt) chỉ <= 20 thôi.
Vậy cách trên không thể AC được rồi, tuy nhiên hãy vẫn nên cài đặt nó để sau này ta lấy nó làm công cụ kiểm tra tính chính xác cho cách làm tối ưu. Bạn nào có chưa biết cách lấy nó làm công cụ kiểm tra tính chính xác thì có thể xem qua bài viết này của mình nha:
https://www.facebook.com/100003824621962/posts/2303526743118124/?d=n
---------------------------------------------------
Giờ ta sẽ đến cách làm tối ưu hơn để AC bài này nha.
Để mà có thể AC được bài này thì ta sẽ dùng phương pháp quy hoạch động (Dynamic Programming) để giải quyết nha các bạn. Cho bạn nào đã biết cơ bản về quy hoạch động rồi thì ta có 4 bước như sau:
Bước 1: Xác định bài toán con:
Gọi F[i] (0 <= i <= n) là số điểm nhiều nhất ta có thể có được khi xét i phần tử đầu tiên.
Bước 2: Xác định bài toán cơ sở:
Khi ta xét 0 phần tử nào thì chắc chắn ta sẽ không có điểm nào rồi. Vậy F[0] = 0. Khi ta xét 1 phần tử đầu tiên thì số điểm lớn nhất ta lấy được chính là a[1] * cnt[a[1]] vì ta chỉ lấy được phần tử 1 thôi mà. Vậy F[1] = a[1] * cnt[a[1]].
Chốt lại ta sẽ có F[0] = 0; F[1] = a[1] * cnt[a[1]].
Bước 3: Xác định đáp án của bài toán:
Hiển nhiên sẽ là F[n] rồi; ở đây các bạn lưu ý n là số phần tử khác biệt trong vector mình lưu nha. Vì ta cần in ra số điểm nhiều nhất ta có thể có được khi xét n phần tử khác biệt nên đáp án chính là F[n].
Bước 4: Xác định công thức truy hồi:
Giờ giả sử ta đang xét phần tử i. Ta sẽ kiểm tra xem liệu ta có thể lấy được nó không bằng cách kiểm tra if(a[i] != a[i - 1] + 1) tức là phần tử ta đang xét thoả mãn điều kiện đề bài đó, vì nếu ta dù có chọn hay không chọn phần tử a[i - 1] thì phần tử a[i] cũng sẽ không bị xoá. Vậy ở trường hợp này (a[i] != a[i - 1] + 1) thì F[i] sẽ bằng số điểm nhiều nhất ta có thể có được khi xét i - 1 phần tử đầu tiên + số điểm ta nhận được khi lấy phần tử a[i]. Vậy ta sẽ có F[i] = F[i - 1] + a[i] * cnt[a[i]] khi a[i] != a[i - 1] + 1.
Vậy còn trường hợp mà a[i] == a[i - 1] + 1 thì sao. Ở đây phải nhớ rằng F[i] sẽ lưu số điểm lớn nhất mà ta có thể có được khi xét i phần tử đầu tiên nhé chứ không bắt buộc ta phải lấy phần tử thứ i hay không đâu. Vậy từ đó F[i - 1] có thể lấy hoặc không lấy phần tử i - 1 tuỳ vào trường hợp nào lớn hơn thôi.
Bạn nào mà thấy hơi lú thì đọc chút nữa rồi ngẫm là ra nha; lúc này ta sẽ đối diện 2 trường hợp đó là:
- Trường hợp 1 (gọi tắt là TH1): Ta có lấy phần tử a[i] nhưng chắc chắn ta sẽ không được lấy phần tử i - 1. Và để có số điểm lớn nhất thì ta sẽ lấy số điểm nhiều nhất ta có thể có được khi xét i - 1 phần tử đầu tiên mà không lấy phần tử thứ i - 1 rồi + với số điểm ta lấy được khi lấy phần tử a[i] thôi. Và để ý nếu ta xét i - 1 phần tử đầu tiên mà không lấy phần tử thứ i - 1 thì khác gì đang xét i - 2 phần tử đầu tiên đâu. Vậy đó chính là F[i - 2] đó; từ đó ở trường hợp mà ta có lấy phần tử a[i] thì số điểm lớn nhất ta nhận được chính là = số điểm lớn nhất ta có thể có được khi xét i - 2 phần tử đầu tiên + số điểm ta lấy được khi lấy phần tử a[i]. Vậy ở trường hợp này F[i] = F[i - 2] + a[i] * cnt[a[i]]
- Trường hợp 2 (gọi tắt là TH2): Ta không lấy phần tử a[i] nữa. Vậy đương nhiên là số điểm lớn nhất khi xét i phần tử đầu tiên sẽ = số điểm lớn nhất khi xét i - 1 phần tử đầu tiên rồi. Vậy F[i] = F[i - 1]. Và vì F[i] là số điểm lớn nhất có thể nên F[i] sẽ = max(TH1, TH2) và sẽ là F[i] = max(F[i - 1], F[i - 2] + a[i] * cnt[a[i]]) khi a[i] == a[i - 1] + 1.
Vậy từ đó ta có công thức truy hồi sau:
if(a[i] == a[i - 1] + 1) thì F[i] = max(F[i - 1], F[i - 2] + a[i] * cnt[a[i]]); else F[i] = F[i - 1] + a[i] * cnt[a[i]].
Sau đây là source code cho các bạn tham khảo cách làm trên của mình nha:
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int N = (int)1e5;
int cnt[N + 5];
ll f[N + 5];
int main() {
ios_base::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n;
cin >> n;
vector<ll> a;
a.push_back(-1);
for(int i = 1; i <= n; ++i) {
int x;
cin >> x;
if(cnt[x] == 0) {
a.push_back(x);
}
cnt[x]++;
}
n = a.size() - 1;
sort(a.begin(), a.end());
f[0] = 0;
f[1] = a[1] * cnt[a[1]];
for(int i = 2; i <= n; ++i) {
if(a[i] == a[i - 1] + 1) {
f[i] = max(f[i - 1], f[i - 2] + a[i] * cnt[a[i]]);
}
else {
f[i] = f[i - 1] + a[i] * cnt[a[i]];
}
}
cout << f[n];
return 0;
}
Đánh giá độ phức tạp của cách làm trên:
- Độ phức tạp không gian (Space Complexity): O(n). Chính xác thì mình sẽ dùng tận 3 mảng luôn đó nhưng theo quy tắc bigO thì vẫn là O(n) nha.
- Độ phức tạp thời gian (Time Complexity): O(nlogn) vì lớn nhất là từ bước gọi hàm sort.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪 . Vậy nên đừng tiếc gì cho mình một like nha để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇.