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

Bắt tay ở kỳ thi

Bắt tay ở kỳ thi
Nguồn: CLUE OJ Độ khó: Dễ Nhập xuất Tổ hợp Long long

Đề bài

Trường của Tèo có tổng cộng 30 lớp, mỗi lớp cử $N$ bạn đi dự một kỳ thi lập trình. Trước giờ thi, để chúc nhau may mắn, mỗi bạn bắt tay đúng một lần với mỗi bạn khác (dù cùng lớp hay khác lớp).

Hỏi có tất cả bao nhiêu cái bắt tay?

Dữ liệu vào: Một dòng chứa số nguyên dương $N$.

Kết quả: In ra tổng số cái bắt tay.

Giới hạn:

  • $1 \le N \le 10^6$

Subtask:

  • Subtask 1 (40% số điểm): $N \le 35$.
  • Subtask 2 (60% số điểm): không có ràng buộc gì thêm.

Giới hạn thời gian: 1 giây, bộ nhớ: 256 MB. Dữ liệu nhập từ bàn phím và in ra màn hình.

Ví dụ 1 – input
1
Ví dụ 1 – output
435
Ví dụ 2 – input
2
Ví dụ 2 – output
1770

Giải thích: Ví dụ 1: có $30 \cdot 1 = 30$ bạn, mỗi cặp hai bạn bắt tay đúng một lần nên số cái bắt tay là $\dfrac{30 \cdot 29}{2} = 435$.

Nguồn: phỏng theo bài "BẮT TAY" – vinhdinhcoder.net.

Lời mở đầu

Đây là bài nhập môn rất quen thuộc của phần tổ hợp: đếm số cặp. Code đúng chỉ có 1 dòng công thức, nhưng bài này có hai "cái bẫy" kinh điển mà năm nào mình cũng thấy các em sập: vòng lặp quá chậm và tràn số kiểu int. Mình sẽ đi lần lượt từ cách "đếm tay" bằng 2 vòng lặp, rút xuống 1 vòng lặp, rồi tới công thức $O(1)$ – và chạy thật cả 3 cách trên bộ test để các em thấy mỗi cách được bao nhiêu điểm.

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

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ì?

Bộ test có 20 test, mỗi test chỉ là một số $N$. Mình đặt $m = 30N$ là tổng số bạn đi thi. Mình đã kiểm tra: test 1–8 đúng là có $N \le 35$ (subtask 1), test 9–20 là subtask 2.

NhómTest$N$$m = 30N$ lớn nhấtĐáp án lớn nhấtSố test $m(m-1) > 2^{31}-1$
Subtask 11–81 … 351.050550.7250
Subtask 29–2036 … 1.000.00030.000.000449.999.985.000.0008
  • Ở subtask 2, $N$ lớn nhất là $10^6$ nên có tới $m = 3 \cdot 10^7$ bạn, đáp án lên tới 449.999.985.000.000 – lớn hơn rất nhiều so với giới hạn của kiểu int ($2^{31} - 1 = 2\,147\,483\,647$).
  • Subtask 2 vẫn có vài test $N$ nhỏ (test 12, 16, 18, 19 có $N \le 1000$), nên một cách "chưa tối ưu" vẫn có thể ăn thêm vài test lẻ ở subtask 2.
  • Có 8 test (test 9–11, 13–15, 17, 20) mà tích $m(m-1)$ vượt quá $2^{31} - 1$, tức là $N \ge 1545$ – đây là các test "bắt" lỗi tràn số.

Kết quả chạy thật của các cách bên dưới theo từng subtask:

SubtaskĐiều kiệnTestCách 1
2 vòng lặp
Cách 2
1 vòng lặp
Cách 3
công thức, int
Cách 3
long long
Subtask 1 (40%)$N \le 35$1–88/88/88/88/8
Subtask 2 (60%)$N \le 10^6$9–204/1212/124/1212/12

(Kết quả bản C++; bản Python xem ở từng cách và bảng tổng kết.)

Ý tưởngMỗi cái bắt tay là một cặp 2 bạn

Có $m = 30N$ bạn. Mỗi cái bắt tay ứng với đúng một cặp hai bạn khác nhau (không quan tâm thứ tự: "An bắt tay Bình" cũng chính là "Bình bắt tay An"). Chuyện cùng lớp hay khác lớp không ảnh hưởng gì – 30 lớp chỉ để cho biết tổng số bạn là $30N$.

