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

Đếm số lượng dãy con liên tiếp phân biệt 2

Đếm số lượng dãy con liên tiếp phân biệt 2
Nguồn: CLUE OJ Độ khó: Trung bình Map / Set Hai con trỏ

Đề bài

Đếm số lượng dãy con liên tiếp phân biệt 2

Cho mảng $A$ gồm $n$ phần tử nguyên. Đếm số lượng dãy con liên tiếp mà không có giá trị nào xuất hiện quá 1 lần.

Ví dụ $3, 2, 3$ không thỏa mãn vì giá trị $3$ xuất hiện 2 lần.

Input:

  • Dòng đầu tiên gồm số nguyên $n$.
  • Dòng thứ hai gồm $n$ số nguyên $A_i$.

Output: In ra số lượng dãy con thỏa mãn.

Điều kiện: $1 \le n \le 10^6$; $|A_i| \le 10^9$.

Subtask:

  • 30% điểm đầu tiên có $n \le 10^2$.
  • 30% điểm tiếp theo có $n \le 10^4$.
  • 30% điểm tiếp theo có $n \le 10^5$.
  • 10% điểm còn lại có $n \le 10^6$.

Giới hạn thời gian: 5 giây, bộ nhớ: 256 MB.

Ví dụ – input
4
1 2 1 2
Ví dụ – output
7

Lời mở đầu

"Dãy con liên tiếp" chính là một đoạn $[l, r]$ của mảng. Với ví dụ $1, 2, 1, 2$: cả 4 đoạn độ dài 1 đều được, 3 đoạn độ dài 2 là $(1, 2)$, $(2, 1)$, $(1, 2)$ cũng được, còn mọi đoạn dài từ 3 trở lên đều chứa hai số giống nhau. Tổng cộng $4 + 3 = 7$ đoạn.

Bài này là một bài "kinh điển" để luyện kỹ thuật hai con trỏ (cửa sổ trượt) kết hợp với set. Mình sẽ đi từ cách vét cạn đơn giản nhất, rồi tối ưu dần qua từng subtask, và chạy thật cả 3 cách trên bộ test chính thức để các bạn thấy rõ 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ó 100 test, chia nhóm rất rõ theo $n$:

TestnGiá trị $a_i$Số giá trị khác nhauĐoạn tốt dài nhấtĐáp án lớn nhất
1–204 … 101 … 10≈ 71% của n737
21–30100-993.535.854 … 996.323.860≈ 79% của n521.919
31–6010.000-999.958.696 … 999.967.683≈ 76% của n5741.643.075
61–90100.000-999.996.950 … 999.999.359≈ 67% của n1.19229.387.370
91–991.000.000-999.986.449 … 999.999.381≈ 55% của n1.321315.399.362
1001.000.0001 … 1.000.000≈ 100% của n1.000.000500.000.500.000

Một vài điều đáng chú ý:

  • Các test 1–20 rất nhỏ ($n \le 10$, giá trị từ 1 đến 10 nên trùng nhau nhiều). Từ test 21 trở đi các số trải khắp $[-10^9, 10^9]$ nhưng vẫn được chọn sao cho có khoảng 1/4 – 1/3 số bị lặp lại.
  • Nhờ vậy, ở các test 1–99 đoạn tốt dài nhất chỉ khoảng 1000 phần tử – điều này sẽ giúp cách 2 "lì" hơn mình tưởng.
  • Test 100 là test "sát thủ": $n = 10^6$ và các số $1, 2, \ldots, 10^6$ đôi một khác nhau. Mọi đoạn đều tốt nên đáp án là $\frac{n(n+1)}{2}$ $= 500\,000\,500\,000$ – vượt xa int, biến đếm bắt buộc phải là long long.

Subtask trong đề khớp đúng với bộ test. Số test đúng của từng cách (chạy thật, giới hạn 5 giây):

SubtaskĐề ghiTestCách 1
(vét cạn)
Cách 2
(dừng sớm + set)
Cách 3
(hai con trỏ)
1 (30%)$n \le 10^2$1–3030/3030/3030/30
2 (30%)$n \le 10^4$31–600/3030/3030/30
3 (30%)$n \le 10^5$61–900/3030/3030/30
4 (10%)$n \le 10^6$91–1000/100/1010/10

Ý tưởngĐoạn tốt và tính chất "co giãn"

