Sliding Window Foundations

8 concept walkthroughs, each with a worked explanation and an interactive visualization, before you start solving problems in this area.

A window is a slice of a row

A list is a row of boxes, and each box holds one value. Picture a shelf with five parcels that weigh 4, 2, 7, 1 and 6. Each box has an index, which is its position number. Counting starts at 0, because the index says how many boxes you step past from the start, and the first box needs 0 steps. So nums[1] is 2, and len(nums), the number of boxes, is 5. A window is a stretch of boxes that sit side by side with no gap. The boxes at positions 1, 2 and 3 make the window [2, 7, 1]. We name its two ends left and right, and both ends are inside the window. Python cuts a window out with a slice, nums[left:right + 1]. The end of a slice is always left out, so we add 1 to keep the box at right. The size of the window is right - left + 1, which is 3 - 1 + 1 = 3. A run of side-by-side values is called a subarray when it sits in a list, and a substring when it sits in a text.

The intuition

Why use windows at all? Many questions ask about a stretch of things that sit together: the heaviest three parcels in a row, or the longest piece of a word with no repeated letter. A window describes any such stretch with just two numbers, left and right. It also tells you how many stretches there are. With n = 5 boxes and width k = 3, a window can start at position 0, 1 or 2, so there are 5 - 3 + 1 = 3 windows. In general there are n - k + 1. Whenever a question asks about side-by-side items and wants the longest, the shortest, the biggest total or a count, think window.

A real-world analogy

Lay a cardboard frame with a rectangular opening over a shelf of parcels. Whatever shows through the opening is the window. The frame's left edge is left, its right edge is right, and the parcels you can see are the window's values. You can only ever see parcels that sit side by side, never two parcels with one hidden between them. Sliding the frame one place along the shelf shows a new window. The shelf is the list, the opening is the window, and the hidden parcels are everything outside it.

nums = [4, 2, 7, 1, 6]          # five parcels, positions 0 to 4
left, right = 1, 3              # two names in one line: the window is positions 1 to 3
window = nums[left:right + 1]   # the end of a slice is left out, so add 1
print(window)                   # [2, 7, 1]
print(len(window))              # 3, the same as right - left + 1
print(sum(window))              # 10, sum adds every value: 2 + 7 + 1
print(len(nums) - 3 + 1)        # 3, how many windows of width 3 fit

The slow way: add every window again

Here is a question. A shelf holds six parcels that weigh 2, 1, 5, 1, 3 and 2. Which three side-by-side parcels weigh the most in total? The direct way is to take each window of width 3, add its weights from scratch, and keep the biggest total seen so far. The four windows are [2, 1, 5] = 8, [1, 5, 1] = 7, [5, 1, 3] = 9 and [1, 3, 2] = 6. The answer is 9. Now count the work as additions: 4 windows x 3 additions each = 12 additions. Programmers describe how the work grows with Big-O. It says how many steps the job takes as the list gets longer. Here the steps grow like n x k, so we write O(n x k) and read it as order n times k. Try 100,000 parcels with width 50,000. There are 50,001 windows, and each is added from scratch: 50,001 x 50,000 is about 2.5 billion additions. That is far too many to finish quickly.

The intuition

Look at how often each parcel gets added: positions 0 to 5 are added 1, 2, 3, 3, 2 and 1 times, 12 in all. The 5 at position 2 is added three times, and every time it is the same 5. We keep working out things we already knew. Neighbouring windows share most of their parcels: [2, 1, 5] and [1, 5, 1] share the 1 and the 5. Adding the shared parcels again is pure waste. Spotting the waste is the first step to removing it. The extra memory used here is only a few boxes, total, best and left, however long the shelf is. We say the extra space is O(1), which means it does not grow with the list.

A real-world analogy

A shop assistant checks the combined price of three neighbouring items on a shelf. For every new position they clear the calculator and key in all three prices again, even though two of the prices were typed a moment ago. The shelf is the list, the three prices are the window, clearing the calculator is starting total at 0, and typing the same prices again is the repeated work we want to avoid.

def best_sum_slow(nums, k):          # def starts a function, return hands back its answer
    best = 0                         # biggest total so far (fine while every value is positive)
    for left in range(len(nums) - k + 1):   # range(4) gives 0, 1, 2, 3: every start
        total = 0
        for i in range(left, left + k):     # positions left up to left + k - 1
            total = total + nums[i]         # add this window from scratch
        best = max(best, total)             # keep the larger of the two
    return best

