Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa/Sliding window — thống kê trên cửa sổ trượt
25/38
Bài 25 / 38~15 phútBig data & streaming — Khi RAM không đủMiễn phí lượt xem

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àuCửa sổ trượt trên dữ liệu
Khung chỉ thấy N cảnh gần nhấtCửa sổ N phần tử liên tiếp
Cảnh cũ trôi khỏi khung khi cảnh mới vàoPhầ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ấtMonotonic 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 conKỹ thuậtTốc độ
Tìm số lớn nhất / nhỏ nhất trong khungMonotonic dequeNhanh tuyến tính
Tính tổng / trung bình trong khungPrefix sum (tổng tích luỹ) hoặc sliding sumNhanh tuyến tính
Đoạn liên tiếp dài nhất thoả điều kiện tổngTwo-pointerNhanh 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 khungDeque + bảng traTuyế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

Thử đoán

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?

rA[r]Bước 1 (pop front)Bước 2 (pop back)DQ (chỉ số)Max
03[0]
111 < 3, không pop[0, 1]
22pop 1 (A[1]=1 ≤ 2)[0, 2]A[0]=3
35pop 0 (0 < 3-3+1=1)pop 2 (2≤5), pop 0 (3≤5)[3]A[3]=5
444 < 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].

Năm bước của deque trên mảng 3 1 2 5 4; bước r bằng 3 có ba lần pop trong một bước

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ầnra đú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":

rclastSeenlCửa sổlen
0a{a:0}0"a"1
1b{a:0,b:1}0"ab"2
2c{a:0,b:1,c:2}0"abc"3
3aa lặp, lastSeen[a]=0 ≥ l=0 → l=11"bca"3
4bb lặp, lastSeen[b]=1 ≥ l=1 → l=22"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ánKỹ thuậtĐiều kiện áp dụng
Max / Min, cửa sổ cố định kMonotonic dequeCửa sổ kích thước cố định
Đoạn liên tiếp dài nhất thoả điều kiệnTwo-pointerPhầ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 + slidingCửa sổ cố định hoặc biến đổi
Tổng, sum, averagePrefix sumChỉ 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

📚 Deep Dive — Nguồn gốc & tham khảo

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_req dù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ẹp l — 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

Tự kiểm tra
Q1
Deque 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ổ.

Q2
Vì 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.

Q3
Two-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.

Q4
Trong 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.

Q5
Cho 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].

Q6
Cho 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.

Q7
Bà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

Đặt 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

Bài tiếp theo

Mini-challenge — top-K phần tử trên stream