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

Dãy dài nhất

Dãy dài nhất
Nguồn: CLUE OJ – Map/Set Độ khó: Khá Hai con trỏ multiset Deque đơn điệu

Đề bài

Cho dãy số nguyên $A = (a_0, a_1, \ldots, a_{n-1})$. Hãy tìm một dãy con dài nhất gồm các phần tử liên tiếp trong dãy $A$ sao cho chênh lệch giữa phần tử lớn nhất và nhỏ nhất của dãy đó không vượt quá $\Delta$.

Dữ liệu:

  • Dòng đầu tiên chứa hai số nguyên $n, \Delta$ $(1 \le n \le 10^6;\ 0 \le \Delta \le 2 \times 10^9)$.
  • Dòng thứ hai chứa $n$ số nguyên $a_1, a_2, \ldots, a_n$ $(\forall i: |a_i| \le 10^9)$.

Kết quả: một số nguyên duy nhất là độ dài dãy con tìm được.

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

Subtask:

  • 40% điểm đầu tiên có $n \le 10^4$.
  • 40% điểm tiếp theo có $n \le 10^5$.
  • 20% điểm còn lại không có giới hạn gì thêm.
Ví dụ – input
10 6
10 5 4 3 2 1 9 8 7 6
Ví dụ – output
5

Giải thích ví dụ: với $\Delta = 6$, đoạn 5 4 3 2 1 (vị trí 1 → 5) có max − min $= 5 - 1 = 4 \le 6$, dài 5. Đoạn 9 8 7 6 cũng hợp lệ nhưng chỉ dài 4. Không có đoạn nào dài 6 thoả mãn (ví dụ thêm số 10 vào đầu thì $10 - 1 = 9 > 6$, thêm số 9 vào cuối thì $9 - 1 = 8 > 6$), nên đáp án là 5.

Lời mở đầu

Đây là một bài "cửa sổ trượt" kinh điển: tìm đoạn liên tiếp dài nhất thoả một điều kiện về max và min. Cái khó là khi cửa sổ trượt, ta phải biết ngay max và min hiện tại của cửa sổ, kể cả khi vừa bỏ đi phần tử bên trái. Mình sẽ đi từ cách vét cạn dễ hiểu nhất, rồi dùng multiset (đúng chủ đề Map/Set), và cuối cùng là hàng đợi đơn điệu để chạy kịp $n = 10^6$ trong 0.5 giây.

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 gom các test có cùng đặc điểm thành nhóm:

TestnΔGiá trị aiĐáp án
1–205 – 100 – 10-20 … 201 – 5
21–4010.00014.130.946 – 99.971.761-999.879.395 … 999.872.0375.000 – 5.003
41–80100.00011.436.515 – 97.220.927-999.996.662 … 999.994.35450.000 – 50.002
81–951.000.00026.803.454 – 95.938.713-999.999.865 … 999.998.620500.000 – 500.006
96–1001.000.0002.000.000.000-999.996.109 … 999.998.7401.000.000

Một vài điều mình rút ra khi soi bộ test:

  • Test 1–20 rất nhỏ ($n \le 10$, $|a_i| \le 20$), có cả trường hợp $\Delta = 0$ (khi đó dãy con hợp lệ phải gồm các số bằng nhau).
  • Từ test 21 trở đi, nửa đầu dãy gồm các số nhỏ ($|a_i| \le 10^6$), nửa sau là các số ngẫu nhiên tới $\pm 10^9$. Vì thế đáp án luôn xấp xỉ $n/2$: dãy con dài nhất nằm gọn trong nửa đầu. Đây cũng là kiểu test "giết" cách vét cạn, vì với mỗi $l$ ở nửa đầu ta phải kéo $r$ đi rất xa.
  • Test 96–100 có $\Delta = 2 \cdot 10^9$: mọi chênh lệch đều $\le 2 \cdot 10^9$ nên đáp án là cả dãy ($10^6$). Chênh lệch lớn nhất có thể là $10^9 - (-10^9) = 2 \cdot 10^9$, vẫn vừa kiểu int (tối đa khoảng $2{,}147 \cdot 10^9$) nhưng sát nút, nên mình dùng long long cho an toàn.

Bảng subtask theo đề, mình đã đối chiếu với kích thước thật của từng test:

