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: Hãy tưởng tượng bạn nhìn qua khung cửa sổ một con tàu đang chạy — lúc nào bạn cũng chỉ thấy N cảnh gần nhất, cảnh cũ trôi khỏi khung khi cảnh mới lọt vào. Cửa sổ trượt (sliding window) là kỹ thuật trả lời câu hỏi "tình hình ra sao trong N phần tử gần nhất?" mà không phải nhìn lại từ đầu mỗi lần. Có ba mẹo tuỳ loại câu hỏi: muốn biết phần tử lớn nhất / nhỏ nhất trong khung thì xếp một hàng "người cao đứng trước" (monotonic deque); muốn tìm đoạn dài nhất thoả điều kiện về tổng thì dùng hai con trỏ co giãn khung (two-pointer); muốn đếm xem trong khung có bao nhiêu loại phần tử khác nhau thì đếm tần suất bằng bảng tra (hash map). Cả ba đều nhanh vì mỗi phần tử chỉ vào khung một lần rồi ra một lần.
Hệ thống monitoring của Cloudflare phải tính độ trễ lớn nhất trong 60 giây gần nhất trên mỗi node biên (edge node), cập nhật mỗi giây. Nếu cứ mỗi giây lại quét lại cả cửa sổ: 60 phép mỗi node × 10.000 node × 1 lần/giây = 600.000 phép/giây — tạm chấp nhận được. Nhưng nếu cửa sổ kéo dài 1 giờ (3.600 giây), vẫn 10.000 node, mỗi giây quét lại: 3.600 × 10.000 × 1 = 36 triệu phép/giây — không kham nổi. Mấu chốt: quét lại cả cửa sổ mỗi bước là lãng phí, vì hai cửa sổ liền nhau gần như giống hệt.
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. Trực giác — khung cửa sổ con tàu đang chạy
Trước khi vào định nghĩa, hãy hình dung bạn ngồi trên một con tàu đang chạy và nhìn ra ngoài qua một khung cửa sổ. Lúc nào bạn cũng chỉ thấy N cảnh gần nhất: khi tàu tiến lên, một cảnh mới lọt vào mép phải của khung, đồng thời cảnh cũ nhất trôi ra khỏi mép trái. Cửa sổ trượt làm đúng vậy trên dữ liệu: một "khung" gồm N phần tử liên tiếp, dịch dần sang phải khi dữ liệu mới tới.
Giả sử bạn muốn biết: "cảnh cao nhất (ngọn núi cao nhất) đang trong khung lúc này là gì?" Cách ngây thơ là mỗi lần khung dịch lại nhìn quét hết N cảnh từ đầu — chậm, vì làm lại công việc cũ. Có một mẹo hay hơn: xếp một hàng "người cao đứng trước". Mỗi khi một cảnh mới vào khung, ai trong hàng thấp hơn nó thì bị loại (vì còn có người vừa cao hơn vừa mới hơn, người thấp kia không bao giờ là cao nhất nữa). Nhờ vậy đầu hàng luôn là cảnh cao nhất đang trong khung — chỉ cần liếc đầu hàng, không quét lại gì. Hàng đặc biệt này về sau gọi là monotonic deque (hàng đợi đơn điệu hai đầu).
| Khung cửa sổ con tàu | Cửa sổ trượt trên dữ liệu |
|---|---|
| Khung chỉ thấy N cảnh gần nhất | Cửa sổ N phần tử liên tiếp |
| Cảnh cũ trôi khỏi khung khi cảnh mới vào | Phần tử rời cửa sổ khi cửa sổ dịch sang phải |
| Hàng "người cao đứng trước" → liếc đầu hàng là thấy cao nhất | Monotonic deque → đầu deque là max của cửa sổ |
2. 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 | Tốc độ |
|---|---|---|
| Tìm số lớn nhất / nhỏ nhất trong khung | Monotonic deque | Nhanh tuyến tính |
| Tính tổng / trung bình trong khung | Prefix sum (tổng tích luỹ) hoặc sliding sum | Nhanh tuyến tính |
| Đoạn liên tiếp dài nhất thoả điều kiện tổng | Two-pointer | Nhanh tuyến tính |
| Đoạn liên tiếp không lặp phần tử | Two-pointer + bảng tra (hash map) | Nhanh tuyến tính |
| Đếm số loại phần tử khác nhau trong khung | Deque + bảng tra | Tuyến tính (vài biến thể chậm hơn chút) |
Hai từ trong bảng cần làm rõ ngay: prefix sum (tổng tích luỹ) là mảng cộng dồn — prefix[i] lưu tổng mọi phần tử từ đầu tới i, nhờ vậy tổng của một đoạn bất kỳ tính ngay bằng một phép trừ thay vì cộng lại từng phần tử. "Nhanh tuyến tính" nghĩa là tổng công việc tỉ lệ thuận với số phần tử n (ký hiệu O(n)): xử lý gấp đôi dữ liệu thì tốn gấp đôi thời gian, không hơn.
Cấu trúc dữ liệu cốt lõi là deque (double-ended queue, hàng đợi hai đầu): thêm/bớt ở cả hai đầu đều tức thì — chi tiết ở bài queue và deque.
3. Kỹ thuật 1 — Monotonic deque cho sliding max
Hợp khi nào: bạn cần phần tử lớn nhất (hoặc nhỏ nhất) trong một khung kích thước cố định trượt đều — đúng kiểu "hỏi ngọn núi cao nhất đang trong khung". Đây là lúc dùng hàng "người cao đứng trước" (monotonic deque, hàng đợi đơn điệu hai đầu).
3.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: nếu một phần tử vừa nhỏ hơn vừa cũ hơn một phần tử khác trong cửa sổ, nó sẽ không bao giờ là max — vì luôn có người vừa cao hơn vừa ở lại lâu hơn. Đúng như hàng "người cao đứng trước": mỗi khi nạp phần tử mới, ta loại (pop) khỏi deque những ai thấp hơn nó, giữ deque giảm dần.
Deque lúc nào cũng thỏa A[deque[0]] ≥ A[deque[1]] ≥ ..., nên max của cửa sổ hiện tại luôn là phần tử ở đầu deque, A[deque[0]] — chỉ cần liếc đầu hàng.
3.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)
3.3 Trace — A = [3, 1, 2, 5, 4], k = 3
Trước khi đọc bảng: với A = [3, 1, 2, 5, 4], k = 3, hãy đoán deque (lưu chỉ số) sẽ chứa gì ngay sau khi nạp A[3]=5. Phần tử nào bị pop, vì lý do nào?
| 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].

