Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học

Tiệm Sách

Tiệm Sách
Nguồn: Codeforces Group Độ khó: Dễ Tham lam Sắp xếp

Đề bài

Nhà sách thành phố đang có chương trình ưu đãi "mua 3 lấy tiền 2 quyển đắt hơn". Tất nhiên khách hàng cũng có thể mua ít hơn hoặc nhiều hơn 3 quyển và tùy thuộc vào cách mua: 3 cuốn sách 1 lượt, cuốn rẻ nhất sẽ được miễn phí.

Giả sử anh Hiểu muốn mua 7 quyển sách có giá là: $2, 3, 4, 4, 6, 9, 10$. Nếu:

  • Lượt 1 anh mua 3 quyển có giá $10, 3, 2$ thì quyển có giá $2$ sẽ được miễn phí.
  • Lượt 2 anh mua 3 quyển có giá $6, 4, 4$ thì quyển có giá $4$ sẽ được miễn phí.
  • Lượt 3 anh mua 1 quyển có giá $9$ thì hiển nhiên sẽ không được miễn phí quyển nào vì chỉ mua 1 quyển sách.

Yêu cầu: Tính số tiền nhỏ nhất phải trả cho $n$ quyển sách.

Dữ liệu vào:

  • Dòng đầu là số tự nhiên $n$ $(n \le 10^5)$.
  • Dòng thứ hai gồm $n$ số tự nhiên $a_i$ là giá của cuốn sách thứ $i$ $(1 \le a_i \le 10^6)$.

Dữ liệu ra: Gồm 1 số duy nhất là số tiền ít nhất phải trả cho $n$ cuốn sách.

Subtask:

  • 20% số test đầu tiên có $n \le 20$.
  • 30% số test tiếp theo có $n \le 10^3$.
  • 20% số test tiếp theo có $n \le 10^4$.
  • 30% số test còn lại không có giới hạn gì thêm.
Ví dụ – input
6
6
4
5
5
5
5
Ví dụ – output
21

Giải thích ví dụ: sắp giá giảm dần được $6, 5, 5, 5, 5, 4$. Lượt 1 mua $6, 5, 5$ miễn phí một cuốn $5$; lượt 2 mua $5, 5, 4$ miễn phí cuốn $4$. Số tiền phải trả là $6 + 5 + 5 + 5 = 21$.

Lời mở đầu

Bài này mình được một em học viên hỏi và mình đã hướng dẫn chi tiết cách thức giải quyết cho em ấy. Tin rằng sẽ có giá trị với nhiều bạn nên mình chia sẻ đề bài để các bạn thử sức làm, kèm thêm lời giải chi tiết để bạn nào không giải quyết được thì có thể tham khảo nhen. Hi 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 😇.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

Nộp bài trên CLUE OJ
Dành cho tất cả mọi ngườiNộp bài trên Codeforces

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên Codeforces

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Ý tưởngTư tưởng tham lam (Greedy)

Bài này dùng tư tưởng tham lam để giải quyết. Kể cả bạn nào không biết về tư tưởng tham lam thì thực ra đôi khi các bạn đang giải quyết vấn đề theo chính tư tưởng của tham lam mà các bạn không biết đó. Cho bạn nào chưa biết thì mình có thể nói nhanh thế này:

Tham lam là gì?
Ta cố gắng chọn ra cách giải quyết tốt nhất trong tất cả các cách ở hiện tại rồi đi theo cách đó. Nếu lại đứng trước nhiều lựa chọn khác nhau nữa thì ta lại chọn cách tốt nhất trong số đó, tóm lại cứ tốt nhất mà triển, vì thế tên gọi nó là tham lam. Quy luật nó là như vậy thôi, chứ không có công thức từng bước cụ thể như mấy thuật toán khác.

Thì tại sao bài này lại có thể nhìn ra tư tưởng tham lam? Đọc đề bài là thấy rõ mùi rồi hehe: muốn mua đủ $N$ quyển sách nhưng dựa theo chính sách giảm giá của cửa hàng thì phải làm sao để số tiền phải trả là ít nhất. Đọc kiểu này phát thấy mùi tham lam ngay 😝 Lúc nào cũng muốn phải ngon, phải bổ nhưng phải… rẻ 🤪

