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 đôi

Cặp đôi
Nguồn: CLUE OJ – Chương 15: Ngăn xếp, Hàng đợi Độ khó: Khá Ngăn xếp Ngăn xếp đơn điệu

Đề bài

CẶP ĐÔI (PAIRS.CPP)

Có $n$ người xếp hàng dọc đánh số từ 1 tới $n$ từ đầu hàng tới cuối hàng, người thứ $i$ có chiều cao là $h_i$. Ta nói hai người $i, j$ nhìn thấy nhau nếu giữa hai người đó không tồn tại người nào khác có chiều cao $\ge \min\{h_i, h_j\}$, hay nói cách khác, tất cả những người đứng giữa người $i$ và người $j$ (nếu có) đều có chiều cao thấp hơn cả hai người này.

Yêu cầu: Đếm số cặp chỉ số $i, j$ $(i < j)$ mà hai người $i, j$ nhìn thấy nhau.

Dữ liệu:

  • Dòng 1 chứa số nguyên dương $n \le 5 \cdot 10^5$.
  • Dòng 2 chứa $n$ số nguyên dương $h_1, h_2, \ldots, h_n$ $(\forall i: h_i \le 10^6)$ cách nhau bởi dấu cách.

Kết quả: Ghi ra một số nguyên duy nhất là số cặp chỉ số $i, j$ $(i < j)$ mà hai người $i, j$ nhìn thấy nhau.

Subtask:

  • 30% điểm đầu tiên có $n \le 10^2$.
  • 30% điểm tiếp theo có $n \le 10^4$.
  • 40% điểm còn lại không có giới hạn gì thêm.

Giới hạn thời gian: 1 giây, bộ nhớ: 256 MB. Dữ liệu vào/ra chuẩn (bàn phím, màn hình) dù tên đề ghi PAIRS.CPP.

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

Lời mở đầu

Ở ví dụ 1 ($2, 1, 4, 3, 6, 5$), 7 cặp nhìn thấy nhau là: 5 cặp đứng cạnh nhau, cộng thêm cặp $(1, 3)$ (chiều cao 2 và 4, giữa là người cao 1) và cặp $(3, 5)$ (chiều cao 4 và 6, giữa là người cao 3). Cặp $(1, 5)$ thì không được vì người thứ 3 cao 4 $\ge \min(2, 6)$ che mất.

Ở ví dụ 2, năm người cao bằng nhau: hai người cạnh nhau thì luôn thấy nhau (giữa không có ai), còn hai người cách nhau thì bị người ở giữa cao bằng che mất – vì đề ghi "$\ge$". Nên đáp án chỉ là $4$. Chi tiết "bằng nhau cũng che" này rất quan trọng, lát nữa sẽ quyết định cách ta xử lý ngăn xếp.

Đây là bài kinh điển của ngăn xếp đơn điệu (monotonic stack). Mình sẽ đi từ cách vét cạn theo đúng định nghĩa, tối ưu dần, rồi tới lời giải $O(n)$ bằng ngăn xếp; cả 3 cách đều chạy thật trên bộ test chính thức.

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:

TestnChiều cao $h_i$Kiểu dãyĐáp án
1–205 … 151 … 30ngẫu nhiên, có số trùng5 … 24
21–301001 … 997.369ngẫu nhiên186 … 192
31–6010.0001 … 999.962ngẫu nhiên, nhiều số trùng19.926 … 19.953
61–70500.0001 … 999.998ngẫu nhiên, nhiều số trùng998.002 … 998.134
71–85500.0001 … 500.000hoán vị tăng dần 1, 2, …, n499.999
86–100500.0001 … 500.000hoán vị giảm dần n, …, 2, 1499.999
  • Subtask trong đề khớp đúng với bộ test: test 1–30 có $n \le 100$, test 31–60 có $n = 10^4$, test 61–100 có $n = 5 \cdot 10^5$.
  • 30 test cuối là dãy tăng dần $1, 2, \ldots, n$ (test 71–85) và giảm dần $n, \ldots, 2, 1$ (test 86–100). Đáp án của chúng đều là $n - 1$ (chỉ các cặp đứng cạnh nhau), nhưng như các bạn sẽ thấy ở cách 2, dãy giảm dần là "sát thủ" của các cách dừng sớm.
  • Đáp án lớn nhất trong bộ test chỉ khoảng $10^6$. Thật ra với điều kiện "bằng nhau cũng che", đáp án không bao giờ vượt quá $2n$ (sẽ thấy ở cách 3), nên int là đủ – mình vẫn dùng long long cho chắc tay.

Số test đúng của từng cách theo subtask (chạy thật, giới hạn 1 giây):

SubtaskĐề ghiTestCách 1
$O(n^3)$
Cách 2
(dừng sớm)
Cách 3
(ngăn xếp)
1 (30%)$n \le 10^2$1–3030/3030/3030/30
2 (30%)$n \le 10^4$31–6030/3030/3030/30
3 (40%)$n \le 5 \cdot 10^5$61–1000/4025/4040/40

