31 min read

Chapter 2a — Array Problem Patterns


Chapter 2 gave you the physical reality of arrays: why access is O(1), why middle insertion is O(n), why JavaScript's [] is a dynamic structure managed by V8. This chapter is different in purpose. Here you stop understanding and start doing. The goal is to build pattern recognition — the ability to look at a problem you have never seen before and immediately know which technique applies. That ability does not come from reading. It comes from solving enough problems deeply enough that the patterns become instinct. Every problem in this chapter is solved with full reasoning and a complete dry run. You do not move on until you can reproduce the reasoning cold, without looking.

One important boundary: Two Pointers and Sliding Window each get their own chapters (5 and 6). They are used here — because they are array techniques — but treated as previews. You will see them introduced on one problem each. The deep treatment comes later.


The Pattern Map

Before any problem, this is how you read the signal:

Signal: "maximum or minimum of something over all subarrays" → Kadane's pattern. You maintain a running local value and a global best simultaneously.

Signal: "sum of a range" or "how many subarrays sum to k" or "equilibrium index" → Prefix sum. Possibly combined with a HashMap if counting is involved.

Signal: "in-place", "O(1) extra space", "remove elements" → Two-index read/write. One index reads every element, one index writes only the keepers.

Signal: "intervals", "overlapping ranges", "merge", "non-overlapping" → Sort by start, then sweep. Intervals collapse to an array problem after sorting.

Signal: "sorted array, find pair/triplet", "container", "two ends" → Two Pointers from ends. Full treatment in Chapter 5 — one preview here.

Signal: "contiguous subarray with constraint on length or sum" → Sliding Window. Full treatment in Chapter 6 — one preview here.

Signal: "product of everything except current index" → Prefix product + suffix product. A specific but important pattern.

Hold these signals. Every problem below maps to one of them.


Pattern 1: Kadane's Algorithm — The Running Best

The core idea is this: at every position in the array, you make a single local decision — "does including this element make my current subarray better, or am I better off starting fresh from here?" Alongside that local decision, you track the best answer you have seen globally.

This pattern applies whenever the problem is: find the subarray (contiguous) that maximizes or minimizes some value. The insight — which we will derive — is that you never need to look backward more than one step.


Problem 1: Maximum Subarray (LeetCode 53)

Problem: Given an integer array nums, find the contiguous subarray with the largest sum and return that sum.

code
Input:  [-2, 1, -3, 4, -1, 2, 1, -5, 4]
Output: 6
Reason: [4, -1, 2, 1] has the largest sum = 6

Q1: What do I have? An array of n integers. Integers can be negative. n can be up to 10⁵.

Q2: What do I want? The maximum sum of any contiguous subarray. Not the subarray itself — just the sum.

Q3: What is the brute force? Check every possible subarray. Two nested loops to pick start and end indices, sum each subarray.

javascript
function maxSubarrayBrute(nums) {
  let max = -Infinity;
  for (let i = 0; i < nums.length; i++) {
    let sum = 0;
    for (let j = i; j < nums.length; j++) {
      sum += nums[j];
      max = Math.max(max, sum);
    }
  }
  return max;
}

Time: O(n²). Space: O(1). For n = 10⁵ that is 10¹⁰ operations. Too slow.

Q4: Where is the waste?

Look at what the brute force does: for start index i = 3 (value 4), it computes sums for subarrays [4], [4,-1], [4,-1,2], and so on. Then for i = 4 (value -1), it recomputes [-1], [-1,2], etc. There is massive overlap.

But here is the deeper insight: ask yourself this question at every index i — should the best subarray ending at position i include what came before, or start fresh at i?

If the best subarray ending at i-1 has a positive sum, you want to include it — it can only help. If it has a negative sum, you are better off ditching it and starting at i — dragging in a negative sum makes your total worse.

This is a single comparison per element. No looking backward beyond the previous result.

The algorithm:

javascript
function maxSubarray(nums) {
  let currentSum = nums[0];  // best sum ending AT current position
  let globalMax = nums[0];   // best sum seen so far across all positions

  for (let i = 1; i < nums.length; i++) {
    // Decision: extend the previous subarray, or start fresh here?
    currentSum = Math.max(nums[i], currentSum + nums[i]);
    globalMax = Math.max(globalMax, currentSum);
  }

  return globalMax;
}

Full dry run on [-2, 1, -3, 4, -1, 2, 1, -5, 4]:

We initialize with the first element because an empty subarray is not valid — there must be at least one element.

code
Start: currentSum = -2,  globalMax = -2

i=1, nums[1]=1:
  currentSum = max(1, -2+1) = max(1, -1) = 1
  (starting fresh at 1 is better than extending [-2,1] = -1)
  globalMax = max(-2, 1) = 1