Tham lam chỉ là dạng tư tưởng thôi, còn để hiện thực được nó thì có thể cần đến những kỹ thuật bổ trợ khác nữa, như trong chính bài này là sắp xếp.

Chiến lượcCuốn được miễn phí phải đắt nhất có thể

Theo chính sách giảm giá, cứ 3 cuốn đưa ra tính tiền thì được giảm tiền của cuốn có giá thấp nhất. Vậy muốn mua $N$ cuốn thì ta cứ gom ra 3 cuốn làm 1 nhóm và đem ra tính tiền nhóm đó. Muốn số tiền phải trả ít nhất thì đồng nghĩa số tiền được giảm phải nhiều nhất, nó tỷ lệ nghịch với nhau thôi. Và để số tiền được giảm nhiều nhất thì cuốn sách được miễn phí trong mỗi nhóm 3 cuốn phải có giá lớn nhất có thể.

  • Trong $N$ cuốn, xét ra 3 cuốn có giá lớn nhất, lớn nhì, lớn ba. Cuốn lớn ba sẽ không phải tính tiền (vì trong 3 cuốn đó nó rẻ nhất), và đó cũng là cuốn có số tiền được giảm lớn nhất có thể.
  • Loại 3 cuốn vừa xét ra, tiếp tục xét 3 cuốn lớn nhất, nhì, ba trong $N - 3$ cuốn còn lại, cuốn lớn thứ 3 lại được giảm.
  • Cứ như thế khi nào không gom được nhóm 3 nữa thì dừng. Những cuốn không được giảm cộng tổng lại chính là số tiền ít nhất phải trả.

Thử với 7 cuốn trong đề $2, 3, 4, 4, 6, 9, 10$: cách mua trong đề phải trả $10 + 3 + 6 + 4 + 9 = 32$. Còn theo tham lam: nhóm $(10, 9, 6)$ miễn phí cuốn $6$, nhóm $(4, 4, 3)$ miễn phí cuốn $3$, còn lại cuốn $2$, chỉ phải trả $10 + 9 + 4 + 4 + 2 = 29$.

Mình bổ sung thêm vì sao tham lam này luôn đúng: sắp giá giảm dần $b_1 \ge b_2 \ge \dots \ge b_N$. Giả sử một cách mua bất kỳ có các cuốn miễn phí, xếp giảm dần là $f_1 \ge f_2 \ge \dots$ Cuốn miễn phí thứ $j$ là $f_j$: mỗi cuốn $f_1, \dots, f_j$ đều có 2 bạn cùng nhóm không rẻ hơn nó, nên có ít nhất $3j$ cuốn có giá $\ge f_j$, suy ra $f_j \le b_{3j}$. Vậy tổng tiền được giảm luôn $\le b_3 + b_6 + b_9 + \dots$, mà cách tham lam đạt đúng bằng tổng này nên là tốt nhất.

Cài đặtSắp xếp giảm dần, bỏ qua các chỉ số 2, 5, 8…

Quy trình cứ trong tập các cuốn còn lại đi tìm 3 cuốn có giá lớn nhất, nhì, ba, thì để đơn giản ngay từ đầu ta đi sắp xếp tất cả $N$ cuốn theo giá giảm dần. Rồi cứ thế 3 cuốn đầu tiên chính là 3 cuốn có giá lớn nhất, 3 cuốn tiếp theo sẽ là 3 cuốn lớn nhất trong $N - 3$ cuốn còn lại, vì mảng đã sắp giảm dần nên đảm bảo được trật tự.

Đến đây là rất rõ ràng rồi nhưng vẫn sẽ có những bạn code phức tạp chỗ này lắm. Ta có thể nhìn ra như sau: duyệt từ index 0 trở đi thì 3 cuốn đầu là index $0, 1, 2$, rồi $3, 4, 5$, rồi $6, 7, 8$… Với $0, 1, 2$ thì cuốn miễn phí là index $2$; với $3, 4, 5$ là index $5$; với $6, 7, 8$ là index $8$. Các bạn có nhìn ra điều gì đặc biệt của các index $2, 5, 8, 11, 14, 17, 20\dots$ không?