Một cách đếm rất trực quan: cho các bạn lần lượt bước vào phòng. Bạn thứ $i$ bước vào sẽ bắt tay với $i - 1$ bạn đã có mặt từ trước. Vậy tổng số cái bắt tay là

$$0 + 1 + 2 + \dots + (m - 1) = \dfrac{m(m-1)}{2} = C_m^2.$$

Em nhập số lớp và số bạn mỗi lớp rồi bấm Tiếp để xem từng bạn bước vào (đường màu cam là các cái bắt tay mới):

Ví dụ nhanh:
Đề cố định 30 lớp; em có thể giảm số lớp để hình thưa, dễ đếm hơn.

Cách 1Hai vòng lặp đếm từng cặp – $O(m^2)$

Cách "thật thà" nhất: đánh số các bạn từ $1$ đến $m$, với mỗi bạn $i$ duyệt mọi bạn $j$; nếu $i \ne j$ thì bạn $i$ bắt tay bạn $j$, tăng biến đếm. Vì mỗi cái bắt tay giữa $i$ và $j$ bị đếm 2 lần (một lần ở cặp $(i, j)$, một lần ở cặp $(j, i)$) nên cuối cùng chia đôi.

C++▶ Chạy tay từng dòng codeCách 1: hai vòng lặp duyệt mọi cặp (i, j), đếm i != j rồi chia 2 – O(m²).
Dữ liệu mẫu:
Mỗi hàng của bảng giống hệt nhau nên khung chạy tay tua nhanh phần lớn các vòng lặp.
  1. Biến dem phải là long long vì nó đếm tới $m(m-1)$.
  2. Hai vòng lặp chạy tất cả $m \cdot m$ ô của bảng (như hình trong khung chạy tay), nên số phép tính là $m^2 = 900N^2$. Với $N = 35$: chỉ khoảng $10^6$ phép – rất nhanh. Với $N = 10^6$: $9 \cdot 10^{14}$ phép – máy chạy cả ngày cũng không xong!
Không gian$O(1)$
Thời gian$O(m^2) = O(900N^2)$

Kết quả chạy thật: bản C++ đúng 12/20 test, quá thời gian ở test 9–11, 13–15, 17, 20; test đúng chạy lâu nhất 0.42s. Bản Python đúng 11/20 test, quá thời gian ở test 9–11, 13–15, 17–18, 20; test đúng chạy lâu nhất 0.95s. Đúng trọn subtask 1, còn ở subtask 2 chỉ "ăn ké" được các test có $N$ nhỏ.

Chuyện lạ: trình biên dịch "giải hộ"
Nếu viết vòng trong là for(j = i + 1; j <= m; j++) dem++; (chỉ đếm cặp $i < j$, không cần chia 2) thì mình chạy thử: bản C++ đó đúng 20/20 test; test đúng chạy lâu nhất 0.02s! Lý do: với -O2, g++ nhận ra vòng trong chỉ là "cộng thêm $m - i$" nên tự thay bằng phép cộng, chương trình còn $O(m)$. Đừng trông chờ vào may mắn này: chỉ cần thêm một câu if như code ở trên là trình biên dịch chịu thua, và các trình chấm/cờ biên dịch khác có thể cho kết quả khác.

Cách 2Một vòng lặp – $O(m)$

Dùng đúng ý tưởng "lần lượt bước vào phòng": bạn thứ $i$ bắt tay $i - 1$ bạn đến trước, nên chỉ cần một vòng lặp cộng dồn $i - 1$.

C++▶ Chạy tay từng dòng codeCách 2: bạn thứ i bắt tay i − 1 bạn đến trước, cộng dồn – O(m).
Dữ liệu mẫu:
  1. Không còn phải xét từng cặp, mỗi bạn chỉ tốn 1 phép cộng → $m = 3 \cdot 10^7$ phép cộng, C++ chạy chưa tới 0,1 giây.
  2. Python thì chậm hơn C++ cỡ vài chục lần với vòng lặp, $3 \cdot 10^7$ vòng là quá sức trong 1 giây.
Không gian$O(1)$
Thời gian$O(m) = O(30N)$

Kết quả chạy thật: bản C++ đúng 20/20 test; test đúng chạy lâu nhất 0.01s. Bản Python đúng 16/20 test, quá thời gian ở test 9–10, 14, 17; test đúng chạy lâu nhất 0.32s.

Cách 3Công thức $C_m^2 = \dfrac{m(m-1)}{2}$ – $O(1)$