SubtaskĐiều kiệnCác testCách ăn được
1 (40%)$n \le 10^4$1 – 40Cách 1, 2, 3
2 (40%)$n \le 10^5$41 – 80Cách 2, 3
3 (20%)$n \le 10^6$81 – 100Cách 3

Ý tưởngĐoạn hợp lệ thì mọi đoạn con của nó cũng hợp lệ

Gọi đoạn $[l, r]$ là hợp lệ nếu $\max - \min \le \Delta$. Có một nhận xét rất quan trọng:

Nhận xét
Nếu $[l, r]$ hợp lệ thì mọi đoạn nằm bên trong nó cũng hợp lệ (bỏ bớt phần tử thì max chỉ giảm, min chỉ tăng). Ngược lại nếu $[l, r]$ không hợp lệ thì mọi đoạn chứa nó cũng không hợp lệ.

Hệ quả: khi $r$ tăng dần, vị trí $l$ nhỏ nhất để $[l, r]$ hợp lệ không bao giờ lùi lại. Vậy ta dùng hai con trỏ:

  1. Lần lượt đưa $a[r]$ vào cửa sổ ($r = 0, 1, \ldots, n-1$).
  2. Trong khi $\max - \min > \Delta$ thì bỏ $a[l]$ ra khỏi cửa sổ và tăng $l$.
  3. Lúc này $[l, r]$ là đoạn hợp lệ dài nhất kết thúc tại $r$, cập nhật đáp án bằng $r - l + 1$.

Mỗi phần tử vào cửa sổ 1 lần và ra tối đa 1 lần, nên chỉ có $O(n)$ thao tác thêm/bớt. Câu hỏi còn lại là: lấy max, min của cửa sổ nhanh bằng cách nào? Đó chính là chỗ khác nhau giữa cách 2 (multiset) và cách 3 (deque). Em nhập dữ liệu rồi bấm Tiếp để xem cửa sổ trượt (hàng dưới là cửa sổ đã sắp xếp, giống như cái multiset trong cách 2):

Ví dụ nhanh:

Cách 1Vét cạn mọi điểm đầu – O(n²)

Cách dễ nghĩ nhất: thử mọi điểm đầu $l$, rồi kéo dài $r$ sang phải, vừa đi vừa cập nhật mx, mn. Khi mx - mn > d thì dừng ngay vì kéo dài thêm cũng không thể hết vượt (nhận xét ở trên).

C++▶ Chạy tay từng dòng codeCách 1: thử mọi điểm đầu l, kéo dài r tới khi max − min vượt d.
Dữ liệu mẫu:
  • mx, mn được khởi tạo bằng a[l] cho mỗi điểm đầu mới, rồi cập nhật dần khi thêm a[r].
  • Lệnh break là "cắt tỉa" quan trọng: không có nó thì luôn chạy đủ $n^2/2$ bước kể cả khi đáp án nhỏ.
Không gian$O(n)$
Thời gian$O(n^2)$

Kết quả chạy thật: bản C++ đúng 40/100 test (Subtask 1: 40/40, Subtask 2: 0/40, Subtask 3: 0/20). Cả 40 test của subtask 1 đều chạy dưới 0.03 giây; từ test 41 trở đi bị quá thời gian. Dù có break, ở các test lớn nửa đầu dãy gồm toàn số nhỏ nên với mỗi $l$ ở nửa đầu, $r$ phải chạy tới khoảng $n/2$, tổng cộng cỡ $n^2/8$ bước: $n = 10^5$ đã là hơn $10^9$ bước.

Cách 2Hai con trỏ + multiset – O(n log n)

Áp dụng ý tưởng hai con trỏ. Để biết max, min của cửa sổ, ta bỏ mọi phần tử của cửa sổ vào một multiset: nó luôn tự sắp xếp tăng dần, nên *s.begin() là min, *s.rbegin() là max. Phải dùng multiset vì cửa sổ có thể chứa nhiều số bằng nhau.