i=2, nums[2]=-3:
  currentSum = max(-3, 1+(-3)) = max(-3, -2) = -2
  (starting fresh at -3 is worse than [1,-3]=-2, so we keep [1,-3])
  Wait — both options are negative. We pick the less negative: -2.
  globalMax = max(1, -2) = 1  (global max doesn't change)

i=3, nums[3]=4:
  currentSum = max(4, -2+4) = max(4, 2) = 4
  (starting fresh at 4 is better than extending [1,-3,4]=2)
  globalMax = max(1, 4) = 4

i=4, nums[4]=-1:
  currentSum = max(-1, 4+(-1)) = max(-1, 3) = 3
  (extending [4,-1]=3 is better than starting fresh at -1)
  globalMax = max(4, 3) = 4

i=5, nums[5]=2:
  currentSum = max(2, 3+2) = max(2, 5) = 5
  (extending [4,-1,2]=5 is better)
  globalMax = max(4, 5) = 5

i=6, nums[6]=1:
  currentSum = max(1, 5+1) = max(1, 6) = 6
  (extending [4,-1,2,1]=6 is better)
  globalMax = max(5, 6) = 6

i=7, nums[7]=-5:
  currentSum = max(-5, 6+(-5)) = max(-5, 1) = 1
  (extending [4,-1,2,1,-5]=1 is better than starting at -5)
  globalMax = max(6, 1) = 6

i=8, nums[8]=4:
  currentSum = max(4, 1+4) = max(4, 5) = 5
  (extending gives 5, starting fresh gives 4; extend wins)
  globalMax = max(6, 5) = 6

Final answer: 6 ✓

Why does this work?

At each index i, currentSum holds the maximum sum of any subarray that ends exactly at i. There are only two candidates: a subarray containing only nums[i] (fresh start), or any subarray ending at i-1 extended by nums[i]. The maximum of these two is max(nums[i], currentSum + nums[i]). Since we check every position and track the global best, we cannot miss the answer.

Complexity: O(n) time, O(1) space. One pass, two variables.


Problem 2: Best Time to Buy and Sell Stock (LeetCode 121)

Problem: You are given an array prices where prices[i] is the price of a stock on day i. You can buy once and sell once. Find the maximum profit. If no profit is possible, return 0.

code
Input:  [7, 1, 5, 3, 6, 4]
Output: 5
Reason: Buy on day 2 (price 1), sell on day 5 (price 6). Profit = 6 - 1 = 5.

Q1: What do I have? An array of n prices, all non-negative. Constraint: buy must happen before sell.

Q2: What do I want? Maximum prices[j] - prices[i] where j > i. Return 0 if no positive profit exists.

Q3: Brute force? Every pair (i, j) where j > i. Check prices[j] - prices[i].

javascript
function maxProfitBrute(prices) {
  let max = 0;
  for (let i = 0; i < prices.length; i++) {
    for (let j = i + 1; j < prices.length; j++) {
      max = Math.max(max, prices[j] - prices[i]);
    }
  }
  return max;
}

O(n²). Waste.

Q4: Where is the waste?

To maximize prices[j] - prices[i], you want prices[i] to be as small as possible. For any given sell day j, the best buy day is the day with the lowest price before j.

So as you scan from left to right, track the minimum price seen so far. At each day, compute profit if you sold today: prices[j] - minSoFar. That is your best possible profit for selling on day j.

This is the same structure as Kadane's: a running local value (minSoFar) and a global best (maxProfit).

javascript
function maxProfit(prices) {
  let minSoFar = prices[0];
  let maxProfit = 0;

  for (let i = 1; i < prices.length; i++) {
    // Best profit if we sell today
    maxProfit = Math.max(maxProfit, prices[i] - minSoFar);
    // Update the cheapest buy price we've seen
    minSoFar = Math.min(minSoFar, prices[i]);
  }

  return maxProfit;
}

Full dry run on [7, 1, 5, 3, 6, 4]:

code
Start: minSoFar = 7, maxProfit = 0

i=1, price=1:
  profit if sell today = 1 - 7 = -6 → maxProfit = max(0, -6) = 0
  minSoFar = min(7, 1) = 1

i=2, price=5:
  profit if sell today = 5 - 1 = 4 → maxProfit = max(0, 4) = 4
  minSoFar = min(1, 5) = 1

i=3, price=3:
  profit if sell today = 3 - 1 = 2 → maxProfit = max(4, 2) = 4
  minSoFar = min(1, 3) = 1

i=4, price=6:
  profit if sell today = 6 - 1 = 5 → maxProfit = max(4, 5) = 5
  minSoFar = min(1, 6) = 1

i=5, price=4:
  profit if sell today = 4 - 1 = 3 → maxProfit = max(5, 3) = 5
  minSoFar = min(1, 4) = 1

Final answer: 5 ✓

Why does this work? At every index, the maximum profit achievable by selling on day i is prices[i] - (minimum price in prices[0..i-1]). By maintaining minSoFar, we compute this in O(1) instead of scanning backwards. The rest is tracking the global max.

Note the deeper connection to Kadane's: If you transform the prices array into a daily profit/loss array (differences: prices[i] - prices[i-1]), finding the best buy/sell becomes exactly finding the maximum subarray sum of that difference array. The problems are isomorphic. Recognizing this connection across different-looking problems is what makes you dangerous.

Complexity: O(n) time, O(1) space.


Pattern 2: Prefix Product — The "Left Pass Right Pass" Technique

Prefix sum computes cumulative sums going left to right. The same idea applies to any operation that is associative: multiplication, XOR, GCD. The key insight is that you can make two passes — one from the left, one from the right — and combine the results.


Problem 3: Product of Array Except Self (LeetCode 238)

Problem: Given an integer array nums, return an array answer such that answer[i] is the product of all elements in nums except nums[i]. You may not use the division operator. Solve in O(n) time with O(1) extra space (the output array does not count as extra space).

code
Input:  [1, 2, 3, 4]
Output: [24, 12, 8, 6]
Reason: answer[0] = 2×3×4 = 24
        answer[1] = 1×3×4 = 12
        answer[2] = 1×2×4 = 8
        answer[3] = 1×2×3 = 6

Q1: What do I have? Array of n integers. No division allowed. Must be O(n) time, O(1) extra space.

Q2: What do I want? Array where each position holds the product of everything except the element at that position.

Q3: Brute force? For each index i, loop through all elements except i and multiply. O(n²). Obvious waste.

Q4: Where is the waste?

Look at what each position needs:

code
answer[0] needs:          × nums[1] × nums[2] × nums[3]
answer[1] needs: nums[0] ×           nums[2] × nums[3]
answer[2] needs: nums[0] × nums[1] ×           nums[3]
answer[3] needs: nums[0] × nums[1] × nums[2]

Split each row into what is to the left of index i and what is to the right of index i:

code
answer[i] = (product of everything left of i) × (product of everything right of i)

Both halves can be precomputed in a single pass each.

Pass 1 — Left products: Scan left to right. left[i] = product of nums[0] through nums[i-1]. Pass 2 — Right products: Scan right to left. right[i] = product of nums[i+1] through nums[n-1]. Combine: answer[i] = left[i] × right[i].

But two extra arrays use O(n) space. The O(1) space trick: use the output array itself for the left pass, then fold the right product in on the way back with just a single variable.

javascript
function productExceptSelf(nums) {
  const n = nums.length;
  const answer = new Array(n);

  // Pass 1: fill answer[i] with product of everything LEFT of i
  answer[0] = 1;  // nothing to the left of index 0
  for (let i = 1; i < n; i++) {
    answer[i] = answer[i - 1] * nums[i - 1];
  }
  // After pass 1: answer = [1, 1, 2, 6] for input [1,2,3,4]

  // Pass 2: multiply each position by product of everything RIGHT of i
  let rightProduct = 1;  // nothing to the right of last index
  for (let i = n - 1; i >= 0; i--) {
    answer[i] = answer[i] * rightProduct;
    rightProduct *= nums[i];  // update right product before moving left
  }

  return answer;
}

Full dry run on [1, 2, 3, 4]:

Pass 1 (left products into answer):

code
answer[0] = 1                           (no elements to the left)
answer[1] = answer[0] × nums[0] = 1×1 = 1
answer[2] = answer[1] × nums[1] = 1×2 = 2
answer[3] = answer[2] × nums[2] = 2×3 = 6

answer after pass 1: [1, 1, 2, 6]

Pass 2 (multiply in right products from right to left):

code
rightProduct = 1

i=3: answer[3] = 6 × 1 = 6.   Then rightProduct = 1 × nums[3] = 1×4 = 4
i=2: answer[2] = 2 × 4 = 8.   Then rightProduct = 4 × nums[2] = 4×3 = 12
i=1: answer[1] = 1 × 12 = 12. Then rightProduct = 12 × nums[1] = 12×2 = 24
i=0: answer[0] = 1 × 24 = 24. Then rightProduct = 24 × nums[0] = 24×1 = 24

answer after pass 2: [24, 12, 8, 6] ✓

Why is answer[0] = 1 in pass 1? There are zero elements to the left of index 0. The product of zero elements is 1 (the multiplicative identity — the same reason 0! = 1 in mathematics). Any real element multiplied by 1 is unchanged, so this is the correct neutral starting value.

Why does rightProduct update AFTER we use it? At index i, we need the product of everything to the RIGHT of i — meaning nums[i+1] through nums[n-1]. The variable rightProduct holds that value when we arrive at i. After we use it, we incorporate nums[i] into rightProduct so it is ready for index i-1.

Complexity: O(n) time, O(1) extra space (output array excluded by problem statement).


Pattern 3: Prefix Sum + HashMap — Counting Subarrays

When you need to count subarrays with a specific sum, prefix sum alone is not enough — you need to look up previous prefix sums efficiently. A HashMap transforms the lookup from O(n) to O(1).


Problem 4: Subarray Sum Equals K (LeetCode 560)

Problem: Given an array of integers nums and an integer k, return the total number of contiguous subarrays whose sum equals k.

code
Input:  nums = [1, 1, 1], k = 2
Output: 2
Reason: [1,1] at indices 0-1 and [1,1] at indices 1-2 both sum to 2.

Q1: What do I have? Array of integers (can be negative!). An integer k. n up to 2×10⁴.

Q2: What do I want? A count of subarrays summing exactly to k.

Q3: Brute force? Two loops: for every (i, j) pair, compute the sum and check if it equals k. O(n²). With prefix sum precomputation: O(n) build, then O(n²) queries. Still O(n²) total.

Q4: Where is the waste?

We established in Chapter 2 that sum(i, j) = prefix[j+1] - prefix[i]. We want sum(i, j) = k, so:

code
prefix[j+1] - prefix[i] = k
prefix[i] = prefix[j+1] - k

As we scan from left to right, at position j+1 we have computed prefix[j+1]. We need to know: how many previous prefix values equal prefix[j+1] - k?

If we store prefix sums in a HashMap as we go, this lookup is O(1). We do not need to scan backward — we just look up the complement in the map.

The critical detail — why {0: 1} as the starting state:

Before processing any element, the prefix sum is 0 (empty prefix). If prefix[j+1] - k = 0, it means the entire subarray from index 0 to j sums to k. Without the sentinel {0: 1}, we would miss these subarrays. The 1 means "the prefix sum of 0 has occurred once" — that once being before the array starts.

javascript
function subarraySum(nums, k) {
  const freq = new Map();
  freq.set(0, 1);  // sentinel: prefix sum 0 seen once before the array

  let prefixSum = 0;
  let count = 0;

  for (let i = 0; i < nums.length; i++) {
    prefixSum += nums[i];

    // How many previous prefixes give us a valid subarray ending here?
    const complement = prefixSum - k;
    if (freq.has(complement)) {
      count += freq.get(complement);
    }

    // Record this prefix sum
    freq.set(prefixSum, (freq.get(prefixSum) || 0) + 1);
  }

  return count;
}

Full dry run on [1, 1, 1], k = 2:

code
freq = {0: 1},  prefixSum = 0,  count = 0

i=0, nums[0]=1:
  prefixSum = 0+1 = 1
  complement = 1-2 = -1 → not in freq → count unchanged (0)
  freq = {0:1, 1:1}

i=1, nums[1]=1:
  prefixSum = 1+1 = 2
  complement = 2-2 = 0 → in freq with count 1 → count = 0+1 = 1
  (This found the subarray [1,1] at indices 0-1)
  freq = {0:1, 1:1, 2:1}

i=2, nums[2]=1:
  prefixSum = 2+1 = 3
  complement = 3-2 = 1 → in freq with count 1 → count = 1+1 = 2
  (This found the subarray [1,1] at indices 1-2)
  freq = {0:1, 1:1, 2:1, 3:1}

Final answer: 2 ✓

Why does count += freq.get(complement) potentially add more than 1? If the same prefix sum has appeared three times, there are three different starting positions that would produce a valid subarray ending here. All three are distinct subarrays and all three must be counted. This is why we store frequency counts, not just "seen/not seen."

Handles negatives: Unlike a sliding window approach (which breaks with negatives because shrinking the window does not guarantee the sum decreases), this approach works for any integers. Prefix sums are cumulative — a negative number simply decreases the running sum. The HashMap lookup logic is unaffected.

Complexity: O(n) time, O(n) space for the HashMap.


Problem 5: Find Pivot Index (LeetCode 724)

Problem: Given an array nums, find the leftmost index where the sum of all elements to the left equals the sum of all elements to the right. Return -1 if no such index exists.

code
Input:  [1, 7, 3, 6, 5, 6]
Output: 3
Reason: Left of index 3: 1+7+3 = 11. Right of index 3: 5+6 = 11. Equal.

The elegant insight: At any index i, if we know totalSum and leftSum (sum of elements 0 to i-1), then rightSum = totalSum - leftSum - nums[i]. We just need to check if leftSum == rightSum.

javascript
function pivotIndex(nums) {
  const totalSum = nums.reduce((a, b) => a + b, 0);
  let leftSum = 0;

  for (let i = 0; i < nums.length; i++) {
    const rightSum = totalSum - leftSum - nums[i];
    if (leftSum === rightSum) return i;
    leftSum += nums[i];
  }

  return -1;
}

Dry run on [1, 7, 3, 6, 5, 6]:

code
totalSum = 1+7+3+6+5+6 = 28,  leftSum = 0

i=0: rightSum = 28-0-1 = 27.  leftSum(0) ≠ rightSum(27). leftSum → 1
i=1: rightSum = 28-1-7 = 20.  leftSum(1) ≠ rightSum(20). leftSum → 8
i=2: rightSum = 28-8-3 = 17.  leftSum(8) ≠ rightSum(17). leftSum → 11
i=3: rightSum = 28-11-6 = 11. leftSum(11) = rightSum(11). Return 3 ✓

Complexity: O(n) time for initial sum, O(n) for the scan = O(n) total. O(1) space.


Pattern 4: In-Place Read/Write — The Two-Index Crawl

When the problem says "in-place" or "O(1) extra space" and asks you to filter or rearrange elements, the technique is always the same: one index reads every element, a second index marks where the next "keeper" should be written. The read index advances every step. The write index advances only when it writes.


Problem 6: Remove Duplicates from Sorted Array (LeetCode 26)

Problem: Given an integer array nums sorted in non-decreasing order, remove duplicates in-place such that each unique element appears only once. Return the number of unique elements.

code
Input:  [1, 1, 2, 3, 3, 3, 4]
Output: 4  (and the first 4 elements of nums are [1, 2, 3, 4])

Why sorted matters: In a sorted array, all duplicates are adjacent. You never need to search for a duplicate across the whole array — you only need to compare with the previous element.

javascript
function removeDuplicates(nums) {
  if (nums.length === 0) return 0;

  let writeIndex = 1;  // first element is always unique, start writing from index 1

  for (let readIndex = 1; readIndex < nums.length; readIndex++) {
    // Only write if current element differs from the previous unique element
    if (nums[readIndex] !== nums[writeIndex - 1]) {
      nums[writeIndex] = nums[readIndex];
      writeIndex++;
    }
  }

  return writeIndex;  // number of unique elements
}

Full dry run on [1, 1, 2, 3, 3, 3, 4]:

code
writeIndex = 1

readIndex=1: nums[1]=1, nums[writeIndex-1]=nums[0]=1. Same → skip.
readIndex=2: nums[2]=2, nums[writeIndex-1]=nums[0]=1. Different → nums[1]=2, writeIndex=2.
readIndex=3: nums[3]=3, nums[writeIndex-1]=nums[1]=2. Different → nums[2]=3, writeIndex=3.
readIndex=4: nums[4]=3, nums[writeIndex-1]=nums[2]=3. Same → skip.
readIndex=5: nums[5]=3, nums[writeIndex-1]=nums[2]=3. Same → skip.
readIndex=6: nums[6]=4, nums[writeIndex-1]=nums[2]=3. Different → nums[3]=4, writeIndex=4.

Array: [1, 2, 3, 4, 3, 3, 4]
       ↑ first 4 elements are correct ↑
Return: 4 ✓

Why nums[readIndex] !== nums[writeIndex - 1] and not nums[readIndex] !== nums[readIndex - 1]?

Because writeIndex - 1 points to the last element we confirmed as unique. readIndex - 1 points to the previously read element — which might be a duplicate that we skipped. Comparing against the last written element ensures we are comparing against the true previous unique value.

Complexity: O(n) time, O(1) space.


Pattern 5: Interval Problems — Sort First, Then Sweep

Interval problems look geometric but reduce to array problems after sorting. Once intervals are sorted by their start times, you can process them in a single sweep from left to right, maintaining only the current "active" interval.


Problem 7: Merge Intervals (LeetCode 56)

Problem: Given an array of intervals where intervals[i] = [start, end], merge all overlapping intervals and return an array of the non-overlapping intervals.

code
Input:  [[1,3], [2,6], [8,10], [15,18]]
Output: [[1,6], [8,10], [15,18]]
Reason: [1,3] and [2,6] overlap (3 ≥ 2), so they merge to [1,6].

Q1: What do I have? An array of n intervals. Intervals may be in any order. Intervals may overlap.

Q2: What do I want? An array of merged intervals with no overlaps.

Q3: Brute force? For each interval, check all other intervals for overlap. Merge when overlap found. Repeat until stable. O(n²) or worse.

Q4: Where is the waste?

Overlapping intervals are not random — if we sort by start time, any interval that can merge with the current one must have a start time ≤ the current interval's end. Everything after the end cannot overlap. So the merge decision becomes local.

After sorting, you only ever compare each new interval against the last merged interval. One pass.

javascript
function merge(intervals) {
  // Sort by start time
  intervals.sort((a, b) => a[0] - b[0]);

  const merged = [intervals[0]];  // start with the first interval

  for (let i = 1; i < intervals.length; i++) {
    const current = intervals[i];
    const last = merged[merged.length - 1];  // last merged interval

    if (current[0] <= last[1]) {
      // Overlap: extend the end of the last merged interval if needed
      last[1] = Math.max(last[1], current[i]);
      // BUG NOTE: should be current[1] not current[i] — see dry run below
    } else {
      // No overlap: start a new interval
      merged.push(current);
    }
  }

  return merged;
}

Let me write this correctly:

javascript
function merge(intervals) {
  intervals.sort((a, b) => a[0] - b[0]);

  const merged = [intervals[0]];

  for (let i = 1; i < intervals.length; i++) {
    const [currStart, currEnd] = intervals[i];
    const lastMerged = merged[merged.length - 1];

    if (currStart <= lastMerged[1]) {
      // Overlapping: expand the end if current interval extends further
      lastMerged[1] = Math.max(lastMerged[1], currEnd);
    } else {
      // No overlap: add as a new separate interval
      merged.push([currStart, currEnd]);
    }
  }

  return merged;
}

Full dry run on [[1,3], [2,6], [8,10], [15,18]]:

After sort by start: [[1,3], [2,6], [8,10], [15,18]] (already sorted)

code
merged = [[1,3]]

i=1: current=[2,6], lastMerged=[1,3]
  currStart(2) <= lastMerged[1](3)? YES → overlap
  lastMerged[1] = max(3, 6) = 6
  merged = [[1,6]]

i=2: current=[8,10], lastMerged=[1,6]
  currStart(8) <= lastMerged[1](6)? NO → no overlap
  push [8,10]
  merged = [[1,6], [8,10]]

i=3: current=[15,18], lastMerged=[8,10]
  currStart(15) <= lastMerged[1](10)? NO → no overlap
  push [15,18]
  merged = [[1,6], [8,10], [15,18]] ✓

What does Math.max(lastMerged[1], currEnd) handle? The case where one interval is completely contained inside another. Example: [1,10] and [2,5]. After sorting, [1,10] comes first. When we process [2,5], currStart(2) <= 10, so we try to merge. Math.max(10, 5) = 10 — correctly keeps [1,10]. Without the max, we would incorrectly shrink the interval to [1,5].

Complexity: O(n log n) for sort, O(n) for the sweep = O(n log n) total. O(n) space for output.


Pattern 6: Two Pointers from Ends — Preview

This pattern gets a full chapter (Chapter 5), but you will encounter it in array problems constantly. The idea: place one pointer at the leftmost position and one at the rightmost. Move them toward each other based on a condition. Each move provably eliminates at least one candidate.

This works only when the array has structure (usually sorted, or when the geometry of the problem guarantees what moving a pointer implies).


Problem 8: Trapping Rain Water (LeetCode 42)

This is a hard problem. It appears in interviews at Zepto, Razorpay, Amazon, and Google. It is covered here rather than Chapter 5a because it is the canonical two-pointer array problem and the reasoning is instructive even before the pattern is formalized.

Problem: Given an array height representing an elevation map, compute how much water can be trapped after raining.

code
Input:  [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
Output: 6

Water at any position i is bounded by the shorter of:

  • The tallest wall to its left (including itself)
  • The tallest wall to its right (including itself)

Water at i = min(maxLeft[i], maxRight[i]) - height[i]

If that value is negative (the position itself is taller than one of the walls), it holds 0 water.

Brute force: For each position, scan left and right for the max. O(n²).

Better with prefix arrays: Precompute maxLeft and maxRight arrays. O(n) time, O(n) space.

Two pointer — O(n) time, O(1) space:

The key insight: if maxLeft < maxRight at any point, the water at the left pointer is determined entirely by maxLeft — because even though we do not know the exact right wall, we know it is at least as tall as maxRight, which is already taller than maxLeft. The left side is the binding constraint. Process the left pointer.

javascript
function trap(height) {
  let left = 0, right = height.length - 1;
  let leftMax = 0, rightMax = 0;
  let water = 0;

  while (left < right) {
    if (height[left] <= height[right]) {
      if (height[left] >= leftMax) {
        leftMax = height[left];    // new tallest left wall
      } else {
        water += leftMax - height[left];  // water trapped here
      }
      left++;
    } else {
      if (height[right] >= rightMax) {
        rightMax = height[right];  // new tallest right wall
      } else {
        water += rightMax - height[right];
      }
      right--;
    }
  }

  return water;
}

Dry run on [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]:

code
left=0, right=11, leftMax=0, rightMax=0, water=0

Step 1: h[left]=0, h[right]=1. h[left]<=h[right].
  h[0]=0 >= leftMax(0) → leftMax=0. left++.
  (Position 0: height equals leftMax, no water trapped, but leftMax stays 0)

Step 2: left=1, h[1]=1, h[right=11]=1. h[left]<=h[right].
  h[1]=1 >= leftMax(0) → leftMax=1. left++.

Step 3: left=2, h[2]=0, h[right=11]=1. h[left]<=h[right].
  h[2]=0 < leftMax(1) → water += 1-0 = 1. water=1. left++.
  (Position 2: trapped between wall of height 1 on left and wall ≥ 1 on right)

Step 4: left=3, h[3]=2, h[right=11]=1. h[left]>h[right].
  h[11]=1 >= rightMax(0) → rightMax=1. right--.

Step 5: left=3, h[3]=2, h[right=10]=2. h[left]<=h[right].
  h[3]=2 >= leftMax(1) → leftMax=2. left++.

Step 6: left=4, h[4]=1, h[right=10]=2. h[left]<=h[right].
  h[4]=1 < leftMax(2) → water += 2-1 = 1. water=2. left++.

Step 7: left=5, h[5]=0, h[right=10]=2. h[left]<=h[right].
  h[5]=0 < leftMax(2) → water += 2-0 = 2. water=4. left++.

Step 8: left=6, h[6]=1, h[right=10]=2. h[left]<=h[right].
  h[6]=1 < leftMax(2) → water += 2-1 = 1. water=5. left++.

Step 9: left=7, h[7]=3, h[right=10]=2. h[left]>h[right].
  h[10]=2 >= rightMax(1) → rightMax=2. right--.

Step 10: left=7, h[7]=3, h[right=9]=1. h[left]>h[right].
  h[9]=1 < rightMax(2) → water += 2-1 = 1. water=6. right--.

Step 11: left=7, right=8. h[7]=3, h[8]=2. h[left]>h[right].
  h[8]=2 >= rightMax(2) → rightMax=2. right--.

Step 12: left=7, right=7. left is not < right. Loop ends.

Final answer: 6 ✓

Why does processing the side with the smaller height guarantee correctness?

When height[left] <= height[right]: the water at position left is min(leftMax, rightMax_actual) - height[left]. We know height[right] >= height[left], and we know rightMax_actual >= height[right]. Therefore rightMax_actual >= height[right] >= height[left]. This means rightMax_actual >= leftMax is NOT guaranteed — but here is the critical point: if leftMax <= rightMax_actual, then min(leftMax, rightMax_actual) = leftMax, and we can compute water exactly. If leftMax > rightMax_actual... that contradicts nothing about what we assumed (rightMax_actual >= height[left]), but in that case we would have moved the right pointer in a different step. The algorithm self-corrects.

The proof is by induction on pointer positions, but the intuition is: we always process the side we can compute water for with certainty, and we only move past positions where we have definitively computed the answer.

Complexity: O(n) time, O(1) space. Each element is processed exactly once.


Pattern Recognition: Putting It All Together

Here is the decision process to run when you see an array problem:

code
Read the problem. Identify the signal:

1. "maximum sum / minimum sum of contiguous subarray"
   → Kadane's. Maintain local and global simultaneously.

2. "buy/sell stock", "best time to...", any "running min/max while scanning"
   → Kadane's variant. Track a running best alongside a running value.

3. "product/sum of everything except self"
   → Two-pass prefix (left pass then right pass with a single variable).

4. "count subarrays with sum = k" / "sum of range queries"
   → Prefix sum. If counting, add a HashMap.

5. "sorted array, remove duplicates, remove element, filter in-place"
   → Two-index read/write. readIndex visits all, writeIndex advances on keepers.

6. "merge/count overlapping intervals"
   → Sort by start, sweep with one variable tracking current end.

7. "sorted array, find pair/triplet summing to target"
   → Two pointers from ends (Chapter 5).

8. "longest/shortest contiguous subarray with constraint"
   → Sliding window (Chapter 6).

Vocabulary Map

TermWhat it physically is
Kadane's algorithmA one-pass algorithm that maintains two values simultaneously: the best subarray sum ending at the current position, and the best seen globally. Named after Jay Kadane.
Running minimum/maximumA variable updated at each step to hold the minimum or maximum of all elements seen so far. Used to avoid scanning backward.
Prefix productSame idea as prefix sum but with multiplication. prefix[i] = product of all elements before index i. Requires handling zeros carefully (division breaks).
Left pass / right passTwo O(n) scans in opposite directions. Left pass builds a prefix array. Right pass folds in suffix values using a single running variable. Gives O(1) space where prefix + suffix arrays would use O(n).
Complement lookupIn prefix sum + HashMap problems: for a current prefix sum p, the complement is p - k. The number of times p - k appeared as a previous prefix sum equals the number of valid subarrays ending at the current position.
Sentinel valueAn initial entry added to a data structure before processing begins, to handle edge cases uniformly. In subarray sum = k: {0: 1} handles subarrays starting from index 0 without a special case.
Read index / write indexTwo integer variables used in in-place array modification. The read index advances every iteration; the write index advances only when an element is written. Produces a filtered or deduplicated array in O(n) time, O(1) space.
IntervalA pair [start, end] representing a range. Intervals "overlap" when one starts before the other ends: interval1[1] >= interval2[0] (when sorted by start).
Sort-then-sweepThe technique of sorting an array (usually O(n log n)) to create local structure, then making a single O(n) pass that exploits that structure. Used in interval problems, 3Sum, and many greedy algorithms.
Two pointers from endsA technique where left=0 and right=n-1 start at opposite ends and converge. Each move provably eliminates at least one candidate from consideration, giving O(n) overall. Requires structure (sorting or geometry) to guarantee correctness.
Elevation mapThe visualization used in Trapping Rain Water: each array element represents the height of a bar. Water fills valleys between taller bars.

Practice Problems

Solve all five on paper. No code until you have the full reasoning.


Problem 1 — Trace and Predict

Run Kadane's algorithm on this input by hand:

code
nums = [5, -4, 8, -10, 2, 3, -2, 6, -1]

For each index, write the exact values of currentSum and globalMax after processing that element. Show the decision at each step: did you start fresh or extend? State the final answer and identify which subarray it corresponds to.

Then answer: this array has two "competitive" subarrays. Which one does Kadane's pick, and why does it always pick the correct one even though it never explicitly tracks subarray boundaries?


Problem 2 — Find the Waste

A developer implements "maximum profit from multiple buy/sell transactions" (you can buy and sell as many times as you want, but you can only hold one share at a time):

javascript
function maxProfitMultiple(prices) {
  let maxProfit = 0;
  for (let i = 0; i < prices.length; i++) {
    for (let j = i + 1; j < prices.length; j++) {
      if (prices[j] > prices[i]) {
        maxProfit += prices[j] - prices[i];
        i = j;  // "move i forward to j after selling"
        break;
      }
    }
  }
  return maxProfit;
}

Part A: Run this on [7, 1, 5, 3, 6, 4]. What does it return? Is the answer correct?

Part B: Find an input where this function returns the wrong answer. Explain precisely why it fails.

Part C: The optimal insight for multiple transactions is: if prices[i+1] > prices[i], you should capture that profit. The sum of all positive day-to-day differences equals the maximum profit. Implement this in O(n) time and O(1) space, and prove it gives the same result as the optimal buy-sell strategy.


Problem 3 — Failure Analysis

A developer writes a function to count subarrays with sum equal to k, using a sliding window instead of prefix sum + HashMap:

javascript
function subarraySumSliding(nums, k) {
  let left = 0;
  let sum = 0;
  let count = 0;

  for (let right = 0; right < nums.length; right++) {
    sum += nums[right];

    while (sum > k && left <= right) {
      sum -= nums[left];
      left++;
    }

    if (sum === k) count++;
  }

  return count;
}

Part A: Run this on nums = [1, 1, 1], k = 2. Does it produce the correct answer?

Part B: Run this on nums = [1, -1, 1], k = 1. Walk through each step. What does it return? What is the correct answer? Why does the sliding window approach fail here?

Part C: The root cause is a fundamental limitation of sliding window. State it precisely. What property must the array satisfy for a sliding window to correctly count subarrays with a fixed sum? Why does the prefix sum + HashMap approach not share this limitation?


Problem 4 — Design Under Constraints

You are building the analytics backend for AttemptIQ. For every user, you need to answer these queries in real-time:

  • "How many questions did User X attempt in the date range [day_a, day_b]?"
  • "What was User X's accuracy (% correct) in date range [day_a, day_b]?"

The dates are stored as integers (day number since the user joined). Users can have up to 365 days of data.

Constraints:

  • Each query must run in O(1) time
  • You can precompute whatever you want when a new day's data arrives (O(n) preprocessing per update is acceptable)
  • Memory per user must be O(n) where n = number of days the user has been active

Your task: Design the data structure(s) for a single user. State:

  1. Exactly what arrays/objects you maintain.
  2. The preprocessing step when a new day's attempts are recorded.
  3. The algorithm for answering each query type in O(1).
  4. Why accuracy requires two separate prefix arrays rather than one.
  5. What breaks if the user has days with zero attempts — and how you handle it.

Problem 5 — Reach Beyond

This problem connects Chapter 2a to Chapter 1's complexity framework and asks you to reason about a problem class.

You have seen that:

  • Kadane's algorithm converts O(n²) subarray enumeration into O(n) by maintaining running state
  • Prefix sum + HashMap converts O(n²) subarray counting into O(n) by turning lookups into O(1) hash operations
  • Two pointers converts O(n²) pair search into O(n) by exploiting sorted structure

All three techniques reduce an O(n²) problem to O(n). But not all O(n²) array problems can be reduced to O(n).

Part A: Argue why "find the longest subarray with at most K distinct elements" can be solved in O(n) but "find the longest subarray with exactly K distinct elements" seems harder at first glance. (Hint: think about what a sliding window can naturally track.)

Part B: Here is a trick: exactly(K) = atMost(K) - atMost(K-1). This converts an "exact" constraint into two "at most" constraints. If atMost(K) is solvable in O(n), then exactly(K) is also O(n) via two passes. This is the same decomposition used in "Count Subarrays with Bounded Maximum" from the research. Can you apply this trick to: "count subarrays where the number of odd numbers is exactly K"? Write the atMost(k) function for this specific problem and verify it on nums = [1, 1, 2, 1, 1], k = 3.

Part C: Reflect on the pattern: "if you can solve atMost(k) in O(n), you can solve exactly(k) in O(n) by subtraction." Under what conditions does this decomposition work? What property of atMost must hold for the subtraction atMost(K) - atMost(K-1) to give the correct count of subarrays with exactly K of something? (Think about what it means for a subarray to be "counted" in atMost(K) but not in atMost(K-1).)


What's Coming Next

Every problem in this chapter required looking up whether a value had been seen before — and right now, doing that lookup requires either scanning the array (O(n)) or pre-sorting it (O(n log n)). In Problem 4 of the Subarray Sum section, we sneaked in a Map to make that lookup O(1). That Map is a HashMap — the data structure that eliminates search cost entirely by turning any key into a direct index via a mathematical function. Chapter 3 builds a HashMap from scratch: the hash function, collision handling, why the average case is O(1) but the worst case is O(n), and how JavaScript's Map and {} differ under the hood. Once you understand it deeply, you will see why 40% of medium array problems that feel impossible become trivial the moment you ask "can a HashMap hold the state I need?"


Sources for problem selection: real interview reports from Zepto, Razorpay, Walmart, and Amazon India (2024–2026); LeetCode company-tagged problem frequency data; Neetcode blind 75 / 150 array section; interview pattern analysis from Sliding Window, Two Pointers, and Prefix Sums — Viewport.