Tổng $0 + 1 + \dots + (m - 1)$ có công thức quen thuộc $\dfrac{m(m-1)}{2}$. Đây cũng chính là số cách chọn 2 bạn trong $m$ bạn: $C_m^2$. Chú ý $m$ và $m - 1$ là hai số liên tiếp nên luôn có một số chẵn → $m(m-1)$ chia hết cho 2, phép chia nguyên không làm mất gì.

Bẫy kinh điển: tràn số int

Nhiều bạn viết như sau, và còn rất "cẩn thận" khai báo biến kết quả là long long:

C++▶ Chạy tay từng dòng codeBẫy: n kiểu int nên phép nhân bị tràn trước khi gán vào long long.
Dữ liệu mẫu:
  1. n là int, các hằng 30, 1, 2 cũng là int → toàn bộ biểu thức 30 * n * (30 * n - 1) / 2 được máy tính bằng kiểu int (32 bit, tối đa $2\,147\,483\,647$).
  2. Chỉ sau khi tính xong (đã tràn) kết quả mới được gán sang long long ans – lúc này đã quá muộn, giá trị sai đã được "đóng gói" rồi.
  3. Với $N = 10^6$: $m(m-1) \approx 9 \cdot 10^{14}$, gấp hơn 400 nghìn lần giới hạn int, máy chỉ giữ lại 32 bit cuối → ra một số âm vô nghĩa.

Kết quả chạy thật: bản C++ này đúng 12/20 test, sai ở test 9–11, 13–15, 17, 20; test đúng chạy lâu nhất 0.00s – sai đúng 8 test có $m(m-1) > 2^{31}-1$ (test 9–11, 13–15, 17, 20), mất gần hết subtask 2 dù thuật toán hoàn toàn đúng!

Em kéo $N$ lên dần để xem lúc nào ô nhớ int bị tràn:

Ví dụ nhanh:

Chú thích: trong C++, tràn số kiểu int là "hành vi không xác định" – chuẩn không quy định kết quả, mỗi trình biên dịch có thể ra một số rác khác nhau. Con số "code dùng int in ra" ở trên là đúng con số mình chạy thật bằng g++ -O2 (nó đổi biểu thức thành $450n^2 - 15n$ rồi tính trong 32 bit). Điều chắc chắn duy nhất: kết quả sai.

Sửa lại: dùng long long ngay từ đầu

Chỉ cần n (và m) là long long thì mọi phép nhân đều tính bằng 64 bit (tối đa khoảng $9,2 \cdot 10^{18}$), dư sức chứa $9 \cdot 10^{14}$. Một cách khác là viết 30LL * n để ép phép nhân đầu tiên sang long long.

C++▶ Chạy tay từng dòng codeCách 3: công thức C(m, 2) = m(m − 1)/2 với long long – O(1).
Dữ liệu mẫu:
  1. Với Python thì không lo tràn số: số nguyên Python tự lớn tuỳ ý. Chỉ cần nhớ dùng // (chia nguyên) thay vì /, nếu không sẽ in ra số thực kiểu 435.0.
Không gian$O(1)$
Thời gian$O(1)$

Kết quả chạy thật: bản C++ đúng 20/20 test; test đúng chạy lâu nhất 0.00s. Bản Python đúng 20/20 test; test đúng chạy lâu nhất 0.01s. Full điểm.

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

CáchNgôn ngữĐúngLâu nhấtGhi chú
1. Hai vòng lặp $O(m^2)$C++12/20> 1sQuá thời gian test 9–11, 13–15, 17, 20
Python11/20> 1sQuá thời gian test 9–11, 13–15, 17–18, 20
2. Một vòng lặp $O(m)$C++20/200.01sFull điểm
Python16/20> 1sQuá thời gian test 9–10, 14, 17
3. Công thức, dùng intC++12/200.00sSai test 9–11, 13–15, 17, 20 (tràn số)
3. Công thức, long longC++20/200.00sFull điểm
Python20/200.01sFull điểm
Ghi chú của mình: thời gian là thời gian CPU đo trên máy mình, giới hạn 1 giây; "> 1s" nghĩa là bị dừng vì quá thời gian. Ba bài học rút ra từ một bài rất ngắn: (1) ước lượng số phép tính trước khi code – $m^2 = 9 \cdot 10^{14}$ là không thể; (2) đếm cặp thì nghĩ ngay tới $C_m^2 = m(m-1)/2$; (3) đáp án vượt $2 \cdot 10^9$ thì dùng long long cho cả các biến tham gia phép nhân, không chỉ cho biến kết quả.
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