Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Beginner/B3
3 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣB3 · Part 1 · Beginner

Arrays & strings

The two data structures every other pattern is built on top of.

31 min read4 diagrams18 sections32 exercises
3 of 48

In this chapter

  • What "beginner" means for this chapter
  • An array is a promise about memory
  • What's actually O(1) vs O(n) on an array
  • Strings are arrays with one extra rule
  • Characters as numbers: charCodeAt and 26-slot counts

and 13 more

about 31 min
18On this page
  1. What "beginner" means for this chapter
  2. An array is a promise about memory
  3. What's actually O(1) vs O(n) on an array
  4. Strings are arrays with one extra rule
  5. Characters as numbers: charCodeAt and 26-slot counts
  6. Strings are UTF-16: length, code points and reversing
  7. Parsing numbers out of strings: atoi and Roman numerals
  8. The in-place pattern
  9. Merging from the back: in place, without overwriting
  10. Prefix sums — turn O(n) range queries into O(1)
  11. Kadane's algorithm — the maximum subarray, in one pass
  12. Kadane's variants: the indices, and the circular array
  13. Prefix and suffix products, and the running minimum
  14. Boyer-Moore voting: the majority element
  15. Rotating an array in O(1) space — the triple-reversal trick
  16. The array as its own hash table: marking and cyclic placement
  17. 2D arrays — same rules, one more dimension
  18. Common gotchas worth knowing cold

What "beginner" means for this chapter

If arrays and strings already feel completely automatic to you, skim this one — but don't skip it. The interview traps in this chapter (accidental O(n²) string building, unshift's hidden cost, the shared-reference 2D array bug) are some of the most common ways strong candidates lose points on otherwise-correct solutions.

An array is a promise about memory

A JS array is really a resizable list, but the mental model interviewers expect comes from the lower-level version: a contiguous block of memory where index math replaces searching. Because every slot is the same fixed size apart, the address of index i is just base + i × size — no walking, no scanning. That's the whole reason arr[i] is O(1): it's arithmetic, not a lookup.

7 3 9 1 5 [0] [1] [2] [3] [4] arr[3] = base + 3×size no scanning — direct math
Index access is a formula, not a search — that's the entire source of O(1).

What's actually O(1) vs O(n) on an array

OperationComplexityWhy
Read/write by indexO(1)direct address math
Push/pop at the endO(1) amortizedno shifting needed
Shift/unshift at the startO(n)every other element moves over
splice() in the middleO(n)everything after the cut shifts
Search by value (indexOf, includes)O(n)no shortcut — has to walk it
BEFORE — arr = [7, 3, 9], 3 elements at indices 0, 1, 2 7 3 9 [0] [1] [2] each one moves one slot right — O(n) total AFTER — unshift(x) fills the now-empty index 0 x 7 3 9 [0] [1] [2] [3] 7 is now at [1] not [0], 3 is now at [2] not [1], 9 is now at [3] not [2]
Every element's INDEX changes, which means every element's underlying memory slot changes too — that's the O(n) work, done before x even gets placed at [0].
⚠ The interview trap arr.unshift(x) and arr.shift() feel like O(1) because they're one method call — they're not. Every remaining element has to physically move one slot over. If you reach for these inside a loop, you've likely turned an O(n) solution into O(n²) by accident.

Strings are arrays with one extra rule

In JS, strings are immutable — str[0] = "x" silently does nothing. Every "mutation" (slice, +, replace) actually builds a brand new string. That has a real cost: repeatedly concatenating inside a loop can be O(n) per concatenation, so a naive loop that builds a string character by character can be O(n²) in the worst case. Modern engines often optimise this, but use an array and join when it matters.

// can be O(n²) in the worst case — each += may copy a string of growing length
function buildSlow(chars) {
  let out = "";
  for (const c of chars) out += c;
  return out;
}

// O(n) — push to an array (O(1) amortized), join once at the end
function buildFast(chars) {
  const parts = [];
  for (const c of chars) parts.push(c);
  return parts.join("");
}

Characters as numbers: charCodeAt and 26-slot counts

A character is a small integer in disguise. "a".charCodeAt(0) is 97, "b" is 98, and so on up to "z" at 122; the digits "0" to "9" are 48 to 57. Subtracting the code of "a" turns a lowercase letter into an index from 0 to 25, and that makes a 26-slot counting array possible: a frequency table with no hashing at all, whose lookup is plain index arithmetic.

function letterCounts(s) {
  const counts = new Array(26).fill(0);
  for (let i = 0; i < s.length; i++) {
    counts[s.charCodeAt(i) - 97]++;
  }
  return counts;
}

function isAnagramLowercase(a, b) {
  if (a.length !== b.length) return false;
  const counts = new Int32Array(26);
  for (let i = 0; i < a.length; i++) {
    counts[a.charCodeAt(i) - 97]++;
    counts[b.charCodeAt(i) - 97]--;
  }
  return counts.every((c) => c === 0);
}