Quy luật
Index của cuốn miễn phí cộng thêm 1 thì luôn chia hết cho 3. Đơn giản vì nếu đếm từ 1 thì các cuốn thứ $3, 6, 9, 12, 15\dots$ được miễn phí, đều có số thứ tự chia hết cho 3; do ta đánh index bắt đầu từ 0 nên giảm đi 1 đơn vị thành $2, 5, 8, 11, 14\dots$
index i012345
giá a[i]655554
i + 1123 ✔456 ✔

Ví dụ đề sau khi sắp giảm dần: các ô đỏ (index 2 và 5) là cuốn được miễn phí, cộng các ô xanh: $6 + 5 + 5 + 5 = 21$.

Vậy sau khi sắp xếp mảng giảm dần, ta duyệt từ index 0 trở đi: index nào cộng thêm 1 mà không chia hết cho 3 thì tính tiền (cộng vào biến sum), còn index nào cộng thêm 1 chia hết cho 3 thì bỏ qua vì đó là cuốn được miễn phí. Sau cùng biến sum là số tiền ít nhất phải trả theo chiến lược tham lam hehe 😁

Em nhập giá các cuốn sách rồi bấm Tiếp để xem từng lượt mua:

Ví dụ nhanh:

100%Code O(N log N) với hàm sort của STL

Source code các bạn tham khảo để nhìn thấy rõ tất cả những gì mình đã phân tích ở trên:

#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    int a[n];
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    sort(a, a + n, greater<int>());
    long long sum = 0;
    for(int i = 0; i < n; ++i){
        if((i + 1) % 3 != 0){
            sum += a[i];
        }
    }
    cout << sum;
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n = int(data[0])
a = sorted(map(int, data[1:n + 1]), reverse=True)
total = 0
for i in range(n):
    if (i + 1) % 3 != 0:
        total += a[i]
print(total)
C++▶ Chạy tay từng dòng codeTham lam: sort giảm dần, bỏ qua các cuốn có (i + 1) chia hết cho 3 – O(N log N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int n;
8 cin >> n;
9 int a[n];
10 for(int i = 0; i < n; ++i){
11 cin >> a[i];
12 }
13 sort(a, a + n, greater<int>());
14 long long sum = 0;
15 for(int i = 0; i < n; ++i){
16 if((i + 1) % 3 != 0){
17 sum += a[i];
18 }
19 }
20 cout << sum;
21 return 0;
22}

Giải thích một số chỗ trong code

  1. ios::sync_with_stdio(0); cin.tie(0); 2 dòng đầu tiên của hàm main 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 cần nhập xuất nhiều, như bài này có tới $10^5$ số cần đọc vào mà giới hạn thời gian chỉ 100 ms, ta hãy nên để 2 dòng này vào để chạy nhanh hơn. 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 2 dòng này vào đầu hàm main nhé.
  2. sort(a, a + n, greater<int>()): sắp xếp $n$ phần tử đầu của mảng $a$ theo thứ tự giảm dần (mặc định sort sắp tăng dần, thêm greater<int>() để đảo chiều).
  3. Biến sum phải là long long: tổng có thể lên tới khoảng $\frac{2}{3} \cdot 10^5 \cdot 10^6 \approx 6{,}7 \cdot 10^{10}$, vượt xa giới hạn của int (khoảng $2{,}1 \cdot 10^9$).
Không gian$O(N)$
Thời gian$O(N \log N)$

Thời gian gồm $O(N)$ đọc dữ liệu + $O(N \log N)$ sắp xếp bằng hàm sort trong STL + $O(N)$ duyệt tính tiền. Theo quy tắc cộng BigO ta lấy đại diện theo thành phần lớn nhất nên độ phức tạp tổng quát là $O(N \log N)$.

Đề bài này giới hạn chạy tối đa trong 100 ms thì ngưỡng tối đa sẽ là $(3 \text{ đến } 5) \cdot 10^6$ phép tính, cao lắm là $10^7$. Với $N$ lớn nhất là $10^5$ thì số lần lặp chỉ cỡ $10^5 + 10^5 \cdot \log_2(10^5) + 10^5$ $\approx 19 \cdot 10^5$, làm tròn dư dả là $2 \cdot 10^6$, yên tâm chưa vượt ngưỡng nên sẽ không sợ bị TLE.

Cẩn thậnDùng sắp xếp O(N²) thì sao?