C++▶ Chạy tay từng dòng codeCách 2: hai con trỏ, multiset giữ các phần tử của cửa sổ để lấy max, min.
Dữ liệu mẫu:
  • s.erase(s.find(a[l])): chỉ xoá một bản sao của a[l]. Nếu viết s.erase(a[l]) thì multiset sẽ xoá tất cả các phần tử bằng a[l] – lỗi rất hay gặp!
  • Vòng while luôn dừng: khi cửa sổ chỉ còn 1 phần tử thì max − min $= 0 \le \Delta$.
  • Bản Python không có sẵn multiset nên mình dùng 2 heap (heap max và heap min) lưu cặp (giá trị, chỉ số), phần tử nào có chỉ số $< l$ ở đỉnh heap thì bỏ đi ("xoá trễ").
Không gian$O(n)$
Thời gian$O(n \log n)$

Kết quả chạy thật: bản C++ đúng 80/100 test (Subtask 1: 40/40, Subtask 2: 40/40, Subtask 3: 0/20). Subtask 2 chạy tối đa 0.06 giây (thời gian CPU), nhưng với $n = 10^6$ thì multiset phải cấp phát $10^6$ nút cây đỏ đen, mất khoảng 0.8–1.3 giây CPU trên máy mình, vượt giới hạn 0.5 giây.

Cách 3Hai con trỏ + hàng đợi đơn điệu – O(n)

Ta không cần cả cái multiset, chỉ cần max và min. Xét riêng max: khi thêm $a[r]$, mọi phần tử $a[j] \le a[r]$ với $j < r$ sẽ không bao giờ là max nữa (vì $a[r]$ lớn hơn hoặc bằng và còn ở lại cửa sổ lâu hơn). Nên ta bỏ luôn chúng. Kết quả là deque mx lưu các chỉ số mà giá trị giảm dần từ đầu tới cuối, và a[mx.front()] chính là max của cửa sổ. Deque mn làm ngược lại (giá trị tăng dần) để lấy min.

Khi tăng $l$, nếu chỉ số ở đầu deque nhỏ hơn $l$ (đã ra khỏi cửa sổ) thì pop_front. Vì deque lưu chỉ số tăng dần nên chỉ cần kiểm tra phần tử đầu.

C++▶ Chạy tay từng dòng codeCách 3: hai con trỏ + 2 deque đơn điệu giữ chỉ số của max, min.
Dữ liệu mẫu:
  • Mỗi chỉ số được push_back 1 lần và bị pop (ở đầu hoặc cuối) tối đa 1 lần, nên tổng thời gian là $O(n)$ dù có các vòng while lồng trong for.
  • Trong vòng while(a[mx.front()] - a[mn.front()] > d), mình tăng l trước rồi mới kiểm tra đầu 2 deque. Deque không bao giờ rỗng ở đây vì chỉ số r luôn còn trong cả hai.
  • Đề yêu cầu 0.5 giây với $10^6$ số, phần đọc dữ liệu đã chiếm đáng kể, nên đừng quên ios::sync_with_stdio(0); cin.tie(0);.
Không gian$O(n)$
Thời gian$O(n)$

Kết quả chạy thật: bản C++ đúng 100/100 test (Subtask 1: 40/40, Subtask 2: 40/40, Subtask 3: 20/20). Test lâu nhất chỉ mất 0.13 giây (thời gian CPU), dư sức với giới hạn 0.5 giây.

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

Mình chấm cả 3 cách (bản C++ và bản Python) trên toàn bộ 100 test với giới hạn 0.5 giây. Máy chấm của mình lúc chạy đang khá tải nên mình đo thời gian CPU của chương trình cho công bằng:

Lời giảiSubtask 1
test 1–40
Subtask 2
test 41–80
Subtask 3
test 81–100
TổngCPU lớn nhất
(test đúng)
Cách 1 – C++40/400/400/2040/1000.03s
Cách 1 – Python20/400/400/2020/1000.05s
Cách 2 – C++40/4040/400/2080/1000.06s
Cách 2 – Python40/4040/400/2080/1000.50s
Cách 3 – C++40/4040/4020/20100/1000.13s
Cách 3 – Python40/4040/400/2080/1000.24s
Ghi chú của mình: cả 3 cách cho đáp án đúng ở mọi test mà chúng chạy kịp. Cách 3 (deque đơn điệu) là cách duy nhất qua được subtask 3 trong 0.5 giây. Với Python thì cách 1 đúng 20/100, cách 2 đúng 80/100, cách 3 đúng 80/100 test: Python đọc và xử lý $10^6$ số đã mất hơn 0.5 giây, nên bản Python chỉ để các bạn tham khảo cách cài đặt.
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