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

Giá trị gần nhất nhỏ hơn

Giá trị gần nhất nhỏ hơn
Nguồn: CLUE OJ – Chương 15: Ngăn xếp, Hàng đợi Độ khó: Trung bình Ngăn xếp đơn điệu Bài kinh điển

Đề bài

Cho một mảng gồm $n$ số nguyên, nhiệm vụ của bạn là với mỗi vị trí của mảng, tìm vị trí gần nhất bên trái của nó mà có giá trị nhỏ hơn, tìm vị trí gần nhất bên phải của nó mà có giá trị nhỏ hơn.

Input:

  • Dòng đầu tiên có một số nguyên $n$: kích thước của mảng với $1 \le n \le 2 \cdot 10^5$.
  • Dòng thứ hai có $n$ số nguyên $x_1, x_2, \ldots, x_n$: các giá trị của mảng với $1 \le x_i \le 10^9$.

Output: gồm 2 dòng:

  • Dòng đầu tiên: in $n$ số nguyên – vị trí gần nhất bên trái nhỏ hơn cho mỗi vị trí trong mảng. Nếu không có, in $0$.
  • Dòng thứ 2: in $n$ số nguyên – vị trí gần nhất bên phải nhỏ hơn cho mỗi vị trí trong mảng. Nếu không có, in giá trị $n + 1$.

Subtask:

  • $20\%$ số test đầu tiên có $N \le 10^2$;
  • $10\%$ số test tiếp theo có $N \le 10^3$;
  • $10\%$ số test tiếp theo có $N \le 10^4$;
  • $10\%$ số test tiếp theo có $N \le 10^5$;
  • $50\%$ số test còn lại không có điều kiện gì thêm.

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

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

Giải thích ví dụ (vị trí đánh số từ 1): với $x_4 = 4$, đi sang trái gặp $x_3 = 1 < 4$ nên vị trí trái là $3$; đi sang phải $x_5 = 8$ không nhỏ hơn, $x_6 = 3 < 4$ nên vị trí phải là $6$. Với $x_3 = 1$ (nhỏ nhất mảng) thì bên trái không có → $0$, bên phải không có → $n + 1 = 9$. Chú ý: "nhỏ hơn" là nhỏ hơn hẳn – $x_7 = 2$ không tính $x_1 = 2$ là nhỏ hơn.

Lời mở đầu

Đây là bài kinh điển nhất của ngăn xếp đơn điệu (monotonic stack). Rất nhiều bài khó hơn – hình chữ nhật lớn nhất trong histogram, tích lớn nhất, đếm đoạn con có min/max cho trước... – đều dùng nó làm bước đầu tiên. Mình giải từ cách "đi từng bước" dễ hiểu tới ngăn xếp $O(n)$.

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 đúng như subtask của đề:

TestnGiá trị $x_i$Kiểu dãy
1–102 … 101 … 30ngẫu nhiên
11–201003 … 32.753ngẫu nhiên
21–301.0002 … 32.767ngẫu nhiên
31–4010.0001 … 32.768ngẫu nhiên
41–50100.0001 … 32.768ngẫu nhiên
51–70200.0001 … 200.000tăng dần
71–90200.0001 … 200.000giảm dần
91–100200.0001 … 100.001tăng rồi giảm
  • Các test 1–50 có giá trị ngẫu nhiên (đa số $\le 32\,768$), có lặp lại giá trị – nhớ dùng so sánh nhỏ hơn hẳn.
  • 50 test lớn nhất ($n = 2 \cdot 10^5$) là các dãy "ác": tăng dần $1, 2, \ldots, n$ (test 51–70), giảm dần (test 71–90) và tăng rồi giảm hình ngọn núi $1, 2,$ $\ldots,$ $100\,001,$ $100\,000,$ $\ldots,$ $1$ (test 91–100). Đây chính là những dãy làm cách "đi từng bước" phải đi rất xa.

Kết quả chạy thật (giới hạn 1 giây):

SubtaskĐiều kiệnTestCách 1
(đi từng bước)
Cách 2
(ngăn xếp)
1 (20%)$N \le 10^2$1–2020/2020/20
2 (10%)$N \le 10^3$21–3010/1010/10
3 (10%)$N \le 10^4$31–4010/1010/10
4 (10%)$N \le 10^5$41–5010/1010/10
5 (50%)$N \le 2 \cdot 10^5$51–1000/5050/50

Ý tưởngAi đứng sau và nhỏ hơn thì "che" người đứng trước

Xét bên trái. Giả sử đang ở vị trí $i$, và có hai vị trí $j < k < i$ với $x_j \ge x_k$. Khi đó $j$ không bao giờ là đáp án cho $i$ hay bất kỳ vị trí nào sau đó: nếu $x_j < x_i$ thì $x_k \le x_j < x_i$ và $k$ còn gần hơn. Nghĩa là $j$ đã bị $k$ che mất, có thể vứt bỏ.

