Sliding window — thống kê trên cửa sổ trượt
Tính max/min/sum/distinct trên N phần tử gần nhất: monotonic deque cho max O(1) amortized, two-pointer cho sum/điều kiện, hash map cho distinct.
TL;DR: Cửa sổ trượt (sliding window) là kỹ thuật duy trì một tập con các phần tử liên tiếp trong mảng/stream và trả lời truy vấn trên tập đó khi cửa sổ dịch sang phải. Ba kỹ thuật chính: (1) Monotonic deque cho max/min trong O(1) amortized — giữ deque giảm dần, pop khi phần tử mới lớn hơn hoặc ngoài cửa sổ; (2) Two-pointer cho sum/điều kiện — mở rộng phải khi chưa đủ, thu hẹp trái khi vượt; (3) HashMap + deque cho distinct. Tất cả đều đạt O(n) tổng nhờ mỗi phần tử vào deque/pointer đúng một lần và ra đúng một lần.
Hệ thống monitoring của Cloudflare phải tính max latency trong 60 giây gần nhất trên mỗi trong số hàng nghìn edge node, cập nhật mỗi giây. Nếu quét lại toàn bộ cửa sổ mỗi giây: O(60) mỗi node × 10.000 node × 1 update/giây = 600.000 ops/giây — chấp nhận được. Nhưng khi cửa sổ là 1 giờ (3.600 giây) với 100 node/giây, chi phí tăng lên 360 triệu ops/giây.
Monotonic deque giải quyết bài này với O(1) amortized cho mỗi update và O(1) cho query, bất kể kích thước cửa sổ.
1. Bài toán tổng quát
Cho mảng (hoặc stream) A[0..n-1] và cửa sổ kích thước k. Với mỗi vị trí r từ k-1 đến n-1, cần tính thống kê trên A[r-k+1 .. r].
| Bài toán con | Kỹ thuật | Độ phức tạp |
|---|---|---|
| Max / Min trong cửa sổ cố định | Monotonic deque | O(n) tổng |
| Sum / Average trong cửa sổ cố định | Prefix sum hoặc sliding sum | O(n) tổng |
| Subarray dài nhất thoả điều kiện sum | Two-pointer | O(n) |
| Substring không lặp ký tự | Two-pointer + HashMap | O(n) |
| Số distinct trong cửa sổ | Deque + HashMap | O(n log n) hoặc O(n) |
Cấu trúc dữ liệu cốt lõi — deque (double-ended queue) — là liên kết trực tiếp tới bài queue và deque: push/pop cả hai đầu O(1).
2. Kỹ thuật 1 — Monotonic deque cho sliding max
2.1 Ý tưởng
Giữ một deque chứa chỉ số (index) các phần tử đang là ứng viên max cho cửa sổ hiện tại. "Ứng viên" theo nghĩa: phần tử nhỏ hơn phần tử mới hơn (index lớn hơn) trong cửa sổ sẽ không bao giờ là max — vì phần tử đó vừa nhỏ hơn vừa sẽ ra khỏi cửa sổ trước. Ta pop chúng ra khỏi deque khi nạp phần tử mới, giữ deque giảm dần.
Deque lúc nào cũng thỏa A[deque[0]] ≥ A[deque[1]] ≥ ... → max cửa sổ hiện tại luôn là A[deque[0]].
2.2 Pseudocode
function slidingWindowMax(A, k):
-- Trả về mảng max[i] = max của A[i-k+1 .. i], i từ k-1 đến n-1
DQ <- empty deque -- lưu chỉ số, giảm dần theo giá trị
result <- []
for r <- 0 to A.length - 1:
-- Bước 1: loại chỉ số ngoài cửa sổ (phần tử quá cũ)
while DQ không rỗng và DQ.front() < r - k + 1:
DQ.pop_front()
-- Bước 2: loại chỉ số nhỏ hơn A[r] từ phía sau (không còn là ứng viên)
while DQ không rỗng và A[DQ.back()] <= A[r]:
DQ.pop_back()
DQ.push_back(r)
-- Bước 3: sau khi cửa sổ đủ k phần tử, ghi nhận max
if r >= k - 1:
result.append(A[DQ.front()])
return result
// Time: O(n) Space: O(k)
Vì sao O(n)? Mỗi chỉ số r được push vào deque đúng 1 lần và pop ra đúng 1 lần (hoặc ở bước 1 do out-of-window, hoặc ở bước 2 do bị phần tử lớn hơn đẩy ra). Tổng số phép pop không vượt tổng số phép push = n. Đây là phân tích amortized: tổng chi phí O(n) dù mỗi vòng lặp có thể pop nhiều lần — xem amortized analysis.
2.3 Trace — A = [3, 1, 2, 5, 4], k = 3
| r | A[r] | Bước 1 (pop front) | Bước 2 (pop back) | DQ (chỉ số) | Max |
|---|---|---|---|---|---|
| 0 | 3 | — | — | [0] | — |
| 1 | 1 | — | 1 < 3, không pop | [0, 1] | — |
| 2 | 2 | — | pop 1 (A[1]=1 ≤ 2) | [0, 2] | A[0]=3 |
| 3 | 5 | pop 0 (0 < 3-3+1=1) | pop 2 (2≤5), pop 0 (3≤5) | [3] | A[3]=5 |
| 4 | 4 | — | 4 < 5, không pop | [3, 4] | A[3]=5 |
Kết quả: [3, 5, 5] — đúng với cửa sổ [3,1,2], [1,2,5], [2,5,4].
flowchart LR
subgraph "r=2: nạp A[2]=2"
D0["DQ: [0,1]"]
POP["pop back: A[1]=1 ≤ 2"]
D1["DQ: [0,2]"]
MAX1["max = A[0] = 3"]
D0 --> POP --> D1 --> MAX1
end
subgraph "r=3: nạp A[3]=5"
D2["DQ: [0,2]"]
POPF["pop front: idx 0 ngoài cửa sổ"]
D3["DQ: [2]"]
POPB["pop back: A[2]=2 ≤ 5, rồi (empty)"]
D4["DQ: [3]"]
MAX2["max = A[3] = 5"]
D2 --> POPF --> D3 --> POPB --> D4 --> MAX2
end3. Kỹ thuật 2 — Two-pointer cho subarray thoả điều kiện
Two-pointer dùng hai biến l (trái) và r (phải) cùng quét từ trái sang phải. Mở rộng r để tìm trạng thái hợp lệ; thu hẹp l khi vi phạm điều kiện.
3.1 Bài toán: subarray dài nhất có sum ≤ target
function longestSubarraySumAtMost(A, target):
l <- 0
currentSum <- 0
maxLen <- 0
for r <- 0 to A.length - 1:
currentSum <- currentSum + A[r] -- mở rộng cửa sổ sang phải
-- Thu hẹp từ trái khi vi phạm
while currentSum > target:
currentSum <- currentSum - A[l]
l <- l + 1
-- Cửa sổ [l..r] hợp lệ
maxLen <- max(maxLen, r - l + 1)
return maxLen
// Time: O(n) Space: O(1)
O(n) vì r chỉ tăng, l chỉ tăng — mỗi cái tăng tối đa n lần.
3.2 Bài toán: substring dài nhất không lặp ký tự
Dùng thêm HashMap để theo dõi lần xuất hiện gần nhất của mỗi ký tự:
function longestUniqueSubstring(S):
lastSeen <- Map() -- ký tự → chỉ số xuất hiện gần nhất
l <- 0
maxLen <- 0
for r <- 0 to S.length - 1:
c <- S[r]
if c trong lastSeen và lastSeen[c] >= l:
-- Ký tự lặp lại trong cửa sổ — dịch l sang sau nó
l <- lastSeen[c] + 1
lastSeen[c] <- r
maxLen <- max(maxLen, r - l + 1)
return maxLen
// Time: O(n) Space: O(alphabet_size)
Trace S = "abcab":
| r | c | lastSeen | l | Cửa sổ | len |
|---|---|---|---|---|---|
| 0 | a | {a:0} | 0 | "a" | 1 |
| 1 | b | {a:0,b:1} | 0 | "ab" | 2 |
| 2 | c | {a:0,b:1,c:2} | 0 | "abc" | 3 |
| 3 | a | a lặp, lastSeen[a]=0 ≥ l=0 → l=1 | 1 | "bca" | 3 |
| 4 | b | b lặp, lastSeen[b]=1 ≥ l=1 → l=2 | 2 | "cab" | 3 |
Kết quả: 3 ("abc" hoặc "bca" hoặc "cab").
4. Kỹ thuật 3 — Cửa sổ cố định + HashMap cho distinct
Đếm số phần tử distinct trong cửa sổ kích thước k cố định:
function slidingWindowDistinct(A, k):
freq <- Map() -- phần tử → số lần xuất hiện trong cửa sổ
distinct <- 0
result <- []
for r <- 0 to A.length - 1:
-- Thêm A[r] vào cửa sổ
freq[A[r]] <- freq.getOrDefault(A[r], 0) + 1
if freq[A[r]] = 1: distinct <- distinct + 1 -- lần đầu xuất hiện
-- Loại A[r-k] ra khi cửa sổ đã đủ k+1
if r >= k:
out <- A[r - k]
freq[out] <- freq[out] - 1
if freq[out] = 0: distinct <- distinct - 1 -- biến mất khỏi cửa sổ
if r >= k - 1:
result.append(distinct)
return result
// Time: O(n) Space: O(k)
5. Khi nào dùng kỹ thuật nào
| Bài toán | Kỹ thuật | Điều kiện áp dụng |
|---|---|---|
| Max / Min, cửa sổ cố định k | Monotonic deque | Cửa sổ kích thước cố định |
| Subarray dài nhất thoả điều kiện | Two-pointer | Phần tử không âm; hoặc điều kiện monotone với kích thước cửa sổ |
| Substring / subarray ngắn nhất chứa tất cả | Two-pointer + Map | Điều kiện kiểm tra được trong O(1) khi thêm/bớt |
| Số distinct trong cửa sổ | Map + sliding | Cửa sổ cố định hoặc biến đổi |
| Tổng, sum, average | Prefix sum | Chỉ query, không update online |
Dấu hiệu nhận biết bài sliding window: đề hỏi về "subarray liên tiếp", "cửa sổ N phần tử gần nhất", "dài nhất/ngắn nhất thoả [điều kiện trên tập liên tiếp]".
6. Pitfall
Pitfall 1 — Deque lưu giá trị thay vì chỉ số
-- SAI: lưu giá trị vào deque
DQ.push_back(A[r]) -- Không biết khi nào phần tử ra khỏi cửa sổ!
if DQ.front() < A[r-k]: DQ.pop_front() -- So sánh giá trị sai
-- DUNG: lưu chỉ số, kiểm tra out-of-window bằng chỉ số
DQ.push_back(r)
while DQ.front() < r - k + 1: DQ.pop_front() -- so sánh index
Deque phải lưu chỉ số để kiểm tra chính xác khi nào phần tử ra ngoài cửa sổ. Lưu giá trị mất thông tin vị trí — không phân biệt được A[i] = A[j] ở hai vị trí khác nhau.
Pitfall 2 — Two-pointer áp dụng khi điều kiện không monotone
-- SAI: dùng two-pointer cho "subarray có tổng = target" với A gồm âm và dương
-- Khi thu hẹp l, tổng có thể tăng hoặc giảm không đoán trước được
-- → two-pointer không đảm bảo tìm đủ mọi cửa sổ hợp lệ
-- DUNG với A gồm cả số âm: dùng prefix sum + HashMap
prefix[0] <- 0
for i <- 1 to n: prefix[i] <- prefix[i-1] + A[i-1]
-- Tìm i, j sao cho prefix[j] - prefix[i] = target
-- tương đương tìm prefix[i] = prefix[j] - target trong HashMap
Two-pointer chỉ đúng khi mảng không âm (hoặc khi điều kiện monotone: cửa sổ lớn hơn luôn vi phạm hoặc luôn hợp lệ hơn).
Pitfall 3 — Quên điều kiện biên khi l vượt r
-- SAI: khi target rất nhỏ, l có thể vượt r trong while loop
while currentSum > target:
currentSum <- currentSum - A[l]
l <- l + 1
-- Sau loop, l > r → cửa sổ rỗng, nhưng vẫn tính r - l + 1 = âm
-- DUNG: giới hạn l không vượt r+1, hoặc kiểm tra kích thước cửa sổ
while currentSum > target và l <= r:
currentSum <- currentSum - A[l]
l <- l + 1
if l <= r: maxLen <- max(maxLen, r - l + 1)
Khi tất cả phần tử đơn lẻ đều lớn hơn target, mọi cửa sổ đều vi phạm — kết quả là 0, không phải âm.
7. Liên hệ các bài khác
- Queue và deque: Monotonic deque là ứng dụng trực tiếp của deque — cần hiểu push/pop hai đầu O(1) mới thấy vì sao sliding max chạy O(n). Đọc bài đó để nắm cấu trúc trước khi học kỹ thuật này.
- Amortized analysis: Lập luận "mỗi phần tử vào deque đúng 1 lần, ra đúng 1 lần → tổng O(n)" là phân tích amortized. Bài đó cung cấp công cụ chứng minh chính thức cho lập luận này.
- Count-Min Sketch: bài trước trong module — kết hợp sliding window + CMS ra pattern "cửa sổ thời gian + frequency sketch": dùng 2 CMS luân phiên (swap mỗi nửa window) để rate limiting theo thời gian. Hiểu cả hai kỹ thuật mới triển khai được pattern này.
- HyperLogLog: tương tự, kết hợp HLL + sliding window cho "distinct count trong 5 phút gần nhất" — pattern phổ biến trong analytics dashboard.
- Mini-challenge top-K: bài thực hành — kết hợp monotonic deque hoặc heap + cửa sổ trượt để tìm top-K phần tử trên stream.
📚 Deep Dive
Bài báo / sách:
- Introduction to Algorithms (CLRS) — Sliding window thường được trình bày như kỹ thuật trong bài tập (Chapter 15 Dynamic Programming, DP on sequences), nhưng monotonic deque xuất hiện trong các bài tập về stack-based algorithms.
- Competitive Programmer's Handbook (Laaksonen), Chương 8 "Amortized analysis" — trình bày monotonic stack/deque với chứng minh amortized chặt chẽ.
- LeetCode tagged "sliding window" có ~250 bài — pattern phổ biến nhất trong coding interview.
Two-pointer vs Sliding window: hai tên thường dùng lẫn nhau. Phân biệt tinh tế: "sliding window" nhấn mạnh kích thước cửa sổ (fixed hoặc variable); "two-pointer" nhấn mạnh cơ chế di chuyển con trỏ. Thực tế cùng một kỹ thuật.
Ứng dụng production:
- Nginx rate limiting:
limit_reqmodule dùng leaky bucket (biến thể sliding window) — đếm request trong cửa sổ trượt 1 giây. - Kafka consumer lag monitoring: tính throughput trung bình 5 phút dùng sliding sum trên window.
- Stock price analysis (TA indicators): RSI, MACD, Bollinger Bands đều là sliding window trên giá đóng cửa.
- Network intrusion detection: tính rate packet trong cửa sổ thời gian nhỏ để phát hiện DDoS.
Cấu trúc nâng cao:
- Segment tree with lazy propagation: cho phép max/min query trên arbitrary range (không chỉ sliding window) trong O(log n).
- Sparse table: precompute range max/min trong O(n log n), query O(1) — tốt hơn deque khi cửa sổ thay đổi tùy ý và không update online.
Tóm tắt
- Sliding window duy trì một cửa sổ trên mảng/stream và cập nhật kết quả khi cửa sổ dịch — tránh tính lại từ đầu.
- Monotonic deque cho sliding max/min: giữ deque giảm dần (chỉ số), pop back khi phần tử mới lớn hơn, pop front khi ngoài cửa sổ. Max =
A[deque.front()]. O(n) tổng, O(1) amortized mỗi phép. - Two-pointer cho sum/điều kiện: mở rộng
r, thu hẹpl— chỉ đúng khi điều kiện monotone với kích thước cửa sổ (mảng không âm hoặc điều kiện tương đương). - HashMap + sliding cho distinct: tăng/giảm freq khi thêm/xoá phần tử, đếm distinct từ số entry freq > 0.
- Dấu hiệu nhận biết: "subarray/substring liên tiếp", "N phần tử gần nhất", "dài nhất/ngắn nhất thoả [điều kiện trên tập liên tiếp]".
Tự kiểm tra
Q1Deque trong slidingWindowMax lưu chỉ số thay vì giá trị. Nếu lưu giá trị, thuật toán sẽ sai ở đâu? Cho ví dụ cụ thể.▸
Lưu giá trị mất thông tin vị trí. Xét A = [5, 1, 5], k=2. Ở r=2, A[2]=5. Nếu deque lưu giá trị [5, 1] từ r=0,1, ta pop back 1 vì 1 < 5, xong pop 5 vì 5 ≤ 5 → deque rỗng → push 5 → deque [5]. Max trả về 5.
Nhưng nếu đây là r=0 hay r=2? Ta không biết! Bước kiểm tra "phần tử ngoài cửa sổ" (pop front khi chỉ số quá cũ) không làm được với giá trị — vì không biết giá trị 5 thuộc cửa sổ nào. Khi A có nhiều phần tử bằng nhau, lưu giá trị hoàn toàn không phân biệt được, dẫn đến giữ lại phần tử đã ra ngoài cửa sổ hoặc loại nhầm phần tử còn trong cửa sổ.
Q2Vì sao monotonic deque đạt O(n) tổng dù mỗi vòng lặp có thể pop nhiều phần tử? Lập luận amortized cụ thể.▸
Định nghĩa "tín dụng": mỗi phần tử khi push vào deque nhận 1 tín dụng. Mỗi phép pop tiêu 1 tín dụng của phần tử bị pop. Tổng tín dụng phát ra = n (mỗi phần tử push đúng 1 lần). Mỗi pop tiêu đúng 1 tín dụng → tổng pop không vượt tổng tín dụng = n.
Nói cách khác: mỗi chỉ số r vào deque đúng 1 lần (khi xử lý r) và ra khỏi deque đúng 1 lần (hoặc pop front vì ngoài cửa sổ, hoặc pop back vì có phần tử lớn hơn). Tổng số phép push = n; tổng số phép pop ≤ n. Cả vòng for chạy O(n) phép push + O(n) phép pop = O(n) tổng cộng.
Q3Two-pointer cho bài 'subarray dài nhất có sum ≤ target' chỉ đúng khi mảng không âm. Tại sao? Và khi mảng có số âm, dùng kỹ thuật gì?▸
Two-pointer dựa vào tính monotone: khi mở rộng cửa sổ (tăng r), tổng không giảm; khi thu hẹp (tăng l), tổng không tăng. Với mảng không âm, điều này đúng. Khi có số âm, thêm phần tử âm vào cửa sổ làm tổng giảm — mở rộng cửa sổ có thể khiến vi phạm trở thành hợp lệ, nên thu hẹp sớm là sai.
Với số âm, dùng prefix sum + HashMap: prefix[i] = A[0]+...+A[i-1]. Subarray A[l..r] có sum = prefix[r+1] - prefix[l]. Tìm i,j sao cho prefix[j] - prefix[i] = target tương đương lookup prefix[i] = prefix[j] - target trong HashMap — O(n) tổng.
Q4Trong longestUniqueSubstring, tại sao điều kiện dịch l là lastSeen[c] >= l thay vì chỉ c trong lastSeen? Cho ví dụ cho thấy bỏ điều kiện này gây lỗi.▸
Khi ký tự c xuất hiện trước nhưng đã nằm ngoài cửa sổ hiện tại (chỉ số < l), nó không còn trong cửa sổ — không gây lặp. Nếu bỏ điều kiện lastSeen[c] >= l, ta dịch l về vị trí sau occurrence cũ, nhưng occurrence cũ đã ngoài cửa sổ — l bị đặt nhỏ lại (lùi về)!
Ví dụ: S = "abac". Sau r=2 (c='a'), lastSeen={a:2, b:1}, l=1 (đã dịch do 'a' ở r=0). Tới r=3 (c='c'), c chưa có trong map. Giả sử trước đó ta đã gặp 'a' ở r=0, lastSeen[a]=0 < l=1. Nếu bỏ điều kiện, ta dịch l=0+1=1 — l không thay đổi, may mắn đúng. Nhưng xét S = "abba": r=3 (c='a'), lastSeen[a]=0, l đang là 2 (sau 'b' lặp). Bỏ điều kiện → l = 0+1 = 1 < 2 — l bị lùi về, cửa sổ mở rộng chứa 'b' lặp → sai.
Q5Cho A = [2, 3, 1, 5, 4, 2], k = 3. Trace đầy đủ deque (lưu chỉ số) và kết quả max ở mỗi bước.▸
Dùng slidingWindowMax, lưu chỉ số, pop back khi giá trị <= A[r], pop front khi index < r-k+1.
r=0 A[0]=2: DQ=[0]. r=1 A[1]=3: pop 0 (A[0]=2 <= 3) → DQ=[1]. r=2 A[2]=1: DQ=[1,2]. Max=A[1]=3.
r=3 A[3]=5: pop front? idx 1 < 3-3+1=1? không (1 không < 1). pop back: A[2]=1 <= 5 → pop, A[1]=3 <= 5 → pop → DQ=[3]. Max=A[3]=5.
r=4 A[4]=4: pop front? 3 < 4-3+1=2? không. pop back: A[3]=5 > 4 → không pop. DQ=[3,4]. Max=A[3]=5.
r=5 A[5]=2: pop front? 3 < 5-3+1=3? không (3 không < 3). pop back: A[4]=4 > 2 → dừng. DQ=[3,4,5]. Max=A[3]=5.
Kết quả: [3, 5, 5, 5] cho các cửa sổ [2,3,1], [3,1,5], [1,5,4], [5,4,2].
Q6Bài 'đếm số cửa sổ kích thước k có đúng m phần tử distinct' có thể giải bằng sliding window không? Phác thảo hướng tiếp cận.▸
Có, dùng kỹ thuật "đúng m distinct = atMost(m) - atMost(m-1)" — chuyển bài toán "đếm cửa sổ có đúng m distinct" thành hiệu của hai bài "đếm cửa sổ có nhiều nhất m distinct".
Hàm atMostK(A, k_size, K_distinct): dùng two-pointer + HashMap đếm distinct trong cửa sổ biến đổi — khi distinct > K_distinct, thu hẹp l. Đếm số cửa sổ hợp lệ kết thúc tại r là r - l + 1 (mỗi l' từ l đến r đều cho cửa sổ hợp lệ). count(exactly_m) = atMostK(m) - atMostK(m-1). Độ phức tạp O(n) — hai lần quét two-pointer. Kỹ thuật "exact = atMost(k) - atMost(k-1)" tái dùng được cho nhiều bài sliding window exact-count.
Bài tiếp theo: Mini-challenge — top-K phần tử trên stream
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên