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

Cocacola

Cocacola
Nguồn: CLUE OJ – Chương 14: Cửa sổ trượt + Tham lam Độ khó: Dễ Tham lam Mô phỏng Công thức

Đề bài

Cửa hàng cạnh nhà Hà đang có khuyến mãi như sau: nếu có $K$ vỏ chai cocacola thì người ta sẽ đổi cho một chai mới. Hiện tại Hà có $N$ chai còn nguyên, em quyết định mời hết bạn bè đến nhà để uống bằng hết và tận dụng khuyến mãi của cửa hàng để đổi mới và uống được càng nhiều càng tốt.

Yêu cầu: tính xem Hà và các bạn có thể uống được tối đa bao nhiêu chai cocacola.

Dữ liệu: gồm một dòng chứa hai số nguyên dương $N$ và $K$ $(N \le 10^9,\ 2 \le K \le 10^9)$.

Kết quả: gồm một số là kết quả tìm được.

Ví dụ – input
9 3
Ví dụ – output
13

Giải thích ví dụ: ban đầu có 9 chai, uống hết sẽ có 9 cái vỏ, lại đổi được 3 chai mới; uống hết 3 chai mới sẽ có 3 vỏ, lại đổi được 1 chai nữa. Vậy tổng là $9 + 3 + 1 = 13$.

Lời mở đầu

Bài "đổi vỏ lấy chai" là bài khởi động rất quen thuộc của phần Tham lam: cứ có đủ $K$ vỏ là đổi ngay, không có lý do gì để chờ. Cái khó (nhỏ thôi) nằm ở chỗ $N$ tới $10^9$, nên mình sẽ đi qua 3 cách: mô phỏng đổi từng chai, mô phỏng đổi cả lô và cuối cùng là một công thức $O(1)$ – mỗi cách đều chạy thật trên bộ test của CLUE.

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:

Bộ testBộ test chính thức có gì?

TestNKĐáp ánSố lần đổi (≈ số vòng lặp của Cách 1)
1 – 301 … 492 … 101 … 650 … 32
31 – 951.231.821 … 982.668.5942 … 9941.233.616 … 1.219.371.1391.795 … 609.685.569
96 – 1001.000.000.0004 … 101.111.111.111 … 1.333.333.333111.111.111 … 333.333.333
  • Bộ test có 100 test. Đề không chia subtask, nhưng bộ test chia rõ 2 nhóm: test 1–30 rất nhỏ ($N \le 49$, $K \le 10$), test 31–100 có $N$ tới $10^9$ (5 test cuối $N = 10^9$ đúng bằng giới hạn).
  • Có 6 test với $K = 2$ (test 10, 13, 16, 20, 68, 71) – trường hợp số lần đổi nhiều nhất. Có các test $N < K$ (test 2, 18, 24–25): không đổi được chai nào, đáp án chính là $N$.
  • Đáp án lớn nhất trong bộ test là 1.333.333.333. Về lý thuyết đáp án có thể tới gần $2 \cdot 10^9$ (khi $N = 10^9$, $K = 2$), rất sát giới hạn của int ($\approx 2{,}147 \cdot 10^9$), chỉ cần cộng thêm chút là tràn số – cứ dùng long long cho chắc.

Kết quả chạy thật của các cách trên từng nhóm test:

NhómĐiều kiệnCác testCách 1
C++
Cách 1
Python
Cách 2
C++ / Python
Cách 3
C++ / Python
Nhỏ$N \le 49$1 – 3030/3030/3030/3030/30
Lớn$N \le 10^9$31 – 10070/7035/7070/7070/70

Ý tưởngCó đủ K vỏ là đổi ngay

Ta theo dõi 2 con số: uong – số chai đã uống, và vo – số vỏ đang cầm trong tay.

  • Ban đầu uống hết $N$ chai: uong = N, vo = N.
  • Chừng nào còn vo >= K thì mang $K$ vỏ đi đổi lấy 1 chai, uống luôn chai đó (uong tăng 1) và lại có thêm 1 vỏ.

Vì sao tham lam đúng? Mỗi lần đổi chỉ làm lợi (thêm 1 chai uống) và số vỏ không bao giờ "hết hạn", nên đổi sớm hay đổi muộn cũng như nhau – miễn là không bỏ phí lần đổi nào. Quá trình chỉ dừng khi số vỏ còn lại $< K$, lúc đó chắc chắn không thể đổi thêm.

Em nhập $N$, $K$ rồi bấm Tiếp để xem từng lượt uống – đổi (chai đỏ là chai còn nguyên, ô xám là vỏ; các vỏ cùng màu viền được gom thành một nhóm $K$ vỏ để đổi):

Ví dụ nhanh:

Cách 1Đổi từng chai một

Cài đặt đúng như lời kể: mỗi vòng lặp đổi đúng một chai.