The anagram check adds one for each letter of a and subtracts one for each letter of b, in the same loop. If the two words use the same letters the same number of times, every slot returns to 0. For "listen" and "silent" the letters e, i, l, n, s, t each get +1 from one word and −1 from the other; for "listen" and "listed", slot n ends at +1 and slot d at −1, so the check fails. Time O(n), space O(1): the table has 26 slots whatever n is.

⚠ The table silently trusts the alphabet Feed an uppercase letter to isAnagramLowercase and "A".charCodeAt(0) - 97 is −32. A typed array ignores writes to an index it does not have, so nothing fails: isAnagramLowercase("A", "B") returns true. With a plain array the stray write creates a property instead, with the same outcome. Before using the trick, check the alphabet in the problem (lowercase English letters? ASCII?) and say so out loud. When the input may be anything, use a Map, or a 128-slot table indexed by the code itself for ASCII.

The same arithmetic converts a digit character to its value (c.charCodeAt(0) - 48), a letter index back to a letter (String.fromCharCode(97 + i)), and tests a range (c >= "a" && c <= "z", which compares code units). It shows up in anagram and ransom-note checks, in "find the difference" problems, and in sliding windows over lowercase text.

Strings are UTF-16: length, code points and reversing

A JavaScript string is a sequence of 16-bit code units, and length, indexing and charCodeAt all count code units. Most characters you type are one unit. Characters outside the Basic Multilingual Plane, including most emoji, take two units, a surrogate pair. The iteration protocol walks by code points instead, which gives the two views different answers:

ExpressionResult for "😀"What it counts
"😀".length2UTF-16 code units
[..."😀"].length1code points (spread and for...of iterate by code point)
"😀".charCodeAt(0)55357the first half of the pair, a lone surrogate
"😀".codePointAt(0)128512the whole character, read from the pair

The bug appears the moment you reverse text by code unit. Splitting "ab😀" into units and reversing puts the two halves of the emoji in the wrong order, and the result is no longer valid text. Reversing by code point keeps each character whole:

function reverseByCodePoints(s) {
  return [...s].reverse().join("");
}

function countCodePoints(s) {
  let count = 0;
  for (let i = 0; i < s.length; i++) {
    count++;
    if (s.codePointAt(i) > 0xffff) i++;
  }
  return count;
}

"ab😀".split("").reverse().join("") breaks the emoji, while reverseByCodePoints("ab😀") returns "😀ba". countCodePoints shows the mechanics by hand: a code point above 0xFFFF occupies two units, so skip one extra index when you meet it.

⚠ Code points are still not "characters" A user-perceived character can be several code points. The family emoji "👨‍👩‍👧" is 8 code units and 5 code points (three people joined by invisible joiners) but one thing on screen, and "\u00e9" (the letter e with an accent) can be one code point or "e" plus a combining accent (2 units), so "\u00e9" === "e\u0301" is false until you call .normalize() on both. For text that people read, Intl.Segmenter splits into those visible units. In an interview, state the assumption ("ASCII or lowercase letters") and keep the solution simple; knowing why length can surprise you is the part that earns credit.

One more ordering gotcha belongs here: < on strings compares code units, not the dictionary order a human expects, so "B" < "a" is true and "a" < "B" is false. For human sorting use a.localeCompare(b).

Parsing numbers out of strings: atoi and Roman numerals

Parsing problems test whether you can turn a messy specification into a small state machine without losing a case. Two classics cover the habits.

String to integer (atoi). The rules: skip leading spaces; read an optional + or -; read digits until the first non-digit; ignore the rest; clamp the result to the signed 32-bit range [−2³¹, 2³¹ − 1]. Each rule becomes one block of code, in this order:

function myAtoi(s) {
  const MAX = 2 ** 31 - 1;
  const MIN = -(2 ** 31);
  let i = 0;
  while (i < s.length && s[i] === " ") i++;
  let sign = 1;
  if (s[i] === "-" || s[i] === "+") {
    if (s[i] === "-") sign = -1;
    i++;
  }
  let value = 0;
  while (i < s.length && s[i] >= "0" && s[i] <= "9") {
    value = value * 10 + (s.charCodeAt(i) - 48);
    if (value > MAX + 1) break;
    i++;
  }
  const result = sign * value;
  if (result > MAX) return MAX;
  if (result < MIN) return MIN;
  return result === 0 ? 0 : result;
}

Dry run: myAtoi(" -042abc")

stepicharacteractionvalue
skip spaces0 to 2" "advance0
sign3"-"sign = −1, advance0
digit4"0"0 × 10 + 00
digit5"4"0 × 10 + 44
digit6"2"4 × 10 + 242
stop7"a"not a digit, leave the loop42

The result is −42. Three details matter. The early break keeps value from growing without bound on a very long digit string, and the clamp afterwards still handles it. sign * value is -0 for the input "-0", so the last line turns it back into 0. And the built-in parseInt is not a substitute: it accepts a hex prefix (parseInt("0x1F") is 31), does not clamp, and gives NaN where atoi gives 0.

Roman numerals. A symbol worth less than the one after it is subtracted (IV = 4, XC = 90); otherwise it is added. That is the whole rule for reading them:

function romanToInt(s) {
  const value = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
  let total = 0;
  for (let i = 0; i < s.length; i++) {
    const current = value[s[i]];
    const next = value[s[i + 1]];
    total += next > current ? -current : current;
  }
  return total;
}

