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 đoạn con 2

Đếm đoạn con 2
Nguồn: CLUE OJ – Map/Set Độ khó: Trung bình Map Set Hai con trỏ

Đề bài

Cho mảng số nguyên dương gồm $n$ phần tử $a_1, a_2, \ldots, a_n$.

Một đoạn con liên tiếp của dãy $a$ được xác định bởi hai chỉ số $l, r$ tức là các phần tử $a_l, a_{l+1}, \ldots, a_r$.

Nhiệm vụ của bạn là đếm số lượng đoạn con có nhiều nhất $k$ phần tử khác nhau.

Dữ liệu vào:

  • Dòng đầu tiên chứa $n, k$ $(1 \le k \le n \le 2 \cdot 10^5)$;
  • Dòng thứ hai chứa $n$ số nguyên dương $a_1, a_2, \ldots, a_n$ $(a_i \le 10^9)$.

Kết quả ra: in ra một số nguyên dương là số lượng đoạn con thỏa mãn yêu cầu của đề bài.

Ràng buộc:

  • Subtask 1: 30% số test $n \le 100$;
  • Subtask 2: 30% số test $n \le 1000$; $a_i \le 1000$;
  • Subtask 3: 20% số test $a_i \le 10^6$;
  • Subtask 4: 20% số test còn lại không có ràng buộc gì.

Giới hạn: 1 giây, 256 MB.

Ví dụ – input
5 2
1 2 3 1 1
Ví dụ – output
10

Giải thích ví dụ: với $k = 2$, dãy 1 2 3 1 1 có 15 đoạn con. Các đoạn có tới 3 giá trị khác nhau là [1 2 3], [1 2 3 1], [1 2 3 1 1], [2 3 1], [2 3 1 1] – 5 đoạn. Còn lại $15 - 5 = 10$ đoạn thoả mãn.

Lời mở đầu

Đây là một bài rất "kinh điển" của chủ đề Map/Set kết hợp kỹ thuật hai con trỏ (cửa sổ trượt). Đề chia 4 subtask rất rõ ràng nên mình sẽ đi đúng theo lộ trình đó: từ vét cạn ăn subtask 1, rồi tối ưu dần lên tới full điểm. Mỗi cách mình đều chạy thật trên toàn bộ 100 test của CLUE OJ và ghi lại kết quả để các bạn thấy rõ vì sao cách đó qua được subtask này mà không qua được subtask kia.

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

Nhóm testSố testnkai lớn nhấtSố giá trị khác nhau (max)Đáp án lớn nhất
1–30305 – 1001 – 1020201.326
31–60301.000 – 1.0005 – 344999394490.927
61–8020200.000 – 200.0001.160 – 32.408999.99970.62712.389.421.145
81–10020200.000 – 200.000370 – 31.835999.999.489117.8178.538.282.141

Đối chiếu với đề thì các test được xếp đúng thứ tự subtask:

SubtaskĐiều kiện (theo đề)TestThực tế trong bộ test
1 (30%)$n \le 100$1 – 30test 1–20: $n \le 10$; test 21–30: $n = 100$, $a_i \le 20$
2 (30%)$n \le 1000$, $a_i \le 1000$31 – 60$n = 1000$, $a_i \le 999$
3 (20%)$a_i \le 10^6$61 – 80$n = 2 \cdot 10^5$, $a_i < 10^6$
4 (20%)không ràng buộc thêm81 – 100$n = 2 \cdot 10^5$, $a_i$ tới gần $10^9$
Chú ý tràn số
Đáp án có thể lên tới khoảng $\frac{n(n+1)}{2} \approx 2 \cdot 10^{10}$, vượt xa giới hạn của int. Trong bộ test có 36 test có đáp án lớn hơn $2^{31} - 1$, nên biến đếm bắt buộc phải là long long.

Ý tưởngNhận xét then chốt: đoạn càng ngắn càng "ít khác nhau"

Gọi $d(l, r)$ là số giá trị khác nhau trong đoạn $[l, r]$. Có hai tính chất rất tự nhiên:

  • Kéo dài đoạn (tăng $r$) thì $d$ chỉ có thể tăng hoặc giữ nguyên.
  • Cắt bớt đầu trái (tăng $l$) thì $d$ chỉ có thể giảm hoặc giữ nguyên.

Hệ quả: nếu đoạn $[l, r]$ thoả mãn ($d \le k$) thì mọi đoạn con nằm trong nó cũng thoả mãn. Vậy với mỗi đầu phải $r$, nếu gọi $l$ là vị trí nhỏ nhất sao cho $[l, r]$ còn thoả mãn, thì có đúng $r - l + 1$ đoạn kết thúc tại $r$ thoả mãn: $[l, r], [l+1, r], \ldots, [r, r]$.

