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

Cặp số hoàn hảo

Cặp số hoàn hảo
Nguồn: Tin học trẻ bảng B – Kim Sơn 2023–2024 Độ khó: Trung bình Map / Set Hai con trỏ

Đề bài

Cặp số hoàn hảo – capso.*

Hai số nguyên được gọi là một “Cặp số hoàn hảo” nếu như tổng của chúng bằng giá trị $s$ cho trước.

Hai bạn học sinh lúc ngồi nhàn rỗi nghĩ ra trò chơi sau đây để tăng khả năng tư duy toán học: cho dãy số $a_1, a_2, \ldots, a_n$, các bạn chọn trong dãy số gồm $n$ số nguyên sao cho giá trị của hai số đã chọn $a_i, a_j$ với $i < j$ thỏa mãn $a_i + a_j = s$ là cặp số hoàn hảo.

Cho một dãy số nguyên $a_1, a_2, \ldots, a_n$ và số nguyên $s$.

Yêu cầu: Em hãy lập trình đếm xem trong dãy số đã cho có bao nhiêu cặp số hoàn hảo.

Dữ liệu vào:

  • Dòng thứ nhất ghi số nguyên dương $n$ $(n \le 10^6)$ và số nguyên $s$ $(|s| \le 10^9)$.
  • Các dòng tiếp theo lần lượt ghi các số $a_1, a_2, \ldots, a_n$ $(|a_i| \le 10^9)$.

Kết quả ra: Ghi ra một số nguyên duy nhất là số lượng cặp số hoàn hảo.

Ràng buộc:

  • Có 60% điểm ứng với $1 \le n \le 10^3$;
  • Có 40% điểm ứng với $1 \le n \le 10^6$.

Lưu ý trên CLUE OJ: dữ liệu nhập từ bàn phím và in ra màn hình. Tuy đề gốc cho giới hạn thời gian 1 giây, nhưng vì máy chấm VNOJ rất mạnh còn máy chấm thực tế của ban giám khảo có thể không mạnh bằng, nên trên web VNOJ thầy chỉ cho 0.3 giây. Bài trong kỳ thi chỉ được nộp 1 lần duy nhất, vì vậy hãy kiểm tra thật kỹ rồi mới nộp.

Ví dụ – input
10 7
5 2 5 3 4 3 1 6 4 0
Ví dụ – output
7

Lời mở đầu

Đây là bài "đếm cặp có tổng bằng $s$" kinh điển – gần như đề thi học sinh giỏi nào cũng có một bài na ná như vậy. Với $n \le 10^3$ thì hai vòng for là xong, nhưng với $n = 10^6$ thì số cặp lên tới khoảng $5 \cdot 10^{11}$, không thể thử hết. Mình sẽ đi từ cách vét cạn, sang cách dùng map để "nhớ" các số đã gặp, rồi tới cách sắp xếp + hai con trỏ chạy nhanh nhất. Các bạn chú ý cả chuyện kiểu dữ liệu nữa nhé: đáp án có thể vượt quá phạm vi int.

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ó 100 test. Mình thống kê theo từng nhóm:

TestnGiá trị $a_i$sĐáp án lớn nhấtSố test đáp án 0
1–109 / 10 / 21 / 24 / 28 / 29 / 30-59 … 60-46 … 37111
11–601.000-32.767 … 32.767-31.479 … 31.964202
61–7010.000-32.767 … 32.767-31.690 … 19.6517690
71–80100.000-32.767 … 32.767-30.841 … 28.11772.2020
81–1001.000.000-32.767 … 32.767-30.592 … 30.0377.494.9560

Một vài nhận xét quan trọng:

  • Giá trị $a_i$ trong test thật chỉ nằm trong khoảng $[-32767, 32767]$ (nhỏ hơn nhiều so với $10^9$ của đề), nhưng ta vẫn phải code theo đúng giới hạn của đề: $a_i + a_j$ có thể tới $2 \cdot 10^9$, vượt int, nên mình dùng long long.
  • Đáp án lớn nhất trong bộ test khoảng 7,5 triệu, nhưng về lý thuyết nếu cả $10^6$ số đều bằng nhau và $s$ bằng 2 lần số đó thì đáp án là $\frac{10^6 \cdot (10^6 - 1)}{2} \approx 5 \cdot 10^{11}$ – bắt buộc biến đếm phải là long long.
  • Ở các test $n = 10^6$, dãy có rất nhiều số trùng nhau (chỉ có 65535 giá trị khác nhau), nên cách xử lý số trùng phải thật chuẩn.