print(best_sum_slow([2, 1, 5, 1, 3, 2], 3))   # 9

Fixed window: add the newcomer, drop the leaver

Slide the window one place to the right and look at what changed. Going from [2, 1, 5] to [1, 5, 1] on the shelf 2, 1, 5, 1, 3, 2, the parcel 2 at position 0 has left, the parcel 1 at position 3 has arrived, and the 1 and the 5 in the middle have not moved. So there is no need to add the middle again: new total = old total + newcomer - leaver. That is 8 + 1 - 2 = 7. We add the first window from scratch once, which is 2 + 1 + 5 = 8. After that, each slide is one addition and one subtraction, however wide the window is. The width stays the same, so this is called a fixed-size window. The two ends, left and right, are called pointers: numbers that point at a position. In the code, right is the newcomer's position, and right - k is the leaver's position, the box that just dropped off the left edge.

The intuition

Why can we trust the shortcut? The middle parcels belong to both windows, so their combined weight is one fixed number. It is 8 - 2 = 6 whatever the middle is made of: 1 and 5, or 3 and 3. The new total is that same 6 plus the newcomer 1, which is 7. The cost is also easy to count. Every parcel is added once when it arrives and subtracted once when it leaves. That is about 2 steps per parcel, so the work is O(n) and the width k no longer matters. For 100,000 parcels and width 50,000: 50,000 additions for the first window plus 50,000 slides x 2 = 150,000 steps, instead of 2.5 billion.

A real-world analogy

A small hall holds exactly three visitors at a time. When a new visitor walks in, the oldest visitor must walk out. The guard keeps a running tally of the ticket numbers inside: add the ticket of the person entering, subtract the ticket of the person leaving. The guard never recounts the people who stay. The hall is the window, the entering visitor is the newcomer, the leaving visitor is the leaver, and the tally is the running total.

def best_sum_fast(nums, k):
    total = sum(nums[0:k])         # the first window, added once
    best = total
    for right in range(k, len(nums)):          # right = the newcomer's position
        total = total + nums[right] - nums[right - k]   # newcomer in, leaver out
        best = max(best, total)
    return best

print(best_sum_fast([2, 1, 5, 1, 3, 2], 3))    # 9

Growing and shrinking: a window with a rule

Sometimes the width is not given, because the width is the answer. In the word "pqrqsp", find the longest stretch of side-by-side letters with no letter twice. Keep a window and one rule: no letter appears twice inside. Grow by moving right one place and taking in the new letter. If that letter is already inside, the rule is broken, so shrink: move left forward, letting the oldest letters go, until the repeat is gone. To remember which letters are inside, use a set. A set holds each value at most once and answers "is it in here?" in about one step on average. inside = set() makes an empty one, inside.add("p") puts p in, "p" in inside gives True or False, and inside.remove("p") takes it out. To repeat "until", we need a while loop: the lines under while run again and again for as long as its condition is True, and the condition is checked before every round. Once the shrinking stops, the window obeys the rule again. That is the moment to record its size.

The intuition

Why shrink from the left, and why never step back? Say the new letter repeats an older copy. Every window that starts at or before that older copy and ends here holds both copies, so none of them can ever be valid, whatever letters come next. The left edge must move past the older copy, and it never needs to return. That is also why the speed is good. right makes n moves, and left makes at most n moves in all, so the total is at most about 2n steps: O(n). The while loop sits inside the for loop, but we do not multiply. We count how many moves the two edges make in total, and each edge only moves forward.

A real-world analogy

People stand in a line for a group photo with one rule: no two people may wear the same colour of scarf. A new person arrives at the right end of the line. If someone already wears that colour, people leave from the left end one at a time until the clash is gone, and then the newcomer joins. The line is the window, the scarf colour is the letter, the people leaving from the left are left moving forward, and the number of people in the line is the size.

def longest_no_repeat(s):
    inside = set()                    # the letters now inside the window
    left = 0
    best = 0
    for right in range(len(s)):       # grow: look at the next letter
        while s[right] in inside:     # shrink while it is already inside
            inside.remove(s[left])    # the oldest letter leaves
            left = left + 1
        inside.add(s[right])          # now it is safe to add
        best = max(best, right - left + 1)   # the rule holds, so record the size
    return best

print(longest_no_repeat("pqrqsp"))   # 4

Counting what is inside: dict, need and have