Hai con trỏ
Khi $r$ tăng thì $l$ tốt nhất cũng chỉ tăng, không bao giờ lùi. Nên ta cho $r$ chạy từ trái sang phải, mỗi lần đưa $a_r$ vào "cửa sổ"; nếu cửa sổ có hơn $k$ giá trị khác nhau thì đẩy $l$ sang phải (đưa $a_l$ ra) cho tới khi hợp lệ, rồi cộng $r - l + 1$ vào đáp án. Mỗi phần tử vào cửa sổ 1 lần, ra 1 lần → tổng cộng $O(n)$ bước di chuyển.

Để biết cửa sổ có bao nhiêu giá trị khác nhau, ta giữ một bảng đếm cnt[x] = số lần $x$ xuất hiện trong cửa sổ. Số giá trị khác nhau chính là số khoá $x$ có cnt[x] > 0. Với map, khi cnt[x] về 0 ta erase luôn, khi đó cnt.size() chính là số giá trị khác nhau.

Em nhập dãy rồi bấm Tiếp để xem cửa sổ trượt (ô xanh tím là cửa sổ $[l, r]$, ô viền cam là phần tử vừa vào):

Ví dụ nhanh:

Cách 1Vét cạn mọi đoạn, dùng set đếm giá trị khác nhau – Subtask 1

Cách "thật thà" nhất: duyệt mọi cặp $(l, r)$, với mỗi đoạn thì bỏ hết các phần tử vào một set (set tự loại phần tử trùng), kích thước set chính là số giá trị khác nhau. Có $\frac{n(n+1)}{2}$ đoạn, mỗi đoạn tốn tới $O(n \log n)$ để dựng set.

C++▶ Chạy tay từng dòng codeCách 1: duyệt mọi đoạn [l, r], dựng set để đếm số giá trị khác nhau.
Dữ liệu mẫu:
  1. set<int> s khai báo bên trong vòng r nên mỗi đoạn được dựng lại từ đầu – đơn giản, dễ đúng, nhưng rất chậm.
  2. (int)s.size(): ép kiểu để so sánh với int k không bị cảnh báo so sánh có dấu/không dấu.
  3. ans là long long ngay từ cách đầu tiên cho quen tay.
Không gian$O(n)$
Thời gian$O(n^3 \log n)$

Kết quả chạy thật: bản C++ đúng 30/100 test – ăn trọn Subtask 1; sai mọi test từ test 31 trở đi (quá thời gian); test đúng chậm nhất chạy dưới 0,01s. Bản Python đúng 30/100 test – ăn trọn Subtask 1; sai mọi test từ test 31 trở đi (quá thời gian); test đúng chậm nhất chạy 0,03s. Với $n = 1000$ đã cần khoảng $1.7 \cdot 10^8$ lần chèn vào set nên subtask 2 trở đi đều quá thời gian.

Cách 2Cố định đầu trái, kéo dài đầu phải với mảng đếm – Subtask 1, 2

Cách 1 lãng phí ở chỗ: đoạn $[l, r+1]$ chỉ hơn đoạn $[l, r]$ đúng một phần tử $a_{r+1}$, vậy mà ta lại dựng set từ đầu. Ta sửa lại: cố định $l$, cho $r$ chạy tăng dần và cập nhật dần số giá trị khác nhau $d$: khi đưa $a_r$ vào, nếu cnt[a[r]] vừa từ 0 lên 1 thì đó là giá trị mới, $d$ tăng 1.

Thêm nữa, theo nhận xét ở phần ý tưởng, khi $d > k$ thì kéo dài thêm cũng vô ích → break luôn. Subtask 2 có $a_i \le 1000$ nên dùng thẳng mảng cnt[1005] làm bảng đếm, không cần set/map.

C++▶ Chạy tay từng dòng codeCách 2: cố định l, kéo dài r và cập nhật dần số giá trị khác nhau d.
Dữ liệu mẫu:
  1. memset(cnt, 0, sizeof(cnt)) mỗi lần đổi $l$: chỉ tốn 1005 phép gán, với $n = 1000$ là khoảng $10^6$ – không đáng kể.
  2. Câu if(d > k) break; đặt trước ans++: đoạn vừa vượt quá $k$ thì không được đếm.
  3. Mảng cnt chỉ có 1005 ô nên cách này không dùng được khi $a_i$ lớn (subtask 3, 4 sẽ truy cập ngoài mảng).
Không gian$O(n + \max a_i)$
Thời gian$O(n^2)$

Kết quả chạy thật: bản C++ đúng 60/100 test – ăn trọn Subtask 1, Subtask 2; sai mọi test từ test 61 trở đi (lỗi chạy); test đúng chậm nhất chạy dưới 0,01s. Bản Python đúng 60/100 test – ăn trọn Subtask 1, Subtask 2; sai mọi test từ test 61 trở đi (lỗi chạy); test đúng chậm nhất chạy 0,18s. Từ subtask 3, $n = 2 \cdot 10^5$ và $a_i$ vượt 1000 nên vừa chậm $O(n^2)$, vừa truy cập ngoài mảng.

Cách 3Hai con trỏ với mảng đếm – Subtask 1, 2, 3