Dry run: romanToInt("MCMXCIV")

symbolvaluenext is bigger?total after
M1000no (C = 100)1000
C100yes (M = 1000)900
M1000no (X = 10)1900
X10yes (C = 100)1890
C100no (I = 1)1990
I1yes (V = 5)1989
V5no (end of string)1994

At the last symbol s[i + 1] does not exist, so next is undefined, and undefined > 5 is false: the final symbol is added with no special case.

The reverse direction is a greedy loop over a table that lists the subtractive forms as entries of their own, largest first. Because 900 and 400 and 90 appear in the table, the greedy choice never has to think about subtraction.

function intToRoman(num) {
  const table = [
    [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"],
    [100, "C"], [90, "XC"], [50, "L"], [40, "XL"],
    [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
  ];
  let out = "";
  for (const [amount, symbol] of table) {
    while (num >= amount) {
      out += symbol;
      num -= amount;
    }
  }
  return out;
}

intToRoman(1994) takes M (leaving 994), CM (94), XC (4) and IV, giving "MCMXCIV", the string the previous trace read back. The standard numerals cover 1 to 3,999; for 4,000 the loop would write "MMMM", which is not standard form, so the problem's range limit matters. intToRoman does a bounded amount of work, since no number from 1 to 3,999 needs more than 15 symbols, and romanToInt is O(n) in the length of the numeral.

⚠ Parsing pitfalls, all one line each Check the spec for what counts as whitespace (only " " here). Test the empty string, a sign with no digits ("-"), digits after a space ("4 3" reads as 4), and a number past the limit. Do the clamp after applying the sign, and watch for -0.

The in-place pattern

A huge share of array interview questions ask for O(1) extra space, which means mutating the input instead of allocating a new array. The standard tool is read and write pointers: walk with two indices, overwrite in place, and treat everything past the write pointer as garbage.

// remove all occurrences of val, in place, return new length
function removeElement(nums, val) {
  let write = 0;
  for (let read = 0; read < nums.length; read++) {
    if (nums[read] !== val) {
      nums[write] = nums[read];
      write++;
    }
  }
  return write; // [0, write) is the real answer
}

This "read pointer scans everything, write pointer only advances on a keep" shape reappears constantly — it's the seed of the two-pointers chapter next.

Merging from the back: in place, without overwriting

You are given two sorted arrays. The first, nums1, has room for everything: its first m slots hold real values and the last n slots are placeholder zeros. The second, nums2, holds n values. Merge them into nums1, sorted, without a third array.

The obvious plan is to merge from the front, taking the smaller head each time. It fails for a concrete reason: when nums2[0] is smaller than nums1[0], it has to be written to index 0, and that slot still holds an unread value of nums1. You would have to shift the whole array right first, which costs O(m) per insert and O(m · n) overall.

The fix is to change the direction. The largest value of the merged result belongs in the last slot, and the last slot is free. So fill the array from the back, always writing the larger of the two current tails.

function mergeSorted(nums1, m, nums2, n) {
  let i = m - 1;
  let j = n - 1;
  let write = m + n - 1;
  while (j >= 0) {
    if (i >= 0 && nums1[i] > nums2[j]) {
      nums1[write] = nums1[i];
      i--;
    } else {
      nums1[write] = nums2[j];
      j--;
    }
    write--;
  }
}

Why it is safe: at every moment the free slots are exactly the ones between the unread part of nums1 and the written tail, and there are j + 1 of them, the number of values still waiting in nums2. The write pointer can therefore never land on an unread value of nums1. Why the loop can stop when j runs out: whatever is left of nums1 is already sorted and already in its final position.

Dry run: nums1 = [1, 3, 5, 0, 0, 0], m = 3, nums2 = [2, 4, 6], n = 3

writecomparevalue writtennums1 afterwards
5nums1[2] = 5 vs nums2[2] = 66, from nums2[1, 3, 5, 0, 0, 6]
4nums1[2] = 5 vs nums2[1] = 45, from nums1[1, 3, 5, 0, 5, 6]
3nums1[1] = 3 vs nums2[1] = 44, from nums2[1, 3, 5, 4, 5, 6]
2nums1[1] = 3 vs nums2[0] = 23, from nums1[1, 3, 3, 4, 5, 6]
1nums1[0] = 1 vs nums2[0] = 22, from nums2[1, 2, 3, 4, 5, 6]

The loop ran five times and stopped with nums1[0] = 1 untouched, which is exactly where it belongs. The time is O(m + n), since every step writes one slot and moves one pointer, and the extra space is O(1). The other order of events is the instructive one: for nums1 = [4, 5, 6, 0, 0, 0] and nums2 = [1, 2, 3] every value of nums1 is copied to the back first, and only then do the three values of nums2 fill the front.

⚠ Three ways this goes wrong Looping while either pointer is alive (i >= 0 || j >= 0) forces you to handle both leftovers, and the nums1 leftover is a pointless self-copy. Stop on j >= 0. Using nums1.length in place of m mixes the placeholder zeros into the data. Writing >= instead of > still sorts correctly, but with > the equal value of nums1 is placed before the equal value of nums2, so the merge stays stable.

Recognise it: two sorted sequences, spare room at the end of one, and a request for no extra array. The same direction change solves "replace every space with %20 in place" (count the spaces, then fill from the back) and any problem where writing at the front would trample unread data.

Prefix sums — turn O(n) range queries into O(1)

If you're going to ask "what's the sum of elements from index i to j?" more than once on the same array, recomputing each sum by scanning is wasteful. Precompute a running total once — O(n) — and every range sum after that is a subtraction, O(1).

3 1 4 1 5 original array 3 4 8 9 14 sum(1..4) = prefix[4] − prefix[0] = 14 − 3 = 11 green cell minus red cell; prefix[i] = sum of everything up to and including index i
One O(n) pass builds the prefix array; every range sum after that is O(1) — a single subtraction.
function buildPrefixSums(nums) {
  if (nums.length === 0) return [];
  const prefix = new Array(nums.length);
  prefix[0] = nums[0];
  for (let i = 1; i < nums.length; i++) {
    prefix[i] = prefix[i - 1] + nums[i];
  }
  return prefix;
}

// sum of nums[left..right] inclusive, O(1) after O(n) preprocessing
function rangeSum(prefix, left, right) {
  return left === 0 ? prefix[right] : prefix[right] - prefix[left - 1];
}

This is the single highest-leverage array trick for "answer many range queries" problems — it turns what looks like it needs O(n) per query (O(n·q) for q queries) into O(n) total preprocessing plus O(1) per query. The same idea extends to 2D (a prefix-sum matrix for rectangle sums) and to counting problems (prefix counts of a condition).

This section is the short version. Subarray counting with a map of prefix sums, difference arrays, 2D prefix sums and the off-by-one traps get a chapter of their own: Prefix sums. Below, the same "precompute a running aggregate" idea shows up again with products and minimums.

Kadane's algorithm — the maximum subarray, in one pass

"Find the contiguous subarray with the largest sum" looks like it needs checking every subarray — O(n²). Kadane's insight: at each position, the best subarray ending here is either "extend the previous best" or "start fresh from here" — whichever is bigger — because a negative running sum can only ever hurt what comes after it.

function maxSubArray(nums) {
  let bestSoFar = nums[0];
  let bestEndingHere = nums[0];

  for (let i = 1; i < nums.length; i++) {
    bestEndingHere = Math.max(nums[i], bestEndingHere + nums[i]);
    bestSoFar = Math.max(bestSoFar, bestEndingHere);
  }
  return bestSoFar;
}

Dry run: maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4])