Gọi một đoạn là tốt nếu các số trong đoạn đôi một khác nhau. Có hai nhận xét rất quan trọng:

Nhận xét 1
Nếu đoạn $[l, r]$ tốt thì mọi đoạn nằm bên trong nó cũng tốt. Ngược lại, nếu $[l, r]$ không tốt (có 2 số trùng nhau) thì mọi đoạn chứa nó, ví dụ $[l, r+1], [l, r+2], \ldots$ cũng không tốt vì vẫn chứa 2 số trùng đó.

Nhận xét 1 cho phép dừng sớm: cố định đầu trái $l$, kéo dài $r$ cho tới khi gặp số trùng thì dừng luôn (cách 2).

Nhận xét 2 – Hai con trỏ
Với mỗi đầu phải $r$, gọi $l$ là vị trí nhỏ nhất để đoạn $[l, r]$ còn tốt. Khi đó có đúng $r - l + 1$ đoạn tốt kết thúc tại $r$: $[l, r], [l+1, r], \ldots, [r, r]$. Khi $r$ tăng thêm 1, vị trí $l$ đó không bao giờ lùi lại (theo nhận xét 1).

Vậy ta giữ một "cửa sổ" $[l, r]$ luôn tốt và một set chứa các giá trị trong cửa sổ. Mỗi lần thêm $a_r$: nếu $a_r$ đã có trong set thì bỏ dần phần tử bên trái ($a_l$, rồi $l$++) cho tới khi hết trùng; sau đó thêm $a_r$ vào và cộng $r - l + 1$ vào đáp án. Mỗi phần tử được thêm vào 1 lần và bị xoá tối đa 1 lần nên tổng số thao tác chỉ là $O(n)$ lần thao tác trên set.

Em nhập dãy số rồi bấm Tiếp để xem cửa sổ trượt (ô xanh là cửa sổ $[l, r]$, ô vàng là $a_r$ vừa xét, ô đỏ là phần tử vừa bị đẩy ra):

Ví dụ nhanh:

Cách 1Vét cạn mọi đoạn – Subtask 1

Làm đúng như định nghĩa: duyệt mọi đoạn $[l, r]$, với mỗi đoạn dùng 2 vòng for so sánh mọi cặp phần tử xem có cặp nào bằng nhau không.

C++▶ Chạy tay từng dòng codeCách 1: duyệt mọi đoạn [l, r], so mọi cặp trong đoạn – O(n⁴).
Dữ liệu mẫu:
Khung này khá nhiều bước vì cách vét cạn so lại từ đầu cho mỗi đoạn – đó cũng chính là lý do nó chậm.
  1. Hàm phanBiet(l, r) trả về false ngay khi thấy một cặp $a_i = a_j$, không cần so tiếp.
  2. Biến dem là long long vì đáp án có thể lên tới $5 \cdot 10^{11}$.
Không gian$O(n)$
Thời gian$O(n^4)$

Có $\frac{n(n+1)}{2}$ đoạn, mỗi đoạn tốn tới $O(n^2)$ phép so sánh nên tổng cộng $O(n^4)$. Với $n = 100$ chỉ cỡ vài triệu phép tính, nhưng với $n = 10^4$ thì là con số khổng lồ.

Kết quả chạy thật: Bản C++ đúng 30/100 test, quá thời gian ở test 31–100; test đúng chạy lâu nhất 0.00s (máy mình đang bận nên từ test 43 trở đi mình cắt ở 1 giây cho nhanh – với $n \ge 10^4$ thì cách $O(n^4)$ chắc chắn không thể chạy dưới 5 giây). 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.06s. Ăn trọn subtask 1 (30% điểm).

Cách 2Dừng sớm + set – Subtask 2, 3

Dùng nhận xét 1: cố định $l$, cho $r$ chạy từ $l$ sang phải và giữ một set các giá trị đã gặp trong đoạn $[l, r-1]$. Nếu $a_r$ đã có trong set thì đoạn $[l, r]$ hỏng, và mọi đoạn dài hơn cũng hỏng, nên break luôn. Mỗi lần chưa hỏng thì đoạn $[l, r]$ tốt, ta tăng dem.

C++▶ Chạy tay từng dòng codeCách 2: cố định l, kéo dài r với set, gặp số trùng thì dừng.
Dữ liệu mẫu:
  1. set<int> s khai báo bên trong vòng $l$ nên mỗi $l$ mới lại bắt đầu với set rỗng.
  2. s.count(a[r]) trả về 1 nếu $a_r$ đã có trong set, 0 nếu chưa – mất $O(\log n)$.