Ngăn xếp đơn điệu
Giữ một ngăn xếp các chỉ số còn "có ích", giá trị tăng dần từ đáy lên đỉnh. Khi xét $i$: bỏ (pop) mọi đỉnh có $x_{top} \ge x_i$ (chúng bị $i$ che từ nay về sau). Đỉnh còn lại (nếu có) chính là vị trí gần nhất bên trái nhỏ hơn $x_i$; ngăn xếp rỗng thì đáp án là $0$. Cuối cùng đẩy $i$ vào.

Bên phải làm y hệt nhưng duyệt từ $n$ về $1$ (ngăn xếp rỗng thì đáp án là $n + 1$). Mỗi chỉ số được đẩy vào 1 lần và lấy ra tối đa 1 lần, nên mỗi lượt chỉ $O(n)$.

Em nhập dãy rồi bấm Tiếp để xem ngăn xếp chạy lượt trái rồi lượt phải:

Ví dụ nhanh:

Cách 1Đi sang trái, sang phải từng bước – $O(n^2)$

Cách tự nhiên nhất: với mỗi $i$, cho $j$ đi lùi từ $i - 1$ về $1$ cho tới khi gặp $a_j < a_i$; tương tự đi tiến từ $i + 1$ tới $n$. Nếu đi hết mảng thì $j$ dừng ở $0$ hoặc $n + 1$ – vừa khéo đúng giá trị đề yêu cầu in khi "không có".

C++▶ Chạy tay từng dòng codeCách 1: mỗi vị trí đi sang trái/phải từng bước tới khi gặp số nhỏ hơn – O(n²).
Dữ liệu mẫu:
  1. Vòng while(j >= 1 && a[j] >= a[i]) j--; bỏ qua các số không nhỏ hơn $a_i$ (dấu >= vì cần nhỏ hơn hẳn).
  2. Không cần xét riêng trường hợp "không có": vòng lặp tự dừng ở $j = 0$ (bên trái) hoặc $j = n + 1$ (bên phải).
  3. Với dãy ngẫu nhiên, $j$ thường dừng rất sớm. Nhưng với dãy tăng dần, mọi số phía sau đều lớn hơn nên vòng bên phải đi tới cuối mảng: tổng cộng $\approx n^2/2 = 2 \cdot 10^{10}$ bước.
Không gian$O(n)$
Thời gian$O(n^2)$ trường hợp xấu

Kết quả chạy thật: Bản C++ đúng 50/100 test, quá thời gian ở test 51–100; test đúng chạy lâu nhất 0.03s. Bản Python đúng 50/100 test, quá thời gian ở test 51–100; test đúng chạy lâu nhất 0.46s.

Cách 2Ngăn xếp đơn điệu – $O(n)$

Cài đặt đúng ý tưởng ở trên với stack<int> chứa chỉ số.

C++▶ Chạy tay từng dòng codeCách 2: ngăn xếp chỉ số với giá trị tăng dần, lượt trái rồi lượt phải – O(n).
Dữ liệu mẫu:
  1. Ngăn xếp chứa chỉ số chứ không chứa giá trị, vì đề hỏi vị trí; giá trị lấy qua a[st.top()].
  2. Điều kiện pop là a[st.top()] >= a[i]: các số bằng $a_i$ cũng bị bỏ, vì chúng không nhỏ hơn hẳn $a_i$ và từ nay đã bị $i$ che.
  3. Sau vòng while, ngăn xếp rỗng nghĩa là không có số nào bên trái nhỏ hơn → $0$; lượt phải thì → $n + 1$.
  4. Trước lượt phải phải làm rỗng ngăn xếp (dòng while(!st.empty()) st.pop();), nếu không sẽ dính chỉ số của lượt trái.
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.29s.

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

CáchNgôn ngữĐúngLâu nhấtGhi chú
1. Đi sang trái/phải từng bướcC++50/100> 1sQuá thời gian test 51–100
Python50/100> 1sQuá thời gian test 51–100
2. Ngăn xếp đơn điệuC++100/1000.05sFull điểm
Python100/1000.29sFull điểm
Ghi chú của mình: cách 1 qua đúng 4 subtask đầu (vì các test đó là dãy ngẫu nhiên nên $j$ dừng rất sớm), còn 50 test dãy tăng/giảm/ngọn núi ở subtask 5 thì quá thời gian. Ngăn xếp đơn điệu qua hết 100 test trong khoảng 0,05 giây. Đây là "viên gạch" sẽ dùng lại ở rất nhiều bài sau trong chương – các bạn nhớ kỹ khuôn mẫu: while (đỉnh không còn có ích) pop; ghi đáp án = đỉnh; push i.
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