inums[i]bestEndingHerebestSoFar
0-2-2-2
11max(1, -2+1)=11
2-3max(-3, 1-3)=-21
34max(4, -2+4)=44
4-1max(-1, 4-1)=34
52max(2, 3+2)=55
61max(1, 5+1)=66
7-5max(-5, 6-5)=16
84max(4, 1+4)=56

The interesting row is i=3: bestEndingHere resets to nums[3] alone (4) instead of extending the still-negative running sum (-2 + 4 = 2). Dropping a negative prefix entirely, rather than dragging it forward, is exactly what lets one O(n) pass replace checking every possible subarray.

Kadane's variants: the indices, and the circular array

Interviewers rarely stop at "return the largest sum". Two follow-ups come up again and again, and both are small changes to the same loop.

Return the subarray, not just its sum. You need to know where the best run started. The run restarts exactly when the sum so far has become a burden, that is, when it is negative; so remember the index at which the current run began and copy it out whenever a new best appears.

function maxSubArrayWithRange(nums) {
  let best = nums[0], bestStart = 0, bestEnd = 0;
  let current = nums[0], start = 0;
  for (let i = 1; i < nums.length; i++) {
    if (current < 0) {
      current = nums[i];
      start = i;
    } else {
      current += nums[i];
    }
    if (current > best) {
      best = current;
      bestStart = start;
      bestEnd = i;
    }
  }
  return { sum: best, start: bestStart, end: bestEnd };
}

This is the same decision as Math.max(nums[i], current + nums[i]): extending is better exactly when current is positive. On the chapter's example [-2, 1, -3, 4, -1, 2, 1, -5, 4] the run restarts at index 1 (because −2 is negative), again at index 3 (because 1 − 3 = −2 is negative), and the best moves to 6 at index 6, so the answer is { sum: 6, start: 3, end: 6 }, the subarray [4, -1, 2, 1]. With ties the strict > keeps the earliest best.

The array is circular. Now the best subarray may wrap around from the end back to the start. There are two shapes the best answer can take: an ordinary run in the middle (plain Kadane finds it), or a run that wraps. A wrapping run is the whole array minus an ordinary run in the middle, so to make the wrap as large as possible you subtract the smallest ordinary run. Run Kadane twice, once for the maximum and once for the minimum, and take the better of the two shapes.