A set says yes or no. Many windows need to know how many. For that we use a dict, short for dictionary: a table that pairs a key with a value, such as the letter n with the count 2. {} makes an empty dict, counts["n"] = 2 stores a pair, and counts["n"] reads it back. Reading a key that is missing is an error, so counts.get("n", 0) is safer: it gives 0 when the key is missing. "n" in counts asks whether the key is there, and del counts["n"] removes the pair. A dict finds a key in about one step on average, however big it is. Two dicts are equal when they hold the same pairs, in any order. Here is the question: does "banana" hold three side-by-side letters that are a rearrangement of "nan", meaning the same letters in any order? need = {"n": 2, "a": 1} is what we look for, and have is what the window holds now. We slide a window of width 3. Each slide adds the newcomer to have, subtracts the leaver, and asks: is have == need?

The intuition

Two piles of letters are rearrangements of each other exactly when every letter has the same count in both. Order does not matter, only counts. So we never compare letters in order. We compare two small tables. There is one trap. When a leaver's count falls to 0, delete the pair. Otherwise {"a": 2, "b": 0} would look different from {"a": 2}, though they describe the same letters. Each slide changes just two pairs. Comparing two dicts looks at the few letters they hold, at most 26 for the letters a to z, which is a fixed number. So the total work stays O(n).

A real-world analogy

A word game gives you a tray that holds exactly three letter tiles, and a card that says which tiles are wanted: two N and one A. After every move of the tray, a helper updates a tally sheet of what is on the tray now: add one for the tile that arrived, cross out one for the tile that left. The card is need, the tally sheet is have, the tray is the window, and matching the sheet to the card is have == need.

text = "banana"
need = {"n": 2, "a": 1}                   # the letters of "nan"
have = {}                                  # what the window holds now
k = 3                                      # the window width
for right in range(len(text)):
    have[text[right]] = have.get(text[right], 0) + 1   # the newcomer comes in
    if right >= k:                         # the window is too wide: one must leave
        leaver = text[right - k]
        have[leaver] = have[leaver] - 1    # the oldest letter goes out
        if have[leaver] == 0:
            del have[leaver]               # remove the pair at zero
    if have == need:
        print("match, window ends at", right)
        break                              # break leaves the loop

At least: shrink while the window still holds everything

Now the rule is "the window holds at least what we need", and we want the shortest window that does. Take the shop signs "BAXBAB" and the need of one A and two B: need = {"A": 1, "B": 2}. A window may hold more than that, so we cannot use ==. Instead we keep missing, the number of needed letters still absent. At the start it is len("ABB") = 3. When a letter arrives, we count it in have. If have[ch] <= need.get(ch, 0), the letter filled an empty place, so missing goes down by 1. A third B or an X does not lower it. When missing reaches 0, the window covers everything. Now we shrink, and we keep shrinking while it still covers. Each time we are inside the loop, the window is valid, so we record its size first. Then the leaver goes out. If its count drops below the need, missing goes back up and the loop ends.

The intuition

The longest-stretch window shrank when its rule broke and recorded after the shrinking. This one shrinks while the rule holds and records before each step. The difference is what the goal is. In the longest version, a valid window gets better by growing. In the shortest version, a valid window gets better by shrinking, so we squeeze it until it is about to stop working. The missing counter turns the question "does the window hold everything?" from a check of every letter into one number compared with 0. Both edges only move forward, so the work is again O(n).

A real-world analogy

You want a photo of a street that shows at least one A shop and two B shops. You stretch the frame to the right until every shop on your list is in view. Then you squeeze the left edge inwards for as long as everything on the list stays in view, noting the frame's length each time. When a listed shop would drop out, you stop squeezing and stretch right again. The frame is the window, the shop signs are the letters, and the list is need.

s = "BAXBAB"
need = {"A": 1, "B": 2}                  # the window must hold at least this much
have, missing, left, best = {}, 3, 0, len(s) + 1   # best starts longer than any window
for right in range(len(s)):
    have[s[right]] = have.get(s[right], 0) + 1      # the newcomer comes in
    if have[s[right]] <= need.get(s[right], 0):     # it fills an empty place
        missing = missing - 1
    while missing == 0:                             # the window covers everything
        best = min(best, right - left + 1)          # record BEFORE shrinking
        have[s[left]] = have[s[left]] - 1           # the oldest letter leaves
        if have[s[left]] < need.get(s[left], 0):    # a needed place became empty
            missing = missing + 1
        left = left + 1