Bảng subtask (đối chiếu với test thật) và cách giải nào ăn được subtask nào:

SubtaskĐiều kiệnTestCách 1
(vét cạn)
Cách 2
(map)
Cách 3
(sắp xếp)
1 (60%)$n \le 10^3$1–60✔✔✔
2 (40%)$n \le 10^6$61–100chỉ test 61–70chậm ở $n = 10^6$✔

Ý tưởngĐứng ở $a_j$, hỏi "phía trước có bao nhiêu số $s - a_j$?"

Cố định số đứng sau $a_j$ của cặp. Số đứng trước $a_i$ phải bằng đúng $s - a_j$. Vậy số cặp có số sau là $a_j$ chính là số lần giá trị $s - a_j$ đã xuất hiện ở các vị trí trước $j$.

Thuật toán (đếm bằng map)
Duyệt $j$ từ trái sang phải, giữ cnt[v] = số lần giá trị $v$ đã gặp. Tại mỗi $j$: cộng cnt[s - a[j]] vào đáp án, rồi mới tăng cnt[a[j]].

Thứ tự "cộng trước, tăng sau" rất quan trọng: nó đảm bảo $i < j$ và một số không tự ghép với chính nó (ví dụ $s = 10$, $a_j = 5$ thì không được tính cặp $(a_j, a_j)$). Vì $|a_i|$ tới $10^9$ nên không dùng mảng đếm được, phải dùng map.

Em nhập $s$ và dãy số rồi bấm Tiếp để xem từng bước (ô vàng là số đang xét, ô xanh là các số phía trước ghép được với nó):

Ví dụ nhanh:

Cách 1Vét cạn mọi cặp – Subtask 1

Cách tự nhiên nhất: thử mọi cặp $i < j$, cặp nào có tổng bằng $s$ thì đếm. Có $\frac{n(n-1)}{2}$ cặp.

C++▶ Chạy tay từng dòng codeCách 1: thử mọi cặp i < j, O(n²).
Dữ liệu mẫu:
Test đề có 45 cặp nên khá dài – em có thể bấm ⏭ hoặc chọn tốc độ "Rất nhanh".
  1. Vòng j bắt đầu từ i + 1 nên mỗi cặp chỉ được xét đúng 1 lần và không có cặp $(i, i)$.
  2. a[] và dem đều là long long để không bị tràn số.
Không gian$O(n)$
Thời gian$O(n^2)$

Kết quả chạy thật: bản C++ đúng 70/100 test (đúng test 1–70; test 71–100 vượt 1 giây), test đúng chạy lâu nhất 0.07s; bản Python đúng 60/80 test (mình chỉ chạy test 1–80 vì vét cạn Python với $n = 10^6$ chạy cả ngày không xong). Đúng như dự đoán: ăn trọn Subtask 1 và cả các test $n = 10^4$, còn từ $n = 10^5$ trở đi thì số cặp quá lớn.

Cách 2Dùng map nhớ các số đã gặp

Cài đặt đúng ý tưởng ở trên: mỗi phần tử chỉ cần 1 lần tìm và 1 lần thêm vào map. Mình còn không cần lưu cả mảng $a$: đọc số nào xử lý luôn số đó.

C++▶ Chạy tay từng dòng codeCách 2: đứng ở x, cộng số lần s − x đã gặp (map), rồi mới thêm x vào map.
Dữ liệu mẫu:
  1. cnt.find(s - x) trả về vị trí của khoá s - x trong map, hoặc cnt.end() nếu không có. Dùng find thay cho cnt[s - x] để không vô tình tạo thêm khoá rỗng (map sẽ phình to và chậm đi).
  2. cnt[x]++ đặt sau lệnh cộng đáp án – đúng thứ tự "cộng trước, tăng sau".
  3. Bản Python dùng dict, tương tự unordered_map của C++ (bảng băm).