function maxSubarrayCircular(nums) {
  let total = 0;
  let curMax = 0, bestMax = -Infinity;
  let curMin = 0, bestMin = Infinity;
  for (const x of nums) {
    total += x;
    curMax = Math.max(x, curMax + x);
    bestMax = Math.max(bestMax, curMax);
    curMin = Math.min(x, curMin + x);
    bestMin = Math.min(bestMin, curMin);
  }
  return bestMax > 0 ? Math.max(bestMax, total - bestMin) : bestMax;
}

Dry run: maxSubarrayCircular([5, -3, 5])

xcurMaxbestMaxcurMinbestMintotal
555555
−325−3−32
5772−37

bestMax is 7 (the whole array). The wrapping option is total − bestMin = 7 − (−3) = 10, which is the last 5 followed by the first 5, with the −3 in the middle skipped. The answer is 10.

⚠ The all-negative case breaks the formula For [-3, -2, -3] the minimum run is the whole array, so total − bestMin is 0: the "wrapping" run is empty, and 0 beats the true answer, −2. That is why the code only trusts the wrap when bestMax > 0. If some element is positive then bestMax is positive, and an "empty wrap" worth 0 can never beat it, so the formula is safe even in the cases where the whole array is the minimum run. When no element is positive, the best answer is the largest single element (a run never gets larger as it grows), which is what bestMax already holds.

Prefix and suffix products, and the running minimum

The prefix-sum idea works for any operation where you can build the answer for a range from the answers for its pieces. Two problems show the pattern with different operations.

Product of the array except self. For each index, return the product of all the other elements. The tempting answer divides the total product by nums[i]. It breaks on zeros: one zero makes the total 0 and every quotient 0 (or NaN when you divide 0 by 0), while the right answer has a non-zero entry at the zero's own position. And the problem usually forbids division anyway.

Think of what the answer at index i is made of: the product of everything to its left times the product of everything to its right. Each of those is a running product, one growing left to right and one growing right to left. You do not need two extra arrays: write the left products into the output, then sweep back from the right keeping a single running variable.

function productExceptSelf(nums) {
  const n = nums.length;
  const out = new Array(n).fill(1);
  let left = 1;
  for (let i = 0; i < n; i++) {
    out[i] = left;
    left *= nums[i];
  }
  let right = 1;
  for (let i = n - 1; i >= 0; i--) {
    out[i] *= right;
    right *= nums[i];
  }
  return out;
}

Dry run: productExceptSelf([1, 2, 3, 4])

passirunning value usedout afterwards
left to right0left = 1[1, 1, 1, 1]
left to right1left = 1[1, 1, 1, 1]
left to right2left = 2[1, 1, 2, 1]
left to right3left = 6[1, 1, 2, 6]
right to left3right = 1[1, 1, 2, 6]
right to left2right = 4[1, 1, 8, 6]
right to left1right = 12[1, 12, 8, 6]
right to left0right = 24[24, 12, 8, 6]

Time O(n), and O(1) extra space not counting the output array, which the problem requires anyway. The same two-sweep shape computes "sum of all others", "gcd of all others" or "max of all others", because it needs nothing from the operation except that it combines pieces.

⚠ Negative zero 0 * -1 is -0 in JavaScript, so productExceptSelf([1, 0, -1]) returns [-0, -1, 0]. It satisfies === 0 and JSON.stringify prints it as 0, but Object.is(-0, 0) is false, and a test helper built on Object.is will call your correct answer wrong. If you see it, normalise with + 0 on the output (-0 + 0 is 0).

Best time to buy and sell once. Given daily prices, find the largest prices[sell] − prices[buy] with buy < sell. Fix the selling day. The best buying day for it is the cheapest day before it, and "cheapest so far" is a running minimum, a prefix aggregate built as you go. So one pass is enough: at each price, first compare against the cheapest earlier price, then update the cheapest.

function maxProfit(prices) {
  let cheapest = Infinity;
  let best = 0;
  for (const price of prices) {
    best = Math.max(best, price - cheapest);
    cheapest = Math.min(cheapest, price);
  }
  return best;
}

Dry run: maxProfit([7, 1, 5, 3, 6, 4])

priceprice − cheapest (before update)bestcheapest after
77 − ∞ = −∞07
11 − 7 = −601
55 − 1 = 441
33 − 1 = 241
66 − 1 = 551
44 − 1 = 351

The answer is 5: buy at 1, sell at 6. Starting best at 0 (not at -Infinity) is the "do nothing" option, which is why a falling market returns 0 instead of a negative number. The sell can never precede the buy, because cheapest only ever holds prices from earlier days. Several transactions, a cooldown, or a fee are the same idea with state, and live in DP state machines.

Boyer-Moore voting: the majority element

A majority element appears strictly more than n/2 times. The first answers are a hash map of counts (O(n) time, O(n) space) and sorting then reading the middle element (O(n log n) time, because more than half the array is one value, so it must sit at the middle index). The vote gets the same answer in O(n) time and O(1) space, and the reason it works is worth deriving rather than memorizing.

Take any two different values and cross them out of the array together. If a majority value exists, each crossed-out pair contains at most one copy of it. So after removing the pair, the majority value still makes up more than half of what remains. Repeat until only copies of one value are left: that value is the majority. The algorithm does the crossing out on the fly, using one remembered value and a count of how many unmatched copies of it are waiting.