Bây giờ áp dụng đúng ý tưởng hai con trỏ: không "reset" lại khi đổi $l$ nữa mà cả $l$ lẫn $r$ cùng tiến về phía trước. Subtask 3 có $a_i \le 10^6$ nên mảng đếm 1 triệu ô vẫn vừa bộ nhớ (4 MB).

C++▶ Chạy tay từng dòng codeCách 3: hai con trỏ l, r với mảng đếm cnt.
Dữ liệu mẫu:
  1. Vòng while(d > k): mỗi lần lặp đưa $a_l$ ra khỏi cửa sổ; nếu đếm của nó về 0 thì cửa sổ mất một giá trị khác nhau.
  2. ans += r - l + 1: sau vòng while, $l$ là đầu trái nhỏ nhất hợp lệ cho đầu phải $r$, nên có đúng $r - l + 1$ đoạn kết thúc tại $r$ thoả mãn.
  3. Con trỏ $l$ chỉ tăng, tổng số lần chạy while trong cả chương trình không quá $n$ → $O(n)$.
Không gian$O(n + \max a_i)$
Thời gian$O(n)$

Kết quả chạy thật: bản C++ đúng 80/100 test – ăn trọn Subtask 1, Subtask 2, Subtask 3; sai mọi test từ test 81 trở đi (lỗi chạy); test đúng chậm nhất chạy 0,03s. Bản Python đúng 80/100 test – ăn trọn Subtask 1, Subtask 2, Subtask 3; sai mọi test từ test 81 trở đi (lỗi chạy); test đúng chậm nhất chạy 0,18s. Subtask 4 có $a_i$ tới $10^9$ thì không thể khai báo mảng đếm $10^9$ ô (4 GB) nên bị lỗi.

Cách 4Hai con trỏ với map – Full điểm

Vấn đề duy nhất còn lại là giá trị quá lớn để làm chỉ số mảng. Đây đúng là lúc dùng map<int, int>: map chỉ lưu những giá trị đang có mặt trong cửa sổ (tối đa $k + 1$ khoá), giá trị lớn tới đâu cũng được. Khi đếm của một khoá về 0 thì erase nó đi, nhờ vậy cnt.size() luôn bằng đúng số giá trị khác nhau – không cần biến $d$ riêng nữa.

C++▶ Chạy tay từng dòng codeCách 4 (full điểm): hai con trỏ, map lưu số lần xuất hiện, cnt.size() là số giá trị khác nhau.
Dữ liệu mẫu:
  1. cnt[a[r]]++: nếu khoá chưa có, map tự tạo với giá trị 0 rồi tăng lên 1.
  2. Bắt buộc phải cnt.erase(a[l]) khi đếm về 0. Nếu quên, khoá vẫn nằm trong map với giá trị 0, cnt.size() sẽ đếm cả nó và cho kết quả sai.
  3. Mỗi thao tác trên map tốn $O(\log k)$, cả chương trình $O(n \log k)$.
Không gian$O(n)$
Thời gian$O(n \log k)$

Kết quả chạy thật: bản C++ đúng 100/100 test – ăn trọn Subtask 1, Subtask 2, Subtask 3, Subtask 4; test đúng chậm nhất chạy 0,18s. Bản Python dùng dict (bảng băm) đúng 100/100 test – ăn trọn Subtask 1, Subtask 2, Subtask 3, Subtask 4; test đúng chậm nhất chạy 0,24s.

Tổng kếtKết quả chạy thật của các cách

CáchSubtask 1
test 1–30
Subtask 2
test 31–60
Subtask 3
test 61–80
Subtask 4
test 81–100
TổngChậm nhất
(test đúng)
Cách 1 – C++30/300/30
quá thời gian
0/20
quá thời gian
0/20
quá thời gian
30/1000,01s
Cách 1 – Python30/300/30
quá thời gian
0/20
quá thời gian
0/20
quá thời gian
30/1000,03s
Cách 2 – C++30/3030/300/20
lỗi chạy
0/20
lỗi chạy
60/1000,00s
Cách 2 – Python30/3030/300/20
lỗi chạy
0/20
lỗi chạy
60/1000,18s
Cách 3 – C++30/3030/3020/200/20
lỗi chạy
80/1000,03s
Cách 3 – Python30/3030/3020/200/20
lỗi chạy
80/1000,18s
Cách 4 – C++30/3030/3020/2020/20100/1000,18s
Cách 4 – Python30/3030/3020/2020/20100/1000,24s
Ghi chú của mình: tất cả số liệu trên là chạy thật trên bộ 100 test của CLUE OJ với giới hạn 1 giây; thời gian là thời gian CPU đo trên máy của mình (biên dịch g++ -O2), mỗi lần chạy bị giới hạn CPU và bộ nhớ như máy chấm. Cách 4 bản C++ đạt 100/100 test. Python chậm hơn C++ nhiều lần; bạn nào nộp Python mà bị sát giới hạn thì nên chọn PyPy nếu hệ thống cho phép.
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