Không gian$O(n)$
Thời gian$O(n \log n)$

Kết quả chạy thật: bản C++ đúng 86/100 test (đúng test 1–80, 85, 87–88, 91–92, 100; test 81–84, 86, 89–90, 93–99 vượt 1 giây), test đúng chạy lâu nhất 0.94s; bản Python đúng 99/100 test. Kết quả luôn đúng, nhưng các test $n = 10^6$ chạy sát hoặc vượt 1 giây.

Vì sao map vẫn chậm?
Mỗi thao tác trên map mất $O(\log n)$ và phải nhảy qua nhiều nút cây nằm rải rác trong bộ nhớ. Với $10^6$ số là khoảng $2 \cdot 10^6$ thao tác cây – vẫn là $O(n \log n)$ nhưng "hằng số" lớn. Đề lại còn ép 0.3 giây trên VNOJ, nên ta cần một cách gọn nhẹ hơn.

Cách 3Sắp xếp + hai con trỏ – Full điểm

Sắp xếp tăng dần, đặt con trỏ l ở đầu, r ở cuối:

  • Nếu a[l] + a[r] < s: tổng nhỏ quá, mà a[l] ghép với số lớn nhất còn chưa đủ, nên a[l] không ghép được với ai → l++.
  • Nếu a[l] + a[r] > s: tương tự, a[r] bị loại → r--.
  • Nếu bằng $s$: có thể có nhiều số bằng a[l] và nhiều số bằng a[r] liền nhau. Đếm khối bằng a[l] ($c_1$ số) và khối bằng a[r] ($c_2$ số), cộng $c_1 \cdot c_2$ cặp rồi bỏ cả 2 khối.
  • Trường hợp đặc biệt a[l] == a[r]: cả đoạn từ l tới r toàn số bằng nhau (bằng $s/2$), chọn 2 trong $k = r - l + 1$ số được $\frac{k(k-1)}{2}$ cặp, rồi dừng.
C++▶ Chạy tay từng dòng codeCách 3: sắp xếp rồi kẹp hai con trỏ l, r từ hai đầu, gom các khối số bằng nhau.
Dữ liệu mẫu:
  1. Trong nhánh else cuối ta có a[l] < a[r], nên các vòng while gom khối không bao giờ chạy lố sang khối bên kia.
  2. Bản Python mình dùng Counter đếm số lần xuất hiện của từng giá trị $x$, rồi ghép $x$ với $y = s - x$ (chỉ tính khi $x < y$ để không đếm 2 lần, còn $x = y$ thì cộng $\binom{c}{2}$). Cùng tư tưởng "gom các số bằng nhau" như hai con trỏ.
Không gian$O(n)$
Thời gian$O(n \log n)$ (sắp xếp) + $O(n)$ (hai con trỏ)

Kết quả chạy thật: bản C++ đúng 100/100 test, chạy lâu nhất 0.19s; bản Python đúng 100/100 test. Full điểm với thời gian rất thoải mái.

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

CáchNgôn ngữĐúngLâu nhấtGhi chú
1. Vét cạn $O(n^2)$C++70/100> 1sĐúng test 1–70; test 71–100 quá thời gian
Python60/80> 1sChỉ chạy thử test 1–80: đúng 1–60, test 61–80 quá thời gian
2. Đếm bằng mapC++86/100> 1sĐúng hết nhưng 14 test $n = 10^6$ chạy quá 1 giây
Python99/100> 1sTest 88 quá 1 giây
3. Sắp xếp + 2 con trỏC++100/1000.19sFull điểm
Python100/1000.60sĐếm theo giá trị bằng Counter
Ghi chú của mình: thời gian trong bảng là thời gian CPU đo trên máy của mình, giới hạn 1 giây (máy chấm VNOJ mạnh hơn nên chạy nhanh hơn). Cách 3 là cách an toàn nhất để qua cả mức 0.3 giây. Ngoài ra nếu dùng unordered_map (có reserve trước) thay cho map ở Cách 2 thì cũng nhanh gần bằng Cách 3. Đừng quên: biến đếm phải là 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