function majorityElement(nums) {
  let candidate = null;
  let count = 0;
  for (const x of nums) {
    if (count === 0) candidate = x;
    count += x === candidate ? 1 : -1;
  }
  return candidate;
}

Dry run: majorityElement([2, 2, 1, 1, 1, 2, 2])

xcandidatecountwhat happened
221count was 0, so adopt 2
222same as candidate
121a 1 cancels one 2
120a 1 cancels the other 2
111count was 0, so adopt 1
210a 2 cancels the 1
221count was 0, so adopt 2

The candidate flips twice and ends as 2, which does appear 4 times out of 7. The candidate along the way is not the answer so far; it is only the survivor of the cancellations, and the true majority is guaranteed to be the survivor at the end.

⚠ The guarantee is part of the algorithm The vote only proves "if a majority exists, it is the candidate". It says nothing when none exists: for [1, 2, 3] it returns 3, which appears once in three. When the problem does not promise a majority, add a second pass that counts the candidate and returns it only if the count exceeds n/2. That second pass is also O(n) and still O(1) space.

More than n/3. The same cancellation works with groups of three distinct values instead of pairs. At most two values can appear more than n/3 times, so keep two candidates and two counts. A value that matches neither candidate and finds both counts above zero cancels one from each. Because this variant never promises that its candidates are real, the verification pass is not optional here.

function majorityThird(nums) {
  let a = null, b = null, countA = 0, countB = 0;
  for (const x of nums) {
    if (x === a) countA++;
    else if (x === b) countB++;
    else if (countA === 0) { a = x; countA = 1; }
    else if (countB === 0) { b = x; countB = 1; }
    else { countA--; countB--; }
  }
  countA = 0;
  countB = 0;
  for (const x of nums) {
    if (x === a) countA++;
    else if (x === b) countB++;
  }
  const out = [];
  if (countA > nums.length / 3) out.push(a);
  if (countB > nums.length / 3) out.push(b);
  return out;
}

Recognise it: "more than half" or "more than a third", plus a request for O(1) space. If space is not constrained, a Map of counts is simpler and no worse to explain; the vote is the answer to the follow-up "can you do it without extra memory?".

Rotating an array in O(1) space — the triple-reversal trick

Rotating right by k with a new array is easy but O(n) space. The in-place version uses a neat property: reversing the whole array, then reversing each of the two pieces that should end up in the "wrong" order, produces a correct rotation.

function rotate(nums, k) {
  const n = nums.length;
  if (n === 0) return;
  k = ((k % n) + n) % n; // also maps a negative k to the equivalent right rotation
  reverse(nums, 0, nums.length - 1);  // reverse everything
  reverse(nums, 0, k - 1);            // un-reverse the first k
  reverse(nums, k, nums.length - 1);  // un-reverse the rest
}

function reverse(arr, lo, hi) {
  while (lo < hi) {
    [arr[lo], arr[hi]] = [arr[hi], arr[lo]];
    lo++; hi--;
  }
}
// [1,2,3,4,5,6,7], k=3
   reverse all    → [7,6,5,4,3,2,1]
   reverse [0,k)  → [5,6,7,4,3,2,1]
   reverse [k,n)  → [5,6,7,1,2,3,4]  ← correctly rotated right by 3

Three O(n) reversals is still O(n) total, but O(1) extra space instead of O(n) — the kind of tradeoff interviewers specifically probe for with "can you do it without the extra array?"

The array as its own hash table: marking and cyclic placement

A hash map answers "have I seen this value?" in O(1), at the cost of O(n) memory. Some problems hand you a shortcut: the values are tied to the indices. If an array of length n holds numbers from 1 to n, then the value v has a natural home, the slot v − 1, and the array itself can play the role of the map. Two techniques use that, and they answer the follow-up "can you do it in O(1) extra space?".

Technique 1: mark the slot by flipping its sign

Find all numbers missing from 1..n. The array holds n values from 1 to n, some repeated, so some values are absent. Visit each value x, and make nums[|x| − 1] negative to record "the value |x| exists". After the pass, an index whose slot is still positive was never marked, so its value i + 1 is missing.

function findDisappeared(nums) {
  for (const x of nums) {
    const idx = Math.abs(x) - 1;
    if (nums[idx] > 0) nums[idx] = -nums[idx];
  }
  const missing = [];
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] > 0) missing.push(i + 1);
  }
  return missing;
}

Dry run: findDisappeared([4, 3, 2, 7, 8, 2, 3, 1])

x as readslot markednums afterwards
43[4, 3, 2, −7, 8, 2, 3, 1]
32[4, 3, −2, −7, 8, 2, 3, 1]
−21[4, −3, −2, −7, 8, 2, 3, 1]
−76[4, −3, −2, −7, 8, 2, −3, 1]
87[4, −3, −2, −7, 8, 2, −3, −1]
21unchanged: already negative
−32unchanged: already negative
−10[−4, −3, −2, −7, 8, 2, −3, −1]