Không gian$O(n)$
Thời gian$O(n^2 \log n)$

Trường hợp xấu nhất (mọi số khác nhau, như test 100) vòng $r$ luôn chạy tới cuối nên vẫn là $O(n^2 \log n)$. Nhưng để ý: tổng số lần vòng trong chạy đúng bằng đáp án (mỗi lần dem++ là một bước). Ở test 61–90 đáp án chỉ khoảng $3 \cdot 10^7$ nên cách này vẫn qua; còn ở test 91–99 đáp án $\approx 3 \cdot 10^8$ và test 100 là $5 \cdot 10^{11}$ thì chịu.

Kết quả chạy thật: Bản C++ đúng 90/100 test, quá thời gian ở test 91–100; test đúng chạy lâu nhất 3.84s. Bản Python đúng 60/100 test, quá thời gian ở test 61–100; test đúng chạy lâu nhất 0.45s. Bản C++ ăn được subtask 1, 2, 3 (90% điểm).

Thử thêm: nếu bỏ set, thay bằng vòng for so $a_r$ với $a_l..a_{r-1}$ (vẫn dừng sớm) thì độ phức tạp lý thuyết tệ hơn ($O(n^3)$), nhưng vì đoạn tốt dài nhất trong test chỉ khoảng 1000 nên thực tế lại chạy nhanh ngang ngửa: bản này đúng 90/100 test, quá thời gian ở test 91–100; test đúng chạy lâu nhất 3.27s. Đây là một ví dụ cho thấy dữ liệu ngẫu nhiên đôi khi "nương tay" với lời giải chưa tối ưu – nhưng gặp test như test 100 thì vẫn gục.

Cách 3Hai con trỏ + set – Full điểm

Đây là cài đặt của nhận xét 2: con trỏ $r$ chạy từ 1 tới $n$, con trỏ $l$ chỉ tiến chứ không lùi.

C++▶ Chạy tay từng dòng codeCách 3: hai con trỏ l, r và set chứa các giá trị trong cửa sổ – O(n log n).
Dữ liệu mẫu:
  1. Vòng while(s.count(a[r])): chừng nào $a_r$ còn trùng với một số trong cửa sổ thì đẩy $a_l$ ra khỏi set và tăng $l$. Vì các số trong cửa sổ đôi một khác nhau nên s.erase(a[l]) xoá đúng phần tử $a_l$.
  2. Sau vòng while, đoạn $[l, r]$ tốt và $l$ nhỏ nhất có thể, nên có đúng $r - l + 1$ đoạn tốt kết thúc ở $r$.
  3. Tuy có vòng while lồng trong for, nhưng $l$ chỉ tăng tối đa $n$ lần trong cả chương trình, nên tổng số lần chạy thân while không quá $n$.
  4. Bản Python dùng biến thể "vị trí xuất hiện cuối cùng": last[x] là vị trí gần nhất của giá trị $x$; nếu last[a[r]] >= l thì nhảy thẳng l = last[a[r]] + 1 thay vì xoá từng phần tử. Ý tưởng y hệt, chỉ là nhảy một bước thay vì đi từng bước.
Không gian$O(n)$
Thời gian$O(n \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.51s. Bản Python đúng 100/100 test; test đúng chạy lâu nhất 1.18s.

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 mọi đoạnC++30/100> 5sQua subtask 1; quá thời gian từ test 31
Python30/40> 5sMình chỉ chạy test 1–40; quá thời gian test 31–40
2. Dừng sớm + setC++90/100> 5sQuá thời gian test 91–100
Python60/100> 5sQuá thời gian test 61–100
2'. Dừng sớm, so tuyến tínhC++90/100> 5sQuá thời gian test 91–100
3. Hai con trỏ + setC++100/1000.51sFull điểm
Python100/1001.18sHai con trỏ + dict vị trí cuối
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 5 giây như đề. Cách 3 chạy test lớn nhất ($n = 10^6$) chỉ khoảng 0.51 giây. Nếu muốn nhanh hơn nữa có thể nén giá trị (sắp xếp rồi đánh số lại) để thay set bằng mảng đánh dấu, nhưng với giới hạn 5 giây thì set là quá đủ và code gọn, dễ hiểu hơn.
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