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, advancerightto admit new elements, advanceleftto 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:
| Costume | Pointers move | Region means | Enabling property |
|---|---|---|---|
| Opposite-ends two pointers | inward, converge | a candidate pair/partition | input is sorted or symmetric |
| Same-direction (read/write) | both forward, write ≤ read | compacted prefix vs. scanned suffix | in-place rewrite, order-preserving |
| Fast & slow | both forward, fast = 2·slow | cycle / midpoint geometry | reachability forms a ρ-shape |
| Fixed window | both forward, right − left = k−1 | exactly-k-wide slice | additive/subtractive aggregate |
| Variable window | both forward, left ≤ right | the maximal/minimal valid slice | predicate monotonic in width |
| Prefix sums | no window — precompute P[i] | sum(l,r) = P[r+1] − P[l] | aggregate is invertible (+, xor) |
| Cyclic sort | i vs. nums[i] | value belongs at a known index | values 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):
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).
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)
}
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.
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
}
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.
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)#
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:
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
}
}
}
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).
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
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)#
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:
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--]
}
}
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):
class ListNode(var value: Int) { var next: ListNode? = null }
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.
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
}
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 + kL ⇒ a + b = kL ⇒ a = 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:
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
}
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):
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
}
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):
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.
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
}
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).
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
}
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):
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):
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.
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
}
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#
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.
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)
}
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 youaddthe new timestamp on the right andremovefrom the left every timestamp older thannow − windowMillis, then reject ifdeque.size > limit.leftchasingright= 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)#
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
}
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:
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.
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]
}
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) = k ⇔ P[r+1] − P[l] = k ⇔ P[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.
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
}
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):
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.
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++
}
}
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.
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
}
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):
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. Longest ⇒ while (invalid) shrink; record after. Shortest ⇒ while (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
HashSetto 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 deprecatedtoLowerCase()); 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 prompt | Sub-pattern | Key move | Time / Space |
|---|---|---|---|
| Sorted array, find a pair/triplet to target | Opposite-ends two ptr | sum<t → lo++ else hi-- | O(n) or O(n²) / O(1) |
| Max area / trapping water between walls | Opposite-ends greedy | move the shorter/smaller side | O(n) / O(1) |
| Palindrome, reverse in place | Opposite-ends symmetric | converge, swap/compare | O(n) / O(1) |
Partition into 0/1/2 or </=/> | Dutch flag (3 ptr) | swap-low advance, swap-high hold | O(n) / O(1) |
| Remove/compact/"new length", merge sorted | Same-direction read/write | write only survivors; merge from back | O(n) / O(1) |
| Cycle / midpoint / dup in O(1) space | Fast & slow (Floyd) | fast = 2·slow; reset to head for start | O(n) / O(1) |
| Window of size exactly k, anagram/permutation | Fixed window | sum += in − out; compare 26-vector | O(n) / O(Σ) |
| Longest/shortest contiguous with a constraint | Variable window | expand right, shrink while (in)valid | O(n) / O(k) |
| Count subarrays with exactly K | atMost(K) − atMost(K−1) | sum right−left+1 per window | O(n) / O(k) |
| Many range sums / subarray-sum=K with negatives | Prefix sum (+hashmap) | P[r+1]−P[l]; count P−k | O(n) build, O(1) query |
| Submatrix sums | 2D prefix | inclusion-exclusion of 4 corners | O(mn) / O(mn) |
| Many range updates, read once at end | Difference array | +v at l, −v at r+1, then prefix | O(n + u) / O(n) |
Values are a permutation of 1..n; find anomaly | Cyclic sort | swap value to index v−1, scan | O(n) / O(1) |
Self-test#
- State the two variable-window templates (longest vs. shortest) and the single line that differs between them.
- Why must opposite-ends two pointers move the pointer at the smaller value in sorted two-sum? What breaks on unsorted input?
- In 3Sum, exactly which three duplicate-skips are required, and what wrong output appears if you drop the
lo/hiskips? - Derive
a = (k−1)L + (L − b)for Floyd's cycle-start and explain why resetting one pointer to the head works. - Why does Find the Duplicate (LC 287) use Floyd rather than cyclic sort, given cyclic sort also finds it?
- In Longest Repeating Character Replacement, why is it safe never to decrease
maxFreqwhen the window shrinks? - Prove
exactly(K) = atMost(K) − atMost(K−1)and explain why exactly-K has no clean single window. - When does Minimum Size Subarray Sum's sliding window fail, and what do you switch to?
- Why does Subarray Sum Equals K use a prefix→count map instead of a sliding window? What seed does the map need and why?
- Give two distinct places the integer-overflow / boxing trap bites in this guide and the fix for each.
- Sort Colors: after swapping
nums[mid]withnums[high], why must you not advancemid? - Merge Sorted Array in place: why fill from the back, and why is the loop condition
j >= 0? - First Missing Positive: why is it O(n) despite the nested
while, and why mustinot advance after a swap? - For the last-index optimization in Longest Substring Without Repeating Characters, why is
left = max(left, last[c]+1)— notlast[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.