Slots 4 and 5 are still positive, so the missing values are 5 and 6. Two details carry the whole technique. A value you read may already have been turned negative by an earlier mark, so the index is taken from Math.abs(x). And a mark is only set when the slot is positive, so a repeated value does not flip it back. The same trick finds duplicates: if the slot is already negative when you arrive, you have seen this value before.

function findDuplicates(nums) {
  const dupes = [];
  for (const x of nums) {
    const idx = Math.abs(x) - 1;
    if (nums[idx] < 0) dupes.push(Math.abs(x));
    else nums[idx] = -nums[idx];
  }
  return dupes;
}

For the same input it returns [2, 3]: the second 2 meets a slot already marked, and so does the second 3. This version assumes every value appears once or twice. A value that appears three times would be reported twice, because its slot is already negative on both later visits.

Technique 2: put each value in its home

Marking tells you what is present. Cyclic placement goes further and actually rearranges the array so that value v sits at index v − 1, using swaps. Afterwards, the first index i whose value is not i + 1 reveals the answer. The classic use is the first missing positive: given any integers, including negatives, zeros and huge numbers, find the smallest positive integer that is absent.

The key observation: among n numbers, the answer must be in the range 1 to n + 1. If the array holds all of 1 to n, the answer is n + 1; otherwise a gap in 1 to n exists and the answer is the first gap. So values outside 1 to n are irrelevant, and values inside have a home slot.

function firstMissingPositive(nums) {
  const n = nums.length;
  for (let i = 0; i < n; i++) {
    while (
      nums[i] >= 1 &&
      nums[i] <= n &&
      nums[nums[i] - 1] !== nums[i]
    ) {
      const home = nums[i] - 1;
      [nums[i], nums[home]] = [nums[home], nums[i]];
    }
  }
  for (let i = 0; i < n; i++) {
    if (nums[i] !== i + 1) return i + 1;
  }
  return n + 1;
}

Dry run: firstMissingPositive([3, 4, −1, 1])

iswapnums afterwardswhy
0index 0 with index 2[−1, 4, 3, 1]3 belongs at index 2; −1 comes back and is out of range, so the loop at i = 0 stops
1index 1 with index 3[−1, 1, 3, 4]4 belongs at index 3
1index 1 with index 0[1, −1, 3, 4]the 1 that arrived belongs at index 0; −1 comes back and stops the loop
2none[1, −1, 3, 4]3 is already at index 2
3none[1, −1, 3, 4]4 is already at index 3

The scan finds nums[1] = −1 where a 2 should be, so the answer is 2. For [7, 8, 9, 11, 12] nothing is in range, no swap happens, and the first slot already fails, giving 1. For [1, 2, 0] slots 0 and 1 hold 1 and 2, and slot 2 holds 0 instead of 3, so the answer is 3. When every slot passes, as in [2, 1] after placement, the answer is n + 1, here 3.

Why it is O(n) with a loop inside a loop. Every swap puts one value into its final home slot, and a value in its home is never moved again (the while test stops as soon as the home already holds that value). There are at most n homes to fill, so all the inner loops together perform at most n swaps, and the outer loop adds n steps. The nested shape is the same shared-budget argument as the two-pointer loops in the complexity chapter.

⚠ Three bugs that cost the offer The swap that eats a value. Writing [nums[i], nums[nums[i] − 1]] = [nums[nums[i] − 1], nums[i]] looks the same, but the second target is worked out after nums[i] has already been overwritten, so it points at the wrong slot. On [3, 1, 2] it produces [2, 3, 2], losing the 1. Compute home first, as the code does. Dropping the duplicate test. Without nums[nums[i] − 1] !== nums[i] a repeated value swaps with its own twin forever, an infinite loop. if instead of while. After one swap, the value that arrived at index i may also belong elsewhere, and an if moves on without placing it.

Recognise it: the values are in 1..n or 0..n−1 (tied to the length) and the problem asks for missing, duplicate, or "first absent" with O(1) space; or the answer is guaranteed to lie in 1..n+1. Both techniques modify the input: if you must not, say so and use a Set, or undo the marks by taking Math.abs of every slot at the end.

2D arrays — same rules, one more dimension

A 2D array in JS is really an array of arrays — each row is its own separate array object, stored at scattered locations (unlike a true contiguous 2D block in lower-level languages). grid[i][j] is still O(1): it's two index lookups chained, each O(1).

grid[0] → grid[1] → grid[2] → 1 2 3 4 5 6 7 8 9 grid[1][2] → row 1, then index 2 within it → 6. Two O(1) lookups, chained.
Three separate row arrays, not one contiguous block — which is exactly why the fill()-sharing bug below happens.
// row-major traversal — the standard order, matches memory/cache-friendly access
function traverse2D(grid) {
  for (let row = 0; row < grid.length; row++) {
    for (let col = 0; col < grid[row].length; col++) {
      console.log(grid[row][col]);
    }
  }
}
⚠ The shared-row bug Array(n).fill(Array(m).fill(0)) creates one inner array and reuses the same reference for every row — mutate grid[0][0] and you'll find grid[1][0] changed too. Build each row independently instead:
const grid = Array.from({ length: n }, () => Array(m).fill(0));
Say it like this → "Arrays give O(1) random access because the address is computed, not searched — but any operation that has to shift elements is O(n), and strings are immutable so building one char-by-char in a loop can be O(n²) in the worst case, so I batch it with an array and join."