Ý tưởngAi còn "lộ mặt" khi nhìn từ cuối hàng?

Thay vì hỏi "cặp $(i, j)$ có thấy nhau không", ta đi lần lượt từng người $j$ từ đầu hàng tới cuối hàng và hỏi: người $j$ nhìn về phía trước thấy được bao nhiêu người? Cộng tất cả lại là đáp án.

Nhận xét 1 – Người bị che thì bị che mãi mãi
Nếu người $i$ đứng trước người $k$ và $h_k \ge h_i$ thì mọi người đứng sau $k$ đều không thể thấy $i$ (vì $k$ đứng giữa và cao $\ge h_i \ge \min$). Ta có thể "gạch tên" $i$ khỏi danh sách ứng viên vĩnh viễn.

Vậy tại mỗi thời điểm, các người chưa bị gạch tên có chiều cao giảm dần nghiêm ngặt theo thứ tự đứng (người đứng sau thấp hơn hẳn người đứng trước, nếu không thì người trước đã bị gạch). Ta cất họ trong một ngăn xếp: đáy là người cao nhất đứng trước, đỉnh là người vừa vào.

Nhận xét 2 – Người mới tới thấy ai?
Khi người mới cao $h$ tới, nhìn từ đỉnh ngăn xếp xuống:
  • Những người thấp hơn $h$ ở đỉnh: đều thấy người mới (người đứng giữa họ và người mới đã bị gạch, tức thấp hơn họ). Đếm mỗi người 1 cặp, rồi gạch tên (pop) vì từ giờ họ bị người mới che.
  • Người tiếp theo (cao $\ge h$), nếu còn: cũng thấy người mới, đếm thêm 1 cặp. Nếu người này cao bằng $h$ thì cũng bị che từ nay → pop luôn. Những người sâu hơn thì bị người này che, không thấy.
  • Cuối cùng đẩy (push) người mới vào ngăn xếp.

Mỗi người được push 1 lần và pop tối đa 1 lần, nên tổng thời gian là $O(n)$. Mỗi cặp được đếm hoặc ứng với một lần pop, hoặc ứng với một người mới (tối đa 1 cặp "người cao hơn gần nhất") – vì thế đáp án $\le 2n$.

Em nhập dãy chiều cao rồi bấm Tiếp để xem ngăn xếp hoạt động. Cột xanh là người đang ở trong ngăn xếp, cột vàng là người mới tới, cột đỏ là người vừa bị gạch tên; mỗi cung nối hai người là một cặp nhìn thấy nhau (cung cam là cặp vừa đếm):

Ví dụ nhanh:

Cách 1Vét cạn theo định nghĩa – Subtask 1 (và "ăn may" subtask 2)

Duyệt mọi cặp $(i, j)$, với mỗi cặp kiểm tra từng người $k$ đứng giữa: chỉ cần một người có $h_k \ge \min(h_i, h_j)$ là cặp đó bị che.

C++▶ Chạy tay từng dòng codeCách 1: duyệt mọi cặp (i, j), kiểm tra từng người đứng giữa – O(n³).
Dữ liệu mẫu:
  1. Biến thay ban đầu là true, gặp người che thì đổi thành false và break luôn.
  2. Với hai người đứng cạnh nhau ($j = i + 1$) vòng k không chạy lần nào, nên thay giữ nguyên true – đúng như đề.
Không gian$O(n)$
Thời gian$O(n^3)$

Có khoảng $\frac{n^2}{2}$ cặp, mỗi cặp tốn tới $O(n)$ bước nên tổng là $O(n^3)$: với $n = 100$ là vài trăm nghìn phép tính, nhưng $n = 10^4$ thì là $10^{12}$ – quá chậm.

Kết quả chạy thật: Bản C++ đúng 60/100 test, quá thời gian ở test 61–100; test đúng chạy lâu nhất 0.23s. Bản Python đúng 30/40 test (mình chỉ chạy test 1–40), quá thời gian ở test 31–40; test đúng chạy lâu nhất 0.02s.

Bất ngờ chưa: cách $O(n^3)$ lại qua luôn cả subtask 2, test $n = 10^4$ chỉ mất khoảng 0.2 giây! Lý do là lệnh break: với dãy ngẫu nhiên, người đứng ngay sau $i$ thường đã che rồi, nên vòng k gần như dừng ngay ở bước đầu, thực tế chỉ còn khoảng $ rac{n^2}{2} = 5 \cdot 10^7$ bước. Nhưng đừng vội mừng: mình thử thêm một test tự tạo hình "thung lũng" $5000,$ $4999,$ $\ldots,$ $1,$ $1,$ $2,$ $\ldots,$ $5000$ ($n = 10^4$) – ở đó rất nhiều cặp nhìn thấy nhau qua cả thung lũng, vòng k phải chạy hết – và cách 1 chạy hơn 10 giây chưa xong. Bộ test của bài chỉ "nương tay" thôi. Còn với $n = 5 \cdot 10^5$ thì riêng số cặp $(i, j)$ đã là $1{,}25 \cdot 10^{11}$, không thể kịp.