Có thể nói phức tạp nhất của cách làm này là ở bước sắp xếp mảng giảm dần. Nếu bạn nào không dùng những thuật toán sắp xếp tối ưu mà dùng những thuật toán như Bubble Sort, Interchange Sort, Selection Sort… với độ phức tạp $O(N^2)$ thì bảo đảm TLE ngay với giới hạn 100 ms của bài này. Thậm chí có là giới hạn 1000 ms (1 giây) như thường lệ thì vẫn TLE luôn, bởi với $N = 10^5$ thì $N^2 = 10^{10}$ quá ngưỡng 1 giây rồi.

Để các bạn thấy rõ, mình viết thêm bản dùng Interchange Sort (đổi chỗ trực tiếp) thay cho hàm sort, phần còn lại giữ nguyên:

Code dùng Interchange Sort O(N²) – chỉ qua các subtask nhỏ
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n;
    cin >> n;
    int a[n];
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    for(int i = 0; i < n - 1; ++i){
        for(int j = i + 1; j < n; ++j){
            if(a[i] < a[j]){
                swap(a[i], a[j]);
            }
        }
    }
    long long sum = 0;
    for(int i = 0; i < n; ++i){
        if((i + 1) % 3 != 0){
            sum += a[i];
        }
    }
    cout << sum;
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
a = [int(x) for x in data[1:n + 1]]
for i in range(n - 1):
    for j in range(i + 1, n):
        if a[i] < a[j]:
            a[i], a[j] = a[j], a[i]
total = 0
for i in range(n):
    if (i + 1) % 3 != 0:
        total += a[i]
print(total)
C++▶ Chạy tay từng dòng codeCùng tư tưởng nhưng tự sắp xếp bằng Interchange Sort – O(N²).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 int n;
6 cin >> n;
7 int a[n];
8 for(int i = 0; i < n; ++i){
9 cin >> a[i];
10 }
11 for(int i = 0; i < n - 1; ++i){
12 for(int j = i + 1; j < n; ++j){
13 if(a[i] < a[j]){
14 swap(a[i], a[j]);
15 }
16 }
17 }
18 long long sum = 0;
19 for(int i = 0; i < n; ++i){
20 if((i + 1) % 3 != 0){
21 sum += a[i];
22 }
23 }
24 cout << sum;
25 return 0;
26}

Trên máy mình bản này chạy khoảng 0.06 giây với $N = 10^4$ (nên chỉ ăn được các subtask đầu, cỡ 50% đến 70% số test tuỳ máy chấm), còn với $N = 10^5$ thì mất tới hơn 13 giây!

Không gian$O(N)$
Thời gian$O(N^2)$

Vì thế phải dùng những thuật toán sắp xếp tối ưu như Quick Sort, Merge Sort, Heap Sort với độ phức tạp $O(N \log N)$ thì mới đảm bảo được. Bình thường thì mình dùng luôn hàm sort có trong thư viện STL C++, nhanh gọn gọi 1 dòng ra xài thôi 🥰, chứ tự code thì vừa dài mà còn có thể sai, hoặc ví dụ Quick Sort nếu rơi vào trường hợp xấu nhất thì độ phức tạp vẫn là $O(N^2)$ nhé. Còn hàm sort trong STL C++ luôn đảm bảo $O(N \log N)$ trong mọi trường hợp: nó dùng thuật Intro Sort, là sự kết hợp, mặc định dùng Quick Sort nhưng nếu phát hiện rơi vào trường hợp xấu thì tự chuyển qua Heap Sort.

Ghi chú kiểm tra của mình: code C++ của bài là của mình trong lúc hướng dẫn; bản Interchange Sort và các bản Python là mình viết thêm. Tất cả đã được kiểm tra với 300 bộ test ngẫu nhiên ($n \le 12$, giá nhỏ để có nhiều cuốn trùng giá, có cả dữ liệu trên 1 dòng và mỗi số 1 dòng) so với chương trình vét cạn quy hoạch động trên tập con (thử mọi cách chia lượt mua 1, 2 hoặc 3 cuốn). Với $N = 10^5$, bản C++ dùng sort chạy khoảng 0.015 giây, bản Python khoảng 0.06 giây; kết quả lớn nhất $66\,667\,000\,000$ (khi mọi cuốn giá $10^6$) xác nhận phải dùng long long.
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 lời bình luận hoặc chia sẻ bài viết để độ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 😇

Nhận xét