C++▶ Chạy tay từng dòng codeCách 1: mỗi vòng lặp đổi đúng 1 chai (k vỏ → 1 chai → 1 vỏ).
Dữ liệu mẫu:
  1. Mỗi vòng lặp: vo -= k (đưa $K$ vỏ cho cửa hàng), uong++ (uống chai mới), vo++ (chai vừa uống thành vỏ). Tức là mỗi lần đổi, số vỏ giảm đúng $K - 1$.
  2. Số vòng lặp bằng số lần đổi, khoảng $\dfrac{N}{K - 1}$. Với $K = 2$ và $N = 10^9$ là gần $10^9$ vòng lặp!
Không gian$O(1)$
Thời gian$O\left(\dfrac{N}{K - 1}\right)$

Kết quả chạy thật: bản C++ đúng 100/100 test, test đúng chạy lâu nhất 0.218s; bản Python đúng 65/100 test (quá thời gian ở test 31, 41, 45, 48, 51–53, 59, 67–68, 70–72, 76, 78–79, 81, 83–100), test đúng chạy lâu nhất 0.573s.

Bản C++ qua hết là vì vòng lặp cực kỳ đơn giản (3 phép tính, trình biên dịch tối ưu rất tốt) và bộ test lớn nhất chỉ cần khoảng $6{,}1 \cdot 10^8$ lần đổi. Bản Python thì không có may mắn đó: các test có $K$ nhỏ đều quá thời gian. Ta cần cách đổi nhanh hơn.

Cách 2Đổi cả lô một lần

Thay vì đổi từng chai, mỗi lượt ta mang tất cả vỏ đi đổi: với vo vỏ thì đổi được moi = vo / k chai mới, còn dư vo % k vỏ lẻ. Uống hết moi chai mới thì có thêm moi vỏ. Đây chính là cách đề bài giải thích ví dụ ($9 \to 3 \to 1$).

C++▶ Chạy tay từng dòng codeCách 2: mỗi lượt đổi tất cả vỏ đang có, số lượt chỉ khoảng log N.
Dữ liệu mẫu:
  1. vo = vo % k + moi: vỏ lẻ còn dư cộng với vỏ của các chai vừa uống.
  2. Sau mỗi lượt số vỏ còn khoảng $\dfrac{vo}{K} + K$, tức là giảm ít nhất một nửa (vì $K \ge 2$) cho đến khi xuống dưới $2K$. Nên số lượt chỉ khoảng $\log_2 N \approx 30$.
Không gian$O(1)$
Thời gian$O(\log N)$

Kết quả chạy thật: bản C++ đúng 100/100 test, test đúng chạy lâu nhất 0.006s; bản Python đúng 100/100 test, test đúng chạy lâu nhất 0.035s.

Cách 3Một công thức O(1)

Nhìn lại Cách 1: mỗi lần đổi, số vỏ giảm đúng $K - 1$. Được đổi khi còn ít nhất $K$ vỏ, tức là sau khi đổi vẫn còn ít nhất 1 vỏ. Vậy: bắt đầu với $N$ vỏ, đổi $e$ lần được khi và chỉ khi

$N - e \cdot (K - 1)$ $\ge 1 \iff e$ $\le \dfrac{N - 1}{K - 1}$

Công thức
Số lần đổi tối đa là $e = \left\lfloor \dfrac{N - 1}{K - 1} \right\rfloor$, nên đáp án $= N + \left\lfloor \dfrac{N - 1}{K - 1} \right\rfloor$.

Kiểm tra với ví dụ: $9 + \lfloor 8 / 2 \rfloor$ $= 9 + 4$ $= 13$. Đúng! Cách nhìn dễ nhớ: giữ riêng 1 vỏ ra, $N - 1$ vỏ còn lại chia thành các nhóm $K - 1$ vỏ – mỗi nhóm cùng với "vỏ giữ riêng" đủ $K$ vỏ để đổi một chai, uống xong chai đó lại có lại đúng 1 vỏ để giữ riêng.

C++▶ Chạy tay từng dòng codeCách 3: công thức N + ⌊(N − 1) / (K − 1)⌋.
Dữ liệu mẫu:
Không gian$O(1)$
Thời gian$O(1)$

Kết quả chạy thật: bản C++ đúng 100/100 test, test đúng chạy lâu nhất 0.006s; bản Python đúng 100/100 test, test đúng chạy lâu nhất 0.033s.

Tổng kếtKết quả chạy thật trên bộ test

CáchNgôn ngữĐúngLâu nhất (test đúng)Ghi chú
1. Đổi từng chaiC++100/1000.218sFull điểm
Python65/1000.573sQuá thời gian ở test 31, 41, 45, 48, 51–53, 59, 67–68, 70–72, 76, 78–79, 81, 83–100
2. Đổi cả lôC++100/1000.006sFull điểm
Python100/1000.035sFull điểm
3. Công thứcC++100/1000.006sFull điểm
Python100/1000.033sFull điểm
Ghi chú của mình: thời gian trong bảng là thời gian CPU trên máy của mình, giới hạn 1 giây/test. Cách 2 và Cách 3 chạy mọi test gần như tức thì với cả C++ và Python. Bài học: khi một vòng lặp mô phỏng "từng đơn vị" quá chậm, hãy thử gộp nhiều bước giống nhau thành một bước (đổi cả lô), rồi nhìn xem mỗi bước làm thay đổi đại lượng nào một lượng cố định – thường sẽ ra công thức.
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