print(best)                                         # 3

A rule from the counts: how many changes does this window need?

Here the rule is a small calculation on the counts. A row of lockers is labelled with letters, "AAXAABB", and you may change at most k = 1 label. After the changes, what is the longest run of one repeated letter? Look at one window. Keep its most common letter and change all the others. The number of changes is the size minus the count of the most common letter. The window "AAX" has size 3, and A appears 2 times, so 3 - 2 = 1 change. The window is valid when changes <= k. In code, max(have.values()) gives the biggest count in have, because .values() lists all the counts. The loop is the same as before: add the newcomer to have, shrink with while the window needs too many changes, then record the size.

The intuition

A rule does not have to be "no repeats" or "has everything". It can be any yes-or-no question you can answer from what is inside the window. Here the question is: size - most common count <= k? We never need to decide which letter to change to. The most common one is always the cheapest choice, so size - top is the least number of changes. The most common count is found by looking at no more than 26 counts, a fixed amount, so the whole job stays O(n).

A real-world analogy

A row of lockers has been painted in different colours, and a painter has k pots of paint, enough to repaint k lockers. For any stretch of lockers, the cheapest plan is to keep the most common colour and repaint the others. The stretch is the window, the colours are the letters, the pots are k, and the number of lockers to repaint is the size minus the count of the most common colour.

def longest_with_changes(s, k):
    have = {}
    left = 0
    best = 0
    for right in range(len(s)):
        have[s[right]] = have.get(s[right], 0) + 1    # the newcomer comes in
        while (right - left + 1) - max(have.values()) > k:   # too many changes needed
            have[s[left]] = have[s[left]] - 1         # the oldest letter leaves
            left = left + 1
        best = max(best, right - left + 1)            # valid now, so record
    return best

print(longest_with_changes("AAXAABB", 1))   # 5

Keeping the biggest ready: the deque

New question: for every window of width 3, find the biggest value. On the shelf [6, 2, 4, 1, 5, 3] the answers are 6, 4, 5 and 5. Scanning every window costs n x k, so we keep the biggest ready instead. Here is the key idea. When a bigger number arrives, any smaller number earlier in the window can never be the biggest again, because the newcomer is bigger and stays in the window longer. So we drop it. What stays is a shortlist whose values go from big to small, and the front of the shortlist is the biggest value. We remove from the back when a bigger number arrives, and from the front when the biggest has slipped out of the window. A list is slow at the front, since nums.pop(0) moves every other value one place left. A deque (say "deck") is built for both ends. We bring it in with from collections import deque. dq.append(x) and dq.pop() work at the back, dq.popleft() works at the front, dq[0] is the first and dq[-1] is the last, each in one step. An empty deque counts as False, so "dq and ..." first checks that it is not empty. We store positions, not values, so we can tell when the front has left the window.

The intuition

Why is dropping safe? Take 2 and the newcomer 4 in the window [6, 2, 4]. The 2 is older, so it leaves the window first. And it is smaller, so while it is in the window the 4 beats it. The 2 can never be the answer, whatever comes next. The shortlist is therefore kept in decreasing order, and the front is always the maximum. The work is O(n). A while loop sits inside the for loop, but each position is added to the deque once and removed at most once, so the total is at most about 2n removals and additions. The deque never holds more than k positions.

A real-world analogy

Think of a queue of people waiting for a group photo, and the photographer always wants the tallest person in the frame. When a taller person joins at the back, every shorter person in the queue is sent away, because the taller one will stay in the frame longer. People also leave from the front when their turn in the frame runs out. The person at the front is always the tallest. The queue is the deque, height is the value, and leaving the frame is the front slipping out of the window.

from collections import deque
def window_maximums(nums, k):
    dq = deque()                       # positions, values going big to small
    result = []
    for i in range(len(nums)):         # i = the newcomer's position
        if dq and dq[0] <= i - k:      # the front has slipped out of the window
            dq.popleft()
        while dq and nums[dq[-1]] <= nums[i]:   # smaller or equal at the back
            dq.pop()                   # can never be the biggest again
        dq.append(i)
        if i >= k - 1:                 # the window is full
            result.append(nums[dq[0]]) # add to the end of a list; the front is the biggest
    return result
print(window_maximums([6, 2, 4, 1, 5, 3], 3))   # [6, 4, 5, 5]

All DSA learning paths