18 min read
Data Structures & Algorithms3,868 words

Two Pointers, Sliding Window and Prefix Sums (Java + Kotlin)

The linear-scan pattern family: how to coordinate two indices over a sequence so you never re-examine a decision already made, collapsing a nested-loop O(n²) into a single O(n) pass.

Series map: 00 playbook · 01 two pointers & sliding window (this file) · 02 binary search, stacks & intervals · 03 trees & graphs · 04 recursion & backtracking · 05 dynamic programming · 06 staff+ practical & OOD. Companion (non-series): data-structures-java-kotlin-study-guide.md — reach there for why a HashMap / ArrayDeque / PriorityQueue behaves as it does (internals, boxing, cache locality). This guide uses those structures as tools and spends its words on the algorithm.


The mental model#

Every technique here is one idea wearing seven costumes:

Maintain a contiguous region [left, right] of the sequence, advance right to admit new elements, advance left to retire old ones, and keep just enough state about what's between the pointers that each element is touched O(1) times.

Brute force asks a fresh question at every pair (i, j) — O(n²). Two pointers exploits monotonicity: some property changes predictably as the window moves, so a decision made at (i, j) tells you the answer for a whole range of neighbors, and you never back up. The pointers only ever move forward (or converge), so total work is O(n) even though there are two loops-worth of index motion — the inner while is amortized across the outer for.

The whole family is one axis of variation — what the region between the pointers means:

CostumePointers moveRegion meansEnabling property
Opposite-ends two pointersinward, convergea candidate pair/partitioninput is sorted or symmetric
Same-direction (read/write)both forward, write ≤ readcompacted prefix vs. scanned suffixin-place rewrite, order-preserving
Fast & slowboth forward, fast = 2·slowcycle / midpoint geometryreachability forms a ρ-shape
Fixed windowboth forward, right − left = k−1exactly-k-wide sliceadditive/subtractive aggregate
Variable windowboth forward, left ≤ rightthe maximal/minimal valid slicepredicate monotonic in width
Prefix sumsno window — precompute P[i]sum(l,r) = P[r+1] − P[l]aggregate is invertible (+, xor)
Cyclic sorti vs. nums[i]value belongs at a known indexvalues are a permutation of 1..n

If you internalize "which of these does the region mean here, and what makes the move safe?" you will recognize the pattern from the problem statement before you write a line. That recognition is 80% of the interview.

A one-time Kotlin helper used throughout (Java inlines a three-line temp swap each time):

kotlin
private fun IntArray.swap(i: Int, j: Int) { val t = this[i]; this[i] = this[j]; this[j] = t }

1. Two pointers — converging from opposite ends#

Recognition cue. Sorted array/string, or a symmetric task: find a pair/triplet summing to a target, reverse in place, is this a palindrome, container/area between two walls, partition into ≤/=/> buckets. If the input is sorted (or you're allowed to sort and indices don't matter), reach here first.