Common gotchas worth knowing cold

  • Sparse arrays — new Array(5) creates 5 empty slots, not zeros; .map() skips them.
  • Copying — const b = a copies the reference, not the array. Use [...a] or a.slice() for a shallow copy.
  • sort() mutates the original array and defaults to string comparison — [10, 2, 1].sort() gives [1, 10, 2] unless you pass a comparator.
  • Dynamic array growth is covered in depth in the complexity chapter's amortized-analysis section — the short version: push() is amortized O(1) because the underlying buffer doubles instead of growing by one each time.
Before you move on
  • Explain why arr.unshift(x) is O(n) even though it's one method call, and name the interview trap it causes when used inside a loop.
  • Build a prefix-sum array and answer a range-sum query in O(1), then say why it beats rescanning per query.
  • Trace Kadane's algorithm on a new array by hand, including at least one point where it resets instead of extending.
  • Explain the shared-row bug in Array(n).fill(Array(m).fill(0)) and how Array.from avoids it.
  • Given a new problem, decide whether it needs O(1) index access (array) or O(n²)-avoiding batched string building — before writing any code.
  • Merge two sorted arrays into the spare room of the first by filling from the back, and say why the front would overwrite unread values.
  • Return the range of the best subarray, and solve the circular version as the larger of plain Kadane and total minus the smallest run, with the all-negative case handled.
  • Build product-except-self from a left sweep and a right sweep, and the best stock profit from a running minimum.
  • Explain why the Boyer-Moore vote finds a majority element, and why a second counting pass is needed when no majority is promised.
  • Use the array's own indices as a hash table (sign marking and cyclic placement) when values lie in 1..n, and name what it costs: the input is modified.
  • Count letters in a 26-slot table, and say where it silently fails (other alphabets, UTF-16 pairs); parse atoi and Roman numerals with every rule in its own step.

32Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsBest Time to Buy and Sell Stockbeginner
  • 8 testsMaximum Subarrayintermediate
  • 8 testsMerge Sorted Array In Placebeginner
  • 8 testsRemove Duplicates from Sorted Arraybeginner
  • 8 testsRotate Array by Kintermediate
  • 8 testsProduct of Array Except Selfintermediate
  • 8 testsMajority Elementbeginner
  • 8 testsMove Zeroesbeginner
  • 8 testsContains Duplicatebeginner
  • 8 testsFind the Missing Numberbeginner
  • 8 testsFind All Numbers Disappeared in an Arraybeginner
  • 8 testsSubarray Sum Equals Kintermediate
  • 8 testsContinuous Subarray Sumintermediate
  • 8 testsMaximum Size Subarray Sum Equals Kintermediate
  • 8 testsContiguous Arrayintermediate
  • 8 testsFind Pivot Indexbeginner
  • 8 testsRange Sum Query — Immutablebeginner
  • 8 testsLongest Common Prefixbeginner
  • 10 testsReverse String In Placebeginner
  • 8 testsReverse Words in a Stringintermediate
  • 8 testsString to Integer (atoi)intermediate
  • 9 testsInteger to Romanintermediate
  • 9 testsRoman to Integerbeginner
  • 8 testsZigzag Conversionintermediate
  • 8 testsValid Anagrambeginner
  • 7 testsFind the Differencebeginner
  • 7 testsMaximum Subarray, With Its Positionbeginner
  • 7 testsMaximum Sum Circular Subarrayintermediate
  • 8 testsMajority Element IIintermediate
  • 7 testsFind All Duplicates in an Arrayintermediate
  • 7 testsFirst Missing Positiveintermediate
  • 7 testsReverse Text Without Breaking Emojibeginner
All 217 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

B3

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. B2Previous · B2The JavaScript toolkit for DSA
  2. B3you are here
  3. Next · B4Prefix sums and difference arraysStore the running total once, and a whole family of range questions becomes one subtraction.34 min read

01On this page

18 sections · 0%

  1. 01What "beginner" means for this chapter
  2. 02An array is a promise about memory
  3. 03What's actually O(1) vs O(n) on an array
  4. 04Strings are arrays with one extra rule
  5. 05Characters as numbers: charCodeAt and 26-slot counts
  6. 06Strings are UTF-16: length, code points and reversing
  7. 07Parsing numbers out of strings: atoi and Roman numerals
  8. 08The in-place pattern
  9. 09Merging from the back: in place, without overwriting
  10. 10Prefix sums — turn O(n) range queries into O(1)
  11. 11Kadane's algorithm — the maximum subarray, in one pass
  12. 12Kadane's variants: the indices, and the circular array
  13. 13Prefix and suffix products, and the running minimum
  14. 14Boyer-Moore voting: the majority element
  15. 15Rotating an array in O(1) space — the triple-reversal trick
  16. 16The array as its own hash table: marking and cyclic placement
  17. 172D arrays — same rules, one more dimension
  18. 18Common gotchas worth knowing cold
Mark as read

[] chapters · t top