Bước r = 3 pop ba lần, nhìn như phản ví dụ cho O(1) mỗi bước. Thật ra nó là bằng chứng: cả ba chỉ số đó đều đã được nạp ở bước trước, và mỗi chỉ số chỉ trả giá ra đúng một lần trong cả hành trình.
4. Vì sao monotonic deque chạy nhanh?
Mấu chốt nằm ở chỗ: mỗi chỉ số r chỉ vào deque đúng một lần và ra đúng một lần (hoặc bị loại ở bước 1 vì đã trôi khỏi cửa sổ, hoặc ở bước 2 vì có phần tử cao hơn đẩy ra). Một vòng lặp đơn lẻ đôi khi loại nhiều phần tử cùng lúc, nhìn có vẻ chậm — nhưng những phần tử đó từng được nạp vào trước đó, mỗi phần tử chỉ "trả giá ra" một lần trong cả hành trình. Vì vậy tổng công việc cho cả mảng tỉ lệ với n, không phải n nhân kích thước cửa sổ.
Cách tính "trung bình mỗi thao tác tốn bao nhiêu khi xét cả chuỗi thao tác" này gọi là amortized (phân tích khấu hao): tổng O(n) chia đều cho n lần cập nhật ra O(1) mỗi lần — xem amortized analysis.
5. Kỹ thuật 2 — Two-pointer cho subarray thoả điều kiện
Hợp khi nào: bạn cần tìm một đoạn liên tiếp dài nhất (hoặc ngắn nhất) thoả một điều kiện về tổng — ví dụ "đoạn dài nhất có tổng không vượt một ngưỡng". Hình dung hai con trỏ như hai mép khung co giãn được: kéo mép phải ra để gom thêm, kéo mép trái vào khi đã quá đà.
Hình dung một cái kẹp co giãn: bạn nới mép phải ra để gom thêm phần tử, rồi siết mép trái vào ngay khi đoạn vừa gom vượt điều kiện. Hai con trỏ l (mép trái) và r (mép phải) đều chỉ chạy về phía trước, không bao giờ lùi. Kéo r ra để mở rộng cửa sổ; co l vào khi cửa sổ vi phạm điều kiện.
Two-pointer hợp nhất với mảng mà mọi phần tử không âm: lúc đó nới mép phải luôn làm tổng tăng, siết mép trái luôn làm tổng giảm — tính chất "đi một chiều" này (gọi là monotone, tức biến đổi đơn điệu theo một hướng) chính là thứ cho phép hai con trỏ không cần quay lui.
5.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)
Nhanh vì cả l lẫn r chỉ đi tới (không bao giờ lùi), mỗi con trỏ tiến tối đa n bước, nên tổng công việc tỉ lệ thuận với số phần tử.
5.2 Bài toán: đoạn dài nhất không lặp ký tự
Bài này thêm một bảng tra (hash map) — bảng cho phép tra "ký tự này xuất hiện gần nhất ở vị trí nào" tức thì, để biết khi nào một ký tự bị lặp lại bên trong cửa sổ:
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").
6. Kỹ thuật 3 — Cửa sổ cố định + HashMap cho distinct
Hợp khi nào: bạn cần đếm có bao nhiêu loại phần tử khác nhau trong khung (distinct), hoặc kiểm tra ràng buộc về tần suất. Mẹo: giữ một bảng tra (hash map) "loại phần tử → đếm bao nhiêu cái trong khung", cộng thêm khi phần tử lọt vào mép phải, trừ bớt khi phần tử rời mép trái — số loại có đếm lớn hơn 0 chính là số phần tử khác nhau.
Hình dung một quầy điểm danh ở cửa khung: mỗi phần tử lọt vào mép phải thì cộng một vạch vào tên loại của nó; mỗi phần tử rời mép trái thì xoá một vạch. Số loại còn ít nhất một vạch chính là số phần tử khác nhau — không cần đếm lại cả khung mỗi bước. Dưới đây là cách đếm số phần tử khác nhau (distinct, tức số loại khác nhau) 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)
7. 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 |
| Đoạn liên tiếp dài nhất thoả điều kiện | Two-pointer | Phần tử không âm; hoặc điều kiện biến đổi một chiều (monotone) theo 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]".
8. 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 — Dùng two-pointer khi điều kiện không biến đổi một chiều (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 biến đổi một chiều — cửa sổ lớn hơn thì luôn càng vi phạm hoặc luôn càng hợp lệ, chứ không lúc thế này lúc thế kia).
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.
9. 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. Ghép cửa sổ trượt với Count-Min Sketch (cấu trúc ước lượng tần suất tốn ít bộ nhớ) cho ta mẫu "cửa sổ thời gian + đếm tần suất gần đúng": dùng hai sketch luân phiên, mỗi nửa cửa sổ đổi vai, để giới hạn số request theo thời gian. Cần hiểu cả hai kỹ thuật mới dựng được mẫu này.
- HyperLogLog: tương tự, ghép HyperLogLog (cấu trúc ước lượng số phần tử khác nhau cực tiết kiệm bộ nhớ) với cửa sổ trượt cho câu hỏi "có bao nhiêu người dùng khác nhau trong 5 phút gần nhất" — rất hay gặp trên bảng phân tích (analytics dashboard).
- Mini-challenge top-K: bài thực hành — ghép monotonic deque (hoặc heap) với cửa sổ trượt để tìm K phần tử nổi bật nhất trên một luồng dữ liệu (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ẽ.
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 giới hạn tốc độ request: module
limit_reqdùng leaky bucket (mô hình "xô rò" — request đổ vào một xô có lỗ rò đều, một biến thể của cửa sổ trượt) để đếm request trong cửa sổ trượt 1 giây. - Theo dõi độ trễ tiêu thụ Kafka: tính lượng xử lý trung bình 5 phút bằng tổng trượt (sliding sum) trên cửa sổ.
- Phân tích giá cổ phiếu: các chỉ báo kỹ thuật như RSI, MACD, Bollinger Bands (đều là các đường trung bình/biên độ tính trên một số phiên gần nhất) thực chất là cửa sổ trượt trên giá đóng cửa.
Cấu trúc nâng cao:
- Segment tree with lazy propagation (cây phân đoạn có lan truyền trì hoãn — cây chia mảng thành các đoạn để truy vấn nhanh, hoãn cập nhật cho tới khi cần): truy vấn max/min trên một đoạn bất kỳ (không chỉ cửa sổ trượt) rất nhanh.
- Sparse table (bảng thưa — tính trước kết quả cho các đoạn có độ dài luỹ thừa của 2): tính trước max/min theo đoạn rồi mỗi truy vấn trả về tức thì — tốt hơn deque khi cửa sổ thay đổi tuỳ ý và dữ liệu không cập nhật liên tục theo thời gian thực.
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 có freq lớn hơn 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 biến đổi một chiều (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 + bảng tra (hash map). Prefix sum là mảng cộng dồn: prefix[i] = A[0]+...+A[i-1]. Đoạn A[l..r] có tổng = prefix[r+1] - prefix[l]. Tìm i, j sao cho prefix[j] - prefix[i] = target chính là tra xem prefix[j] - target đã từng xuất hiện trong bảng chưa — mỗi giá trị prefix lưu vào bảng một lần, nên tổng công việc vẫn tuyến tính.
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].
Q6Cho ba bài toán cửa sổ: (a) max của mỗi cửa sổ cố định size k; (b) subarray ngắn nhất có tổng >= S trên mảng toàn số dương; (c) số phần tử distinct trong mỗi cửa sổ cố định size k. Với mỗi bài, chọn kỹ thuật phù hợp và giải thích vì sao hai kỹ thuật còn lại không hợp.▸
(a) Monotonic deque. Cần cực trị trên cửa sổ size cố định trượt đều — deque giảm dần cho max O(1) amortized. Two-pointer không hợp vì không có điều kiện monotone để thu hẹp; hashmap chỉ đếm tần suất, không theo dõi cực trị.
(b) Two-pointer. Mảng toàn dương nên tổng tăng đơn điệu khi mở rộng phải và giảm khi thu trái — điều kiện monotone cho phép hai con trỏ chạy O(n). Deque cho cực trị chứ không cho tổng; hashmap không liên quan tổng. (Nếu mảng có số âm, two-pointer sai — xem câu hỏi về điều kiện không âm.)
(c) HashMap. Đếm distinct cần tần suất từng phần tử trong cửa sổ — hashmap tăng/giảm freq ở hai mép. Deque chỉ giữ cực trị; two-pointer không đếm được distinct trên cửa sổ cố định.
Q7Bà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ó. Mẹo: đếm số đoạn có đúng m loại khác nhau khó hơn đếm số đoạn có nhiều nhất m loại. Mà "đúng m" = "nhiều nhất m" trừ đi "nhiều nhất m-1": nói nôm na, "có đúng 3 loại" = "có không quá 3 loại" bỏ bớt "có không quá 2 loại". Cùng một bộ đếm chạy hai lần với hai ngưỡng rồi lấy hiệu.
Hàm atMostK(A, k_size, K_distinct) đếm số đoạn có không quá K_distinct loại: dùng two-pointer + bảng tra đếm số loại trong cửa sổ co giãn — khi số loại vượt K_distinct thì thu hẹp l. Số đoạn hợp lệ kết thúc tại r là r - l + 1 (mọi điểm bắt đầu từ l tới r đều cho một đoạn hợp lệ). Khi đó số đoạn có đúng m loại = atMostK(m) - atMostK(m-1). Mỗi lần gọi quét tuyến tính, gọi hai lần vẫn tuyến tính. Mẹo "đúng = nhiều-nhất(m) trừ nhiều-nhất(m-1)" tái dùng được cho nhiều bài đếm-chính-xác trên cửa sổ trượt.
Bài tiếp theo: Mini-challenge — top-K phần tử trên stream
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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