Why sortedness enables the move. With lo at the smallest candidate and hi at the largest, nums[lo] + nums[hi] is monotonic in each pointer: too small ⇒ the only way to grow the sum is lo++ (every element left of hi paired with the current lo is even smaller, so they're all eliminated at once); too big ⇒ hi--. Each step discards a whole row/column of the n² pair matrix. That is the entire trick.

Template (two-sum on a sorted array).

kotlin
fun twoSumSorted(nums: IntArray, target: Int): IntArray { var lo = 0 var hi = nums.size - 1 while (lo < hi) { val sum = nums[lo] + nums[hi] when { sum == target -> return intArrayOf(lo, hi) // LeetCode 167 wants 1-based: lo+1, hi+1 sum < target -> lo++ else -> hi-- } } return intArrayOf(-1, -1) }
java
int[] twoSumSorted(int[] nums, int target) { int lo = 0, hi = nums.length - 1; while (lo < hi) { int sum = nums[lo] + nums[hi]; if (sum == target) return new int[]{lo, hi}; // LeetCode 167: {lo + 1, hi + 1} else if (sum < target) lo++; else hi--; } return new int[]{-1, -1}; }

Reversing a string/array is the degenerate case: swap(lo, hi); lo++; hi--. Complexity: O(n) time, O(1) space.

Canonical problem: 3Sum (LeetCode 15) — sort, fix one, two-point the rest#

The dedup discipline is what interviewers watch. Sort, fix i, then run the opposite-ends scan on the suffix. Skip duplicates at every level.

kotlin
fun threeSum(nums: IntArray): List<List<Int>> { nums.sort() val res = mutableListOf<List<Int>>() for (i in 0 until nums.size - 2) { if (nums[i] > 0) break // smallest is positive -> no triple sums to 0 if (i > 0 && nums[i] == nums[i - 1]) continue // skip duplicate anchor var lo = i + 1 var hi = nums.size - 1 while (lo < hi) { val sum = nums[i] + nums[lo] + nums[hi] when { sum < 0 -> lo++ sum > 0 -> hi-- else -> { res.add(listOf(nums[i], nums[lo], nums[hi])) lo++; hi-- while (lo < hi && nums[lo] == nums[lo - 1]) lo++ // skip duplicate lo while (lo < hi && nums[hi] == nums[hi + 1]) hi-- // skip duplicate hi } } } } return res }
java
List<List<Integer>> threeSum(int[] nums) { Arrays.sort(nums); List<List<Integer>> res = new ArrayList<>(); for (int i = 0; i < nums.length - 2; i++) { if (nums[i] > 0) break; if (i > 0 && nums[i] == nums[i - 1]) continue; int lo = i + 1, hi = nums.length - 1; while (lo < hi) { int sum = nums[i] + nums[lo] + nums[hi]; if (sum < 0) lo++; else if (sum > 0) hi--; else { res.add(List.of(nums[i], nums[lo], nums[hi])); lo++; hi--; while (lo < hi && nums[lo] == nums[lo - 1]) lo++; while (lo < hi && nums[hi] == nums[hi + 1]) hi--; } } } return res; }

Complexity: O(n²) time (outer fix × inner linear scan), O(1) extra beyond the output and the sort's O(log n) stack. The dedup trap: skipping duplicates only on the anchor i but not on lo/hi — or vice versa — produces duplicate triples like [-1,0,1] twice. You need all three skips. Do not dedup with a HashSet<List<Integer>>: it works but signals you didn't see the structure, and it's O(n) memory you didn't need. 4Sum generalizes: fix two, two-point the rest, O(n³).

Container With Most Water (LeetCode 11) & Trapping Rain Water (LeetCode 42)#

Both are greedy converging scans; the insight is which pointer to move.

kotlin
fun maxArea(height: IntArray): Int { // Container With Most Water var lo = 0; var hi = height.size - 1; var best = 0 while (lo < hi) { val h = minOf(height[lo], height[hi]) best = maxOf(best, h * (hi - lo)) if (height[lo] < height[hi]) lo++ else hi-- // move the SHORTER wall } return best } fun trap(height: IntArray): Int { // Trapping Rain Water, O(1) space var lo = 0; var hi = height.size - 1 var leftMax = 0; var rightMax = 0; var water = 0 while (lo < hi) { if (height[lo] < height[hi]) { // right side guarantees a taller wall leftMax = maxOf(leftMax, height[lo]) water += leftMax - height[lo]; lo++ } else { rightMax = maxOf(rightMax, height[hi]) water += rightMax - height[hi]; hi-- } } return water }

Move-the-shorter-wall is provably optimal: area is bounded by the shorter wall, so moving the taller one can only shrink width without lifting the ceiling. For trap, when height[lo] < height[hi] we know some wall on the right is ≥ height[hi] > height[lo], so leftMax alone bounds the water above lo — no need to precompute both prefix maxima. Both O(n) time, O(1) space. (The prefix-max array version of trap is O(n) space — the two-pointer version is the senior answer.)

Valid Palindrome (LeetCode 125) & Dutch National Flag (LeetCode 75)#

kotlin
fun isPalindrome(s: String): Boolean { var lo = 0; var hi = s.length - 1 while (lo < hi) { while (lo < hi && !s[lo].isLetterOrDigit()) lo++ while (lo < hi && !s[hi].isLetterOrDigit()) hi-- if (s[lo].lowercaseChar() != s[hi].lowercaseChar()) return false lo++; hi-- } return true }

lowercaseChar() is the Kotlin 1.5+ replacement for the deprecated toLowerCase() on Char; Java uses Character.toLowerCase(char). Sort Colors is Dijkstra's three-way partition — one pass, three pointers, O(1) space:

kotlin
fun sortColors(nums: IntArray) { var low = 0; var mid = 0; var high = nums.size - 1 // [0,low) = 0s, [low,mid) = 1s, (high,end] = 2s while (mid <= high) { when (nums[mid]) { 0 -> { nums.swap(low, mid); low++; mid++ } // swapped-in value is a known 1 -> advance mid 1 -> mid++ else -> nums.swap(mid, high).also { high-- } // swapped-in value is unexamined -> DON'T advance mid } } }
java
void sortColors(int[] nums) { int low = 0, mid = 0, high = nums.length - 1; while (mid <= high) { switch (nums[mid]) { case 0 -> { int t = nums[low]; nums[low] = nums[mid]; nums[mid] = t; low++; mid++; } case 1 -> mid++; default -> { int t = nums[mid]; nums[mid] = nums[high]; nums[high] = t; high--; } } } }

O(n) time, O(1) space. The trap: incrementing mid after a swap with high. You must re-examine the value that came from the unsorted right side, so leave mid put.


2. Two pointers — same direction (read / write)#

Recognition cue. Modify an array in place, remove/keep elements, compact, stable partition, "return the new length", or merge two sorted arrays without extra space. One pointer reads every element; the other writes only the survivors.

Template (slow-write / fast-read).

kotlin
var write = 0 for (read in nums.indices) { if (keep(nums[read])) { // predicate decides what survives nums[write] = nums[read] // (use swap instead if you must preserve the evicted values, e.g. Move Zeroes) write++ } } // nums[0 until write] is the compacted result; `write` is the new length
java
int write = 0; for (int read = 0; read < nums.length; read++) { if (keep(nums[read])) { nums[write] = nums[read]; write++; } }

Remove Duplicates from Sorted Array (LC 26) & Merge Sorted Array (LC 88)#

kotlin
fun removeDuplicates(nums: IntArray): Int { // sorted input if (nums.isEmpty()) return 0 var write = 1 for (read in 1 until nums.size) { if (nums[read] != nums[write - 1]) nums[write++] = nums[read] } return write } fun moveZeroes(nums: IntArray) { // stable: non-zeros keep order, zeros pushed right var write = 0 for (read in nums.indices) if (nums[read] != 0) nums.swap(write++, read) } fun removeElement(nums: IntArray, target: Int): Int { var write = 0 for (read in nums.indices) if (nums[read] != target) nums[write++] = nums[read] return write }

Merge Sorted Array is the signature fill-from-the-back variant — writing forward would clobber unread nums1 values, so walk the write pointer down from the end:

kotlin
fun merge(nums1: IntArray, m: Int, nums2: IntArray, n: Int) { var i = m - 1; var j = n - 1; var k = m + n - 1 // last real of nums1, last of nums2, write slot while (j >= 0) { if (i >= 0 && nums1[i] > nums2[j]) nums1[k--] = nums1[i--] else nums1[k--] = nums2[j--] } }
java
void merge(int[] nums1, int m, int[] nums2, int n) { int i = m - 1, j = n - 1, k = m + n - 1; while (j >= 0) { if (i >= 0 && nums1[i] > nums2[j]) nums1[k--] = nums1[i--]; else nums1[k--] = nums2[j--]; } }

Loop on j >= 0 only: if nums2 empties first, the rest of nums1 is already in place. All O(n) time, O(1) space. The trap: forgetting the i >= 0 guard, which underflows when nums2 holds the smallest elements.


3. Fast & slow pointers (Floyd's cycle detection)#

Recognition cue. Linked list or an implicit i → f(i) sequence: detect a cycle, find where it starts, find the middle, find a duplicate in O(1) space, is it a palindrome list. The tell is "O(1) extra space" on a structure you can only walk forward.

Node definitions (LeetCode's stubs name the field val; I use value in Kotlin to dodge the keyword clash, val in Java where it's legal):

kotlin
class ListNode(var value: Int) { var next: ListNode? = null }
java
class ListNode { int val; ListNode next; ListNode(int val) { this.val = val; } }

Template: detect a cycle (LeetCode 141). fast laps slow inside any cycle, so they collide.

kotlin
fun hasCycle(head: ListNode?): Boolean { var slow = head; var fast = head while (fast?.next != null) { slow = slow!!.next fast = fast.next!!.next if (slow === fast) return true // reference equality } return false }
java
boolean hasCycle(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) return true; } return false; }

Find the cycle start (LeetCode 142) — and the math. Let a = distance head→entry, b = entry→meeting, L = cycle length. When they meet, slow walked a + b, fast walked a + b + kL (k full laps), and fast walked twice as far: 2(a+b) = a + b + kLa + b = kLa = kL − b = (k−1)L + (L − b). L − b is the distance from meeting point onward back to the entry. So a walker from head and a walker from the meeting point, each stepping once, both reach the entry after a steps. That's the whole reason the second loop works:

kotlin
fun detectCycle(head: ListNode?): ListNode? { var slow = head; var fast = head while (fast?.next != null) { slow = slow!!.next fast = fast.next!!.next if (slow === fast) { var p = head while (p !== slow) { p = p!!.next; slow = slow!!.next } return p } } return null }
java
ListNode detectCycle(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { ListNode p = head; while (p != slow) { p = p.next; slow = slow.next; } return p; } } return null; }

Find the Duplicate Number (LeetCode 287) — Floyd on value → index. With n+1 values in 1..n, the map i → nums[i] is a linked list with exactly one cycle whose entry is the duplicate (two indices point into it). This is the answer when the array must stay read-only and space must be O(1):

kotlin
fun findDuplicate(nums: IntArray): Int { var slow = nums[0]; var fast = nums[0] do { slow = nums[slow]; fast = nums[nums[fast]] } while (slow != fast) slow = nums[0] while (slow != fast) { slow = nums[slow]; fast = nums[fast] } return slow }
java
int findDuplicate(int[] nums) { int slow = nums[0], fast = nums[0]; do { slow = nums[slow]; fast = nums[nums[fast]]; } while (slow != fast); slow = nums[0]; while (slow != fast) { slow = nums[slow]; fast = nums[fast]; } return slow; }

The other three, in Kotlin (Java is a mechanical translation of the same pointer moves):

kotlin
fun middleNode(head: ListNode?): ListNode? { // LC 876: returns the SECOND middle when even var slow = head; var fast = head while (fast?.next != null) { slow = slow!!.next; fast = fast.next!!.next } return slow } fun isHappy(n: Int): Boolean { // LC 202: cycle-detect on digit-square-sum fun squares(x: Int): Int { var v = x; var s = 0; while (v > 0) { val d = v % 10; s += d * d; v /= 10 }; return s } var slow = n; var fast = n do { slow = squares(slow); fast = squares(squares(fast)) } while (slow != fast) return slow == 1 // 1 is a self-loop; unhappy numbers cycle elsewhere } fun isPalindromeList(head: ListNode?): Boolean { // LC 234: midpoint + reverse 2nd half, O(1) space if (head?.next == null) return true var slow = head; var fast = head while (fast?.next != null) { slow = slow!!.next; fast = fast.next!!.next } var prev: ListNode? = null; var curr = slow // reverse from midpoint while (curr != null) { val nx = curr.next; curr.next = prev; prev = curr; curr = nx } var l = head; var r = prev // compare halves while (r != null) { if (l!!.value != r.value) return false; l = l.next; r = r.next } return true }

All O(n) time, O(1) space. Traps: (1) the loop guard must be fast != null && fast.next != null or you NPE on even-length lists; (2) for cycle start, resetting slow (not fast) to head — both walk at speed 1 in phase two; (3) Happy Number relies on 1 → 1 being a fixed point so the fast pointer settles there.


4. Sliding window — fixed size k#

Recognition cue. "Subarray/substring of size exactly k", "window of k", moving average, find all anagrams / a permutation. The window width is pinned; you add one on the right and drop one on the left every step.

Template. Prime the first window, then slide: windowSum += in − out.

kotlin
fun maxSumOfSizeK(nums: IntArray, k: Int): Long { var sum = 0L for (i in 0 until k) sum += nums[i] // prime window [0, k) var best = sum for (right in k until nums.size) { sum += nums[right] - nums[right - k] // admit right, retire right - k best = maxOf(best, sum) } return best }
java
long maxSumOfSizeK(int[] nums, int k) { long sum = 0; for (int i = 0; i < k; i++) sum += nums[i]; long best = sum; for (int right = k; right < nums.length; right++) { sum += nums[right] - nums[right - k]; best = Math.max(best, sum); } return best; }

Note the Long accumulator — a size-k window of large ints overflows a 32-bit sum silently. This is the same overflow lesson as §7.

Canonical: Find All Anagrams (LeetCode 438) — a 26-bucket count window#

A fixed window plus a frequency vector: slide the window, compare counts in O(26) = O(1). Permutation in String (LC 567) is the boolean sibling (return true on the first match).

kotlin
fun findAnagrams(s: String, p: String): List<Int> { val res = mutableListOf<Int>() if (s.length < p.length) return res val need = IntArray(26); val window = IntArray(26) for (c in p) need[c - 'a']++ val k = p.length for (i in s.indices) { window[s[i] - 'a']++ if (i >= k) window[s[i - k] - 'a']-- // window now spans [i-k+1, i] if (i >= k - 1 && window.contentEquals(need)) res.add(i - k + 1) } return res }
java
List<Integer> findAnagrams(String s, String p) { List<Integer> res = new ArrayList<>(); if (s.length() < p.length()) return res; int[] need = new int[26], window = new int[26]; for (char c : p.toCharArray()) need[c - 'a']++; int k = p.length(); for (int i = 0; i < s.length(); i++) { window[s.charAt(i) - 'a']++; if (i >= k) window[s.charAt(i - k) - 'a']--; if (i >= k - 1 && Arrays.equals(window, need)) res.add(i - k + 1); } return res; }

O(n · Σ) time with alphabet Σ = 26 (so O(n)), O(Σ) space. Traps: comparing counts before the window is full (i >= k - 1), and off-by-one on the start index i - k + 1.


5. Sliding window — variable size (grow / shrink) — THE template#

This is the single most important pattern in the guide; roughly a third of "medium" array/string problems reduce to it.

Recognition cue. Longest / shortest / max / min contiguous subarray or substring satisfying a constraint that is monotonic in width"at most K …", "no repeats", "sum ≥ target", "contains all of T". Monotonic means: if a window is valid, every sub-window is valid too (for "at most" style), or if invalid, every super-window is invalid. That monotonicity is what lets left chase right without ever backing up.

The invariant, spoken aloud in the interview: "I expand right to include each new element. While the window violates the constraint, I contract left. When the loop settles, [left, right] is the best window ending at right, and I record the answer." Where you record depends on the goal:

Longest-valid template (record after restoring validity):

kotlin
var left = 0 var best = 0 for (right in nums.indices) { add(nums[right]) // 1. extend the window while (invalid()) { remove(nums[left]); left++ } // 2. shrink until valid again best = maxOf(best, right - left + 1) // 3. window [left, right] is valid -> record }

Shortest-valid template (record while valid, just before shrinking):

kotlin
var left = 0 var best = Int.MAX_VALUE for (right in nums.indices) { add(nums[right]) while (valid()) { best = minOf(best, right - left + 1) // record BEFORE removing remove(nums[left]); left++ } }

The only per-problem choices are: what state describes the window, what makes it invalid, and longest vs. shortest. Here are six instantiations.

5a. Longest Substring Without Repeating Characters (LC 3)#

State: a count map. Invalid when the just-added char appears twice.

kotlin
fun lengthOfLongestSubstring(s: String): Int { val count = HashMap<Char, Int>() var left = 0; var best = 0 for (right in s.indices) { val c = s[right] count[c] = (count[c] ?: 0) + 1 while (count[c]!! > 1) { // duplicate in window val l = s[left]; count[l] = count[l]!! - 1; left++ } best = maxOf(best, right - left + 1) } return best }
java
int lengthOfLongestSubstring(String s) { Map<Character, Integer> count = new HashMap<>(); int left = 0, best = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); count.merge(c, 1, Integer::sum); while (count.get(c) > 1) { char l = s.charAt(left++); count.merge(l, -1, Integer::sum); } best = Math.max(best, right - left + 1); } return best; }

Optimization worth naming: store each char's last index and jump left = max(left, lastIndex[c] + 1) — one move instead of a shrink loop. Correct either way at O(n); the jump version is where the "the left pointer can jump" trap lives (see §Problems).

5b–5d. At-most-K distinct, Longest Repeating Replacement, Min Subarray Sum#

kotlin
fun lengthOfLongestSubstringKDistinct(s: String, k: Int): Int { // LC 340; Fruit Into Baskets (LC 904) is k = 2 if (k == 0) return 0 val count = HashMap<Char, Int>() var left = 0; var best = 0 for (right in s.indices) { count[s[right]] = (count[s[right]] ?: 0) + 1 while (count.size > k) { // too many distinct val l = s[left]; count[l] = count[l]!! - 1 if (count[l] == 0) count.remove(l) // keep map.size == #distinct left++ } best = maxOf(best, right - left + 1) } return best } fun characterReplacement(s: String, k: Int): Int { // LC 424 val count = IntArray(26) var left = 0; var maxFreq = 0; var best = 0 for (right in s.indices) { count[s[right] - 'A']++ maxFreq = maxOf(maxFreq, count[s[right] - 'A']) // (window width) - (most common char) = chars we'd have to replace; must stay <= k while (right - left + 1 - maxFreq > k) { count[s[left] - 'A']--; left++ } best = maxOf(best, right - left + 1) } return best } fun minSubArrayLen(target: Int, nums: IntArray): Int { // LC 209, shortest flavor; requires non-negative nums var left = 0; var sum = 0L; var best = Int.MAX_VALUE for (right in nums.indices) { sum += nums[right] while (sum >= target) { // valid -> record then shrink best = minOf(best, right - left + 1) sum -= nums[left]; left++ } } return if (best == Int.MAX_VALUE) 0 else best }

characterReplacement has a famous subtlety: maxFreq is never decreased when shrinking. It's a high-water mark. That's fine — best only grows when a genuinely larger valid window appears, which requires a larger maxFreq; a stale maxFreq can never inflate the answer, it only lets the window glide at its current max width. minSubArrayLen requires non-negative numbers: the window sum must be monotonic in width for the shrink to be valid. With negatives, sliding window breaks and you switch to prefix + monotonic deque (or §7). Flag that assumption out loud.

5e. Minimum Window Substring (LC 76) — the hard canonical#

State: a need count of T, a window count, and a formed counter of how many distinct chars have met their required multiplicity. Shortest flavor.

kotlin
fun minWindow(s: String, t: String): String { if (s.length < t.length) return "" val need = HashMap<Char, Int>() for (c in t) need[c] = (need[c] ?: 0) + 1 val required = need.size var formed = 0 val window = HashMap<Char, Int>() var left = 0; var bestLen = Int.MAX_VALUE; var bestStart = 0 for (right in s.indices) { val c = s[right] window[c] = (window[c] ?: 0) + 1 if (need.containsKey(c) && window[c] == need[c]) formed++ while (formed == required) { // valid window -> try to shrink if (right - left + 1 < bestLen) { bestLen = right - left + 1; bestStart = left } val l = s[left] window[l] = window[l]!! - 1 if (need.containsKey(l) && window[l]!! < need[l]!!) formed-- left++ } } return if (bestLen == Int.MAX_VALUE) "" else s.substring(bestStart, bestStart + bestLen) }
java
String minWindow(String s, String t) { if (s.length() < t.length()) return ""; Map<Character, Integer> need = new HashMap<>(); for (char c : t.toCharArray()) need.merge(c, 1, Integer::sum); int required = need.size(), formed = 0; Map<Character, Integer> window = new HashMap<>(); int left = 0, bestLen = Integer.MAX_VALUE, bestStart = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); window.merge(c, 1, Integer::sum); // NOTE: .intValue() — comparing two Integers with == tests reference identity above the 127 cache if (need.containsKey(c) && window.get(c).intValue() == need.get(c).intValue()) formed++; while (formed == required) { if (right - left + 1 < bestLen) { bestLen = right - left + 1; bestStart = left; } char l = s.charAt(left++); window.merge(l, -1, Integer::sum); if (need.containsKey(l) && window.get(l) < need.get(l)) formed--; } } return bestLen == Integer.MAX_VALUE ? "" : s.substring(bestStart, bestStart + bestLen); }

The Integer == Integer trap is real and Java-specific: window.get(c) == need.get(c) autoboxes to reference comparison and silently breaks for counts above 127 (outside the Integer cache). Use .intValue() or .equals. See the companion guide's equals/hashCode section for why. All of §5: O(n) time (each index enters and leaves the window once), O(Σ) or O(k) space for the count map.

How this maps to your world: ONS's per-tenant webhook rate limiter is exactly this template on a time axis. A sliding-window-log limiter holds an ArrayDeque<Long> of send timestamps; on each event you add the new timestamp on the right and remove from the left every timestamp older than now − windowMillis, then reject if deque.size > limit. left chasing right = evicting expired timestamps. It's O(1) amortized per request and, unlike a fixed-bucket counter, has no boundary burst. The deque-of-timestamps is the window between two pointers.


6. The at-most-K → exactly-K trick, and counting subarrays#

Recognition cue. "Count subarrays with exactly K distinct / exactly S ones / exactly k odd numbers." Exactly-K resists a clean single window — and knowing why is the senior signal.

Why "exactly K" isn't a clean window. "At most K" is monotonic in width: shrink a valid window, it stays valid. So atMost(K) admits the grow/shrink template, and — the counting insight — a window [left, right] that is at-most-K contributes right − left + 1 subarrays ending at right (every start from left to right). Summing over right counts all at-most-K subarrays. "Exactly K" is not monotonic: shrinking an exactly-K window can drop you to K−1, so there's no clean "shrink while invalid" rule. The fix is subtraction:

exactly(K) = atMost(K) − atMost(K − 1)

Subarrays with K Different Integers (LC 992) & Binary Subarrays with Sum (LC 930)#

kotlin
fun subarraysWithKDistinct(nums: IntArray, k: Int): Int = atMostKDistinct(nums, k) - atMostKDistinct(nums, k - 1) private fun atMostKDistinct(nums: IntArray, k: Int): Int { if (k < 0) return 0 val count = HashMap<Int, Int>() var left = 0; var res = 0 for (right in nums.indices) { count[nums[right]] = (count[nums[right]] ?: 0) + 1 while (count.size > k) { val l = nums[left]; count[l] = count[l]!! - 1 if (count[l] == 0) count.remove(l); left++ } res += right - left + 1 // subarrays ending at `right` that are at-most-K } return res }
java
int subarraysWithKDistinct(int[] nums, int k) { return atMostKDistinct(nums, k) - atMostKDistinct(nums, k - 1); } private int atMostKDistinct(int[] nums, int k) { if (k < 0) return 0; Map<Integer, Integer> count = new HashMap<>(); int left = 0, res = 0; for (int right = 0; right < nums.length; right++) { count.merge(nums[right], 1, Integer::sum); while (count.size() > k) { int l = nums[left++]; if (count.merge(l, -1, Integer::sum) == 0) count.remove(l); } res += right - left + 1; } return res; }

For 0/1 arrays the same subtraction counts sums, because window sum is monotonic:

kotlin
fun numSubarraysWithSum(nums: IntArray, goal: Int): Int = // LC 930 atMostSum(nums, goal) - atMostSum(nums, goal - 1) private fun atMostSum(nums: IntArray, goal: Int): Int { if (goal < 0) return 0 var left = 0; var sum = 0; var res = 0 for (right in nums.indices) { sum += nums[right] while (sum > goal) { sum -= nums[left]; left++ } res += right - left + 1 } return res }

Count Number of Nice Subarrays (LC 1248) — "exactly k odd numbers" — is this same code after mapping odd → 1, even → 0. All O(n) time, O(k) space. The alternative for these counting problems is prefix + hashmap (next section); the at-most trick is the tighter O(1)-space-ish option when values are non-negative/binary.


7. Prefix sums, difference arrays, prefix XOR#

Recognition cue. Many range-sum queries, "subarray summing to K" (esp. with negative numbers, where sliding window fails), submatrix sums, "apply many range updates then read", "subarray with XOR = k." Precompute cumulative aggregates once; answer each query in O(1).

1D prefix sum. Define P[0] = 0, P[i+1] = P[i] + nums[i], so sum(l..r) = P[r+1] − P[l]. The off-by-one is tamed by the n+1-length array with a leading zero.

kotlin
class NumArray(nums: IntArray) { // LC 303 private val prefix = LongArray(nums.size + 1) init { for (i in nums.indices) prefix[i + 1] = prefix[i] + nums[i] } fun sumRange(left: Int, right: Int): Long = prefix[right + 1] - prefix[left] }
java
class NumArray { // LC 303 private final long[] prefix; NumArray(int[] nums) { prefix = new long[nums.length + 1]; for (int i = 0; i < nums.length; i++) prefix[i + 1] = prefix[i] + nums[i]; } long sumRange(int left, int right) { return prefix[right + 1] - prefix[left]; } }

Subarray Sum Equals K (LC 560) — prefix → count HashMap. sum(l..r) = kP[r+1] − P[l] = kP[l] = P[r+1] − k. Walk once, and for each running prefix count how many earlier prefixes equal prefix − k. Seed {0: 1} for subarrays that start at index 0. This handles negatives — the reason you can't use a sliding window here.

kotlin
fun subarraySum(nums: IntArray, k: Int): Int { val countByPrefix = HashMap<Long, Int>() countByPrefix[0L] = 1 var prefix = 0L; var res = 0 for (x in nums) { prefix += x res += countByPrefix.getOrDefault(prefix - k, 0) // query BEFORE inserting -> no zero-length match countByPrefix[prefix] = countByPrefix.getOrDefault(prefix, 0) + 1 } return res }
java
int subarraySum(int[] nums, int k) { Map<Long, Integer> countByPrefix = new HashMap<>(); countByPrefix.put(0L, 1); long prefix = 0; int res = 0; for (int x : nums) { prefix += x; res += countByPrefix.getOrDefault(prefix - k, 0); countByPrefix.merge(prefix, 1, Integer::sum); } return res; }

2D prefix sum, difference array, prefix XOR (Kotlin; each is a one-to-one Java port):

kotlin
class NumMatrix(matrix: Array<IntArray>) { // LC 304, submatrix sums by inclusion-exclusion private val p: Array<LongArray> init { val rows = matrix.size val cols = if (rows > 0) matrix[0].size else 0 p = Array(rows + 1) { LongArray(cols + 1) } for (r in 0 until rows) for (c in 0 until cols) p[r + 1][c + 1] = matrix[r][c] + p[r][c + 1] + p[r + 1][c] - p[r][c] } fun sumRegion(r1: Int, c1: Int, r2: Int, c2: Int): Long = p[r2 + 1][c2 + 1] - p[r1][c2 + 1] - p[r2 + 1][c1] + p[r1][c1] } fun rangeUpdates(n: Int, updates: Array<IntArray>): LongArray { // LC 370: add v to each [l, r] val diff = LongArray(n + 1) for (u in updates) { val l = u[0]; val r = u[1]; val v = u[2].toLong() diff[l] += v // +v starts at l diff[r + 1] -= v // -v cancels just past r } val res = LongArray(n); var running = 0L for (i in 0 until n) { running += diff[i]; res[i] = running } // prefix-sum the diffs return res } fun xorQueries(arr: IntArray, queries: Array<IntArray>): IntArray { // LC 1310: XOR is its own inverse val prefix = IntArray(arr.size + 1) for (i in arr.indices) prefix[i + 1] = prefix[i] xor arr[i] return IntArray(queries.size) { q -> prefix[queries[q][1] + 1] xor prefix[queries[q][0]] } }

Complexity: 1D/XOR build O(n), query O(1); 2D build O(rows·cols), query O(1); difference array O(n + updates). The overflow trap dominates this section: a prefix sum of many ints overflows 32 bits silently and returns garbage — use long for the accumulator and the array. (XOR never overflows, so int is fine there.) The other trap: querying prefix − k after inserting the current prefix, which counts the empty subarray.


8. Cyclic sort#

Recognition cue. The values are a permutation of 1..n (or 0..n, or 0..n−1) — "find the missing / duplicated / smallest-missing-positive number," often with an O(1) extra space demand. Because each value has a known home index, you can sort in O(n) by swapping values into place, then scan for the slot that's wrong.

Template. Value v belongs at index v − 1 (for 1..n). Repeatedly swap the current value home until it's already there, then advance. Each swap places one value permanently, so it's O(n) total despite the nested-looking while.

kotlin
fun cyclicSort(nums: IntArray) { // values 1..n var i = 0 while (i < nums.size) { val correct = nums[i] - 1 if (nums[i] != nums[correct]) nums.swap(i, correct) else i++ } }
java
void cyclicSort(int[] nums) { int i = 0; while (i < nums.length) { int correct = nums[i] - 1; if (nums[i] != nums[correct]) { int t = nums[i]; nums[i] = nums[correct]; nums[correct] = t; } else i++; } }

First Missing Positive (LC 41) — the hard one, O(n) time & O(1) space. Ignore values outside 1..n; place each in-range v at index v − 1; then the first index whose value isn't i + 1 is the answer.

kotlin
fun firstMissingPositive(nums: IntArray): Int { val n = nums.size; var i = 0 while (i < n) { val v = nums[i] if (v in 1..n && nums[v - 1] != v) nums.swap(i, v - 1) else i++ } for (j in 0 until n) if (nums[j] != j + 1) return j + 1 return n + 1 }
java
int firstMissingPositive(int[] nums) { int n = nums.length, i = 0; while (i < n) { int v = nums[i]; if (v >= 1 && v <= n && nums[v - 1] != v) { int t = nums[i]; nums[i] = nums[v - 1]; nums[v - 1] = t; } else i++; } for (int j = 0; j < n; j++) if (nums[j] != j + 1) return j + 1; return n + 1; }

The 1..n sibling problems (Kotlin; scan after the same cyclic placement):

kotlin
fun missingNumber(nums: IntArray): Int { // LC 268, values 0..n; value v belongs at index v val n = nums.size; var i = 0 while (i < n) { val v = nums[i]; if (v < n && v != nums[v]) nums.swap(i, v) else i++ } for (j in 0 until n) if (nums[j] != j) return j return n } fun findDisappearedNumbers(nums: IntArray): List<Int> { // LC 448 var i = 0 while (i < nums.size) { val c = nums[i] - 1; if (nums[i] != nums[c]) nums.swap(i, c) else i++ } val res = mutableListOf<Int>() for (j in nums.indices) if (nums[j] != j + 1) res.add(j + 1) return res }

All O(n) time, O(1) space. For Find the Duplicate (LC 287), cyclic sort finds it (the value that's already home when its slot is taken is the duplicate) — but LC 287 forbids mutating the array, so the Floyd approach from §3 is the intended answer. Know both and state the constraint that picks one: cyclic sort mutates and needs a permutation-like domain; Floyd is read-only. The trap: writing while (i < n) with an i++ inside the swap branch — you must not advance i after a swap, because the value you just pulled in is unexamined.


Problems & how to solve them#

Problem: the window never shrinks (or shrinks forever). Root cause: the while condition tests the wrong polarity — you wrote "shrink while valid" for a longest problem, so it collapses every window, or "shrink while invalid" with a condition that's never true, so it grows unbounded and you get O(n²) or a wrong answer. Fix: pin the flavor first. Longestwhile (invalid) shrink; record after. Shortestwhile (valid) { record; shrink; }. Say the invariant out loud before coding it.

Problem: off-by-one on window length. Root cause: mixing inclusive/exclusive bounds — using right − left instead of right − left + 1, or comparing counts before the fixed window is full. Fix: fix a convention — [left, right] inclusive, width right − left + 1; a fixed window of size k is "full" when right ≥ k − 1. For prefix sums use the n+1 array with a leading zero so sum(l..r) = P[r+1] − P[l] needs no special-casing.

Problem: the answer is recorded at the wrong moment. Root cause: updating best inside the shrink loop for a longest problem (or after it for a shortest one). Fix: longest records after validity is restored (window is maximal-valid for this right); shortest records inside, right before removing nums[left] (window is minimal-valid). The two templates in §5 differ only here.

Problem: two pointers on unsorted input. Root cause: converging lo/hi on data that isn't sorted — the "move the smaller pointer" logic assumes monotonic sums and is meaningless otherwise; likewise sliding-window-sum assumes non-negative values. Fix: opposite-ends two pointers requires sorted or symmetric input — sort first (if indices don't matter) or switch to a HashMap (unsorted two-sum) / prefix-map (unsorted subarray-sum). Min-subarray-sum needs non-negative values; with negatives use prefix + deque.

Problem: 3Sum emits duplicate triples (the "duplicate explosion"). Root cause: skipping duplicates on only one of the three positions. Fix: skip the duplicate anchor (i > 0 && nums[i] == nums[i-1]) and skip duplicate lo/hi after recording a hit. All three, or you get repeats. A HashSet of triples masks the bug at O(n) memory cost — don't.

Problem: overflow in prefix sums / window sums. Root cause: accumulating many ints into a 32-bit int; it wraps silently and returns a plausible-looking wrong number. Fix: use long for any running sum over more than a handful of ints (prefix arrays, size-k window sums, running totals). XOR is safe in int. In Java, also avoid Integer == Integer for counts > 127 — compare .intValue().

Problem: forgetting the left pointer can jump. Root cause: in the last-index optimization for "longest without repeats," setting left = lastIndex[c] + 1 unconditionally lets left move backward when the duplicate is outside the current window, corrupting the width. Fix: left = maxOf(left, lastIndex[c] + 1) — the pointer may jump forward by many, but must never retreat. The shrink-loop version sidesteps this entirely.


Interview framing / how to sound senior#

  • Lead with the recognition cue, not code. "'Longest contiguous substring with at most K distinct' — that's a monotonic-in-width predicate, so a grow/shrink window is O(n) time, O(K) space. Let me confirm the constraint direction, then write the template." You've named the pattern, the complexity, and the plan in one breath. Per the rubric research, senior scoring weights judgment and driving the session over raw speed — this is what that looks like.
  • Always state time AND space, and the enabling assumption. "O(n) time because each index enters and leaves the window once; O(1) space because the count vector is fixed at 26. This assumes non-negative values so the window sum is monotonic — if negatives are possible I'd switch to prefix sums + a hashmap." Naming the assumption is a senior tell; it's the difference between a memorized template and understanding.
  • Narrate the invariant as you code, then test it on a boundary. Empty input, single element, all-equal, all-distinct, the whole array being the answer, no answer existing. Interviewers score a distinct Testing axis; walking two adversarial cases (a negative number, a duplicate at the far end) catches the classic bugs above before they do.
  • Call the trade-off, don't just pick. Sorted two-pointer 3Sum is O(n²)/O(1) and needs a sort; a hashmap variant avoids the sort but costs O(n) memory and messier dedup. Say which you're choosing and why. Reaching for HashSet to dedup 3Sum, or a prefix array where a two-pointer scan suffices, reads as "didn't see the structure."
  • Know the version-dependent facts. Kotlin Char.lowercaseChar() (1.5+, replaced deprecated toLowerCase()); Java switch-arrow (14+/stable 17), List.of (9+), Map.merge/getOrDefault (8+). Say "Java 17" when you use an arrow switch.

Cheat-sheet#

Recognition cue in the promptSub-patternKey moveTime / Space
Sorted array, find a pair/triplet to targetOpposite-ends two ptrsum<t → lo++ else hi--O(n) or O(n²) / O(1)
Max area / trapping water between wallsOpposite-ends greedymove the shorter/smaller sideO(n) / O(1)
Palindrome, reverse in placeOpposite-ends symmetricconverge, swap/compareO(n) / O(1)
Partition into 0/1/2 or </=/>Dutch flag (3 ptr)swap-low advance, swap-high holdO(n) / O(1)
Remove/compact/"new length", merge sortedSame-direction read/writewrite only survivors; merge from backO(n) / O(1)
Cycle / midpoint / dup in O(1) spaceFast & slow (Floyd)fast = 2·slow; reset to head for startO(n) / O(1)
Window of size exactly k, anagram/permutationFixed windowsum += in − out; compare 26-vectorO(n) / O(Σ)
Longest/shortest contiguous with a constraintVariable windowexpand right, shrink while (in)validO(n) / O(k)
Count subarrays with exactly KatMost(K) − atMost(K−1)sum right−left+1 per windowO(n) / O(k)
Many range sums / subarray-sum=K with negativesPrefix sum (+hashmap)P[r+1]−P[l]; count P−kO(n) build, O(1) query
Submatrix sums2D prefixinclusion-exclusion of 4 cornersO(mn) / O(mn)
Many range updates, read once at endDifference array+v at l, −v at r+1, then prefixO(n + u) / O(n)
Values are a permutation of 1..n; find anomalyCyclic sortswap value to index v−1, scanO(n) / O(1)

Self-test#

  1. State the two variable-window templates (longest vs. shortest) and the single line that differs between them.
  2. Why must opposite-ends two pointers move the pointer at the smaller value in sorted two-sum? What breaks on unsorted input?
  3. In 3Sum, exactly which three duplicate-skips are required, and what wrong output appears if you drop the lo/hi skips?
  4. Derive a = (k−1)L + (L − b) for Floyd's cycle-start and explain why resetting one pointer to the head works.
  5. Why does Find the Duplicate (LC 287) use Floyd rather than cyclic sort, given cyclic sort also finds it?
  6. In Longest Repeating Character Replacement, why is it safe never to decrease maxFreq when the window shrinks?
  7. Prove exactly(K) = atMost(K) − atMost(K−1) and explain why exactly-K has no clean single window.
  8. When does Minimum Size Subarray Sum's sliding window fail, and what do you switch to?
  9. Why does Subarray Sum Equals K use a prefix→count map instead of a sliding window? What seed does the map need and why?
  10. Give two distinct places the integer-overflow / boxing trap bites in this guide and the fix for each.
  11. Sort Colors: after swapping nums[mid] with nums[high], why must you not advance mid?
  12. Merge Sorted Array in place: why fill from the back, and why is the loop condition j >= 0?
  13. First Missing Positive: why is it O(n) despite the nested while, and why must i not advance after a swap?
  14. For the last-index optimization in Longest Substring Without Repeating Characters, why is left = max(left, last[c]+1) — not last[c]+1 — mandatory?

Lineage / sources#

The three-way partition is Dijkstra's Dutch National Flag (1976). Cycle detection is Floyd's tortoise-and-hare; Brent's variant is a faster cousin worth naming. The pattern taxonomy and names (fast/slow, sliding window, cyclic sort, at-most-K) follow Grokking the Coding Interview (DesignGurus/Educative) and NeetCode 150; the "14 patterns" framing is Fahim ul Haq's widely-reposted list. Problem numbers reference LeetCode (3, 11, 15, 26, 41, 42, 75, 76, 88, 125, 141, 142, 167, 202, 209, 234, 268, 287, 303, 304, 340, 370, 424, 438, 448, 560, 567, 876, 904, 930, 992, 1248, 1310). Interview-format and rubric notes (round mix by level, the four scoring axes, the practical/"simulate a stream" tilt at infra shops like Snowflake) come from Tech Interview Handbook, interviewing.io's 100K-interview study, and the 2025-26 vendor guides synthesized in the series playbook (00). Treat exact round counts as typical-not-guaranteed. For the internals of any structure used here as a tool — HashMap, ArrayDeque, PriorityQueue — see the companion data-structures-java-kotlin-study-guide.md.