Cách 2Giữ max ở giữa, dừng sớm – Subtask 1, 2 chắc chắn

Cố định $i$, cho $j$ chạy sang phải và giữ biến mx = chiều cao lớn nhất của những người đứng giữa $i$ và $j$. Không cần vòng $k$ nữa: cặp $(i, j)$ thấy nhau khi và chỉ khi mx < min(h[i], h[j]). Thêm nữa, theo nhận xét 1, một khi mx >= h[i] thì $i$ bị che với mọi người phía sau → break.

C++▶ Chạy tay từng dòng codeCách 2: giữ mx = người cao nhất đứng giữa, dừng khi i bị che – O(n²).
Dữ liệu mẫu:
Thử bộ "Giảm dần": vòng j không bao giờ dừng sớm – đó là lý do cách này quá thời gian ở test 86–100.
  1. Thứ tự trong vòng $j$ rất quan trọng: kiểm tra cặp $(i, j)$ trước, rồi mới đưa $h_j$ vào mx (vì $j$ chỉ là "người đứng giữa" đối với các $j$ sau).
  2. Ban đầu mx = 0 (giữa chưa có ai) – mọi $h_i \ge 1$ nên cặp đứng cạnh nhau luôn được đếm.
Không gian$O(n)$
Thời gian$O(n^2)$

Không có break thì đây là $O(n^2)$ tròn trịa, đủ cho $n = 10^4$ với mọi kiểu dữ liệu (test "thung lũng" ở trên chạy chưa tới 0.1 giây). Có break thì với dãy ngẫu nhiên hay dãy tăng dần, vòng $j$ dừng rất sớm (gặp ngay người cao hơn $h_i$), nên chạy gần như $O(n)$. Nhưng với dãy giảm dần, không ai phía sau cao hơn $h_i$, vòng $j$ luôn chạy tới cuối hàng → vẫn là $O(n^2) \approx 1{,}25 \cdot 10^{11}$ phép tính.

Kết quả chạy thật: Bản C++ đúng 85/100 test, quá thời gian ở test 86–100; test đúng chạy lâu nhất 0.06s. Bản Python đúng 75/100 test, quá thời gian ở test 61–70, 86–100; test đúng chạy lâu nhất 0.45s. Đúng như dự đoán: dừng sớm giúp "ăn ké" thêm các test lớn ngẫu nhiên và tăng dần, nhưng gục ở 15 test giảm dần.

Cách 3Ngăn xếp đơn điệu – Full điểm

Cài đặt đúng nhận xét 2. Ngăn xếp chỉ cần lưu chiều cao (không cần chỉ số), và ta có thể đọc tới đâu xử lý tới đó, không cần lưu mảng $h$.

C++▶ Chạy tay từng dòng codeCách 3: ngăn xếp chiều cao giảm dần nghiêm ngặt – O(n).
Dữ liệu mẫu:
  1. Vòng while: các người ở đỉnh thấp hơn $h$ thấy người mới → dem++, rồi pop vì từ nay bị che.
  2. Sau vòng while, nếu ngăn xếp còn người thì người ở đỉnh cao $\ge h$ – người cao hơn hoặc bằng gần nhất phía trước – cũng thấy người mới → dem++.
  3. Dòng if(st.top() == h) st.pop(); xử lý trường hợp cao bằng nhau: người đó thấy người mới nhưng từ nay bị người mới che (vì "$\ge$"). Nếu quên dòng này, ví dụ 2 sẽ ra $1 + 2 + 2 + 2 = 7$ thay vì $4$. Nhờ dòng này ngăn xếp luôn giảm dần nghiêm ngặt.
Không gian$O(n)$
Thời gian$O(n)$

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

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^3)$C++60/100> 1squá thời gian test 61–100
Python30/40> 1sChạy test 1–40; quá thời gian test 31–40
2. Giữ max ở giữa + dừng sớmC++85/100> 1squá thời gian test 86–100
Python75/100> 1squá thời gian test 61–70, 86–100
3. Ngăn xếp đơn điệuC++100/1000.05sfull điểm
Python100/1000.33sfull điểm
Ghi chú của mình: thời gian là thời gian CPU đo trên máy của mình, giới hạn 1 giây như đề. Cách 3 (ngăn xếp) chạy test lớn nhất chỉ khoảng 0.05 giây cho bản C++. Một biến thể nổi tiếng của bài này là khi cao bằng nhau không che nhau (bài "Patrik" của BOI / "Bằng hữu" trên VNOI): khi đó ngăn xếp phải lưu thêm số người cao bằng nhau liên tiếp và đáp án có thể tới $\frac{n(n-1)}{2}$ – lúc ấy bắt buộc 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