B3 · Part 1 · Beginner
Arrays & strings
The two data structures every other pattern is built on top of.
18On this page
- 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
- Strings are UTF-16: length, code points and reversing
- Parsing numbers out of strings: atoi and Roman numerals
- The in-place pattern
- Merging from the back: in place, without overwriting
- Prefix sums — turn O(n) range queries into O(1)
- Kadane's algorithm — the maximum subarray, in one pass
- Kadane's variants: the indices, and the circular array
- Prefix and suffix products, and the running minimum
- Boyer-Moore voting: the majority element
- Rotating an array in O(1) space — the triple-reversal trick
- The array as its own hash table: marking and cyclic placement
- 2D arrays — same rules, one more dimension
- 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.
What's actually O(1) vs O(n) on an array
| Operation | Complexity | Why |
|---|---|---|
| Read/write by index | O(1) | direct address math |
| Push/pop at the end | O(1) amortized | no shifting needed |
| Shift/unshift at the start | O(n) | every other element moves over |
splice() in the middle | O(n) | everything after the cut shifts |
Search by value (indexOf, includes) | O(n) | no shortcut — has to walk it |
⚠ 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:
| Expression | Result for "😀" | What it counts |
|---|---|---|
"😀".length | 2 | UTF-16 code units |
[..."😀"].length | 1 | code points (spread and for...of iterate by code point) |
"😀".charCodeAt(0) | 55357 | the first half of the pair, a lone surrogate |
"😀".codePointAt(0) | 128512 | the 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")
| step | i | character | action | value |
|---|---|---|---|---|
| skip spaces | 0 to 2 | " " | advance | 0 |
| sign | 3 | "-" | sign = −1, advance | 0 |
| digit | 4 | "0" | 0 × 10 + 0 | 0 |
| digit | 5 | "4" | 0 × 10 + 4 | 4 |
| digit | 6 | "2" | 4 × 10 + 2 | 42 |
| stop | 7 | "a" | not a digit, leave the loop | 42 |
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")
| symbol | value | next is bigger? | total after |
|---|---|---|---|
| M | 1000 | no (C = 100) | 1000 |
| C | 100 | yes (M = 1000) | 900 |
| M | 1000 | no (X = 10) | 1900 |
| X | 10 | yes (C = 100) | 1890 |
| C | 100 | no (I = 1) | 1990 |
| I | 1 | yes (V = 5) | 1989 |
| V | 5 | no (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
| write | compare | value written | nums1 afterwards |
|---|---|---|---|
| 5 | nums1[2] = 5 vs nums2[2] = 6 | 6, from nums2 | [1, 3, 5, 0, 0, 6] |
| 4 | nums1[2] = 5 vs nums2[1] = 4 | 5, from nums1 | [1, 3, 5, 0, 5, 6] |
| 3 | nums1[1] = 3 vs nums2[1] = 4 | 4, from nums2 | [1, 3, 5, 4, 5, 6] |
| 2 | nums1[1] = 3 vs nums2[0] = 2 | 3, from nums1 | [1, 3, 3, 4, 5, 6] |
| 1 | nums1[0] = 1 vs nums2[0] = 2 | 2, 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).
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])
| i | nums[i] | bestEndingHere | bestSoFar |
|---|---|---|---|
| 0 | -2 | -2 | -2 |
| 1 | 1 | max(1, -2+1)=1 | 1 |
| 2 | -3 | max(-3, 1-3)=-2 | 1 |
| 3 | 4 | max(4, -2+4)=4 | 4 |
| 4 | -1 | max(-1, 4-1)=3 | 4 |
| 5 | 2 | max(2, 3+2)=5 | 5 |
| 6 | 1 | max(1, 5+1)=6 | 6 |
| 7 | -5 | max(-5, 6-5)=1 | 6 |
| 8 | 4 | max(4, 1+4)=5 | 6 |
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])
| x | curMax | bestMax | curMin | bestMin | total |
|---|---|---|---|---|---|
| 5 | 5 | 5 | 5 | 5 | 5 |
| −3 | 2 | 5 | −3 | −3 | 2 |
| 5 | 7 | 7 | 2 | −3 | 7 |
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])
| pass | i | running value used | out afterwards |
|---|---|---|---|
| left to right | 0 | left = 1 | [1, 1, 1, 1] |
| left to right | 1 | left = 1 | [1, 1, 1, 1] |
| left to right | 2 | left = 2 | [1, 1, 2, 1] |
| left to right | 3 | left = 6 | [1, 1, 2, 6] |
| right to left | 3 | right = 1 | [1, 1, 2, 6] |
| right to left | 2 | right = 4 | [1, 1, 8, 6] |
| right to left | 1 | right = 12 | [1, 12, 8, 6] |
| right to left | 0 | right = 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])
| price | price − cheapest (before update) | best | cheapest after |
|---|---|---|---|
| 7 | 7 − ∞ = −∞ | 0 | 7 |
| 1 | 1 − 7 = −6 | 0 | 1 |
| 5 | 5 − 1 = 4 | 4 | 1 |
| 3 | 3 − 1 = 2 | 4 | 1 |
| 6 | 6 − 1 = 5 | 5 | 1 |
| 4 | 4 − 1 = 3 | 5 | 1 |
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])
| x | candidate | count | what happened |
|---|---|---|---|
| 2 | 2 | 1 | count was 0, so adopt 2 |
| 2 | 2 | 2 | same as candidate |
| 1 | 2 | 1 | a 1 cancels one 2 |
| 1 | 2 | 0 | a 1 cancels the other 2 |
| 1 | 1 | 1 | count was 0, so adopt 1 |
| 2 | 1 | 0 | a 2 cancels the 1 |
| 2 | 2 | 1 | count 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 read | slot marked | nums afterwards |
|---|---|---|
| 4 | 3 | [4, 3, 2, −7, 8, 2, 3, 1] |
| 3 | 2 | [4, 3, −2, −7, 8, 2, 3, 1] |
| −2 | 1 | [4, −3, −2, −7, 8, 2, 3, 1] |
| −7 | 6 | [4, −3, −2, −7, 8, 2, −3, 1] |
| 8 | 7 | [4, −3, −2, −7, 8, 2, −3, −1] |
| 2 | 1 | unchanged: already negative |
| −3 | 2 | unchanged: already negative |
| −1 | 0 | [−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])
| i | swap | nums afterwards | why |
|---|---|---|---|
| 0 | index 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 |
| 1 | index 1 with index 3 | [−1, 1, 3, 4] | 4 belongs at index 3 |
| 1 | index 1 with index 0 | [1, −1, 3, 4] | the 1 that arrived belongs at index 0; −1 comes back and stops the loop |
| 2 | none | [1, −1, 3, 4] | 3 is already at index 2 |
| 3 | none | [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).
// 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 = acopies the reference, not the array. Use[...a]ora.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 howArray.fromavoids 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.
Opens in the editor: write it, run it, and check it against real tests.
All 217 for this level →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.
Finished reading?
0 of 48 chapters read in this series