../

Arrays, hashing, lists, stacks & queues

The linear structures every interview problem is built from: arrays and dynamic arrays, strings, hash maps and sets, linked lists, stacks, queues and deques, each with its costs in TypeScript, JavaScript and Python. Cost notation is in Big-O; trees, heaps and graphs are in Trees & graphs; the techniques that use these structures (two pointers, sliding window, monotonic stack) are in Problem patterns.

Arrays & dynamic arrays

An array stores items in one contiguous block, so item i is at base + i × size: indexing is O(1)O(1) and scans are cache-friendly. A dynamic array (JS Array, Python list) over-allocates and grows its buffer by a constant factor when full (V8 about 1.5×, CPython about 1.125×), making appends O(1)O(1) amortized (why).

Array: one contiguous block 7 3 9 4 1 8 xs[i] = base + i × size O(1) jump to any index Linked list: nodes anywhere, joined by pointers 7 3 9 4 null head 1000 1008 1016 1024 1032 1040 [0] [1] [2] [3] [4] [5] val next insert in the middle: shift the tail, O(n) reach node i by following i links: O(i); insert after a known node: O(1)
Arrays jump to any index; lists walk pointers but splice in O(1)
OperationCostTSPython
Read / write index iO(1)O(1)xs[i], xs.at(-1)xs[i], xs[-1]
Append / pop at the endO(1)O(1) amortizedpush, popappend, pop()
Insert / delete at iO(n−i)O(n - i)splice(i, 0, x), splice(i, 1)insert(i, x), del xs[i]
Insert / delete at the frontO(n)O(n)unshift, shiftinsert(0, x), pop(0)
Search, unsortedO(n)O(n)indexOf, includesindex, in
Search, sortedO(log⁡n)O(\log n)hand-written binary searchbisect_left
Copy a range of kkO(k)O(k)slice(a, b)xs[a:b]
SortO(nlog⁡n)O(n \log n)sort((a, b) => a - b)sort(), sorted()

Fixed-size numeric arrays: Int32Array / Float64Array in JS (typed arrays, contiguous, no holes); array.array or NumPy in Python.

// Two pointers: O(n) time, O(1) extra space
function reverseInPlace<T>(xs: T[]): void {
  for (let i = 0, j = xs.length - 1; i < j; i++, j--)
    [xs[i], xs[j]] = [xs[j], xs[i]];
}
 
// Prefix sums: O(n) to build, then O(1) per range sum
function prefixSums(xs: number[]): number[] {
  const pre = [0];
  for (const x of xs) pre.push(pre[pre.length - 1] + x);
  return pre; // sum of xs[i..j) = pre[j] - pre[i]
}
 
// r × c grid: a new row array for every row
function grid(r: number, c: number): number[][] {
  return Array.from({ length: r }, () =>
    new Array<number>(c).fill(0));
}

Grid trap: Array(r).fill(new Array(c).fill(0)) and [[0] * c] * r put the same row object in every slot, so writing one cell writes a whole column. itertools.accumulate(xs, initial=0) builds the prefix sums in one call.

Strings

Strings are immutable in all three languages: every "change" builds a new string, so edits in a loop cost O(n)O(n) each. Convert to an array of characters, edit, then join once.

TaskTypeScriptPython
Char at is[i], s.charAt(i)s[i]
Char code ↔ chars.charCodeAt(i), String.fromCharCode(c)ord(ch), chr(c)
Letter index 0–25s.charCodeAt(i) - 97ord(ch) - ord("a")
Mutable copy[...s] or s.split("")list(s)
Build from piecesparts.push(p), then parts.join("")parts.append(p), then "".join(parts)
Substring tests.includes(t)t in s
Reverse[...s].reverse().join("")s[::-1]
Sorted letters (anagram key)[...s].sort().join("")"".join(sorted(s))
Split on whitespaces.trim().split(/\s+/)s.split()
Is letter / digit/[a-z]/i.test(ch), /\d/.test(ch)ch.isalpha(), ch.isdigit()
LengthUTF-16 code units (emoji count 2)code points

s[0] = "x" is a compile error in TS (read-only index) and a TypeError in Python. In JS, += in a loop is usually fast because engines defer the copy (ropes); in Python it is only fast by a CPython optimization, so use "".join in both to be safe.

// 26-slot counts: lighter than a Map for a–z input
function letterCounts(s: string): number[] {
  const counts = new Array<number>(26).fill(0);
  for (let i = 0; i < s.length; i++)
    counts[s.charCodeAt(i) - 97]++;
  return counts;
}
 
// Build pieces in an array, join once: O(n)
function runLength(s: string): string {
  const parts: string[] = [];
  for (let i = 0; i < s.length; ) {
    let j = i;
    while (j < s.length && s[j] === s[i]) j++;
    parts.push(`${s[i]}${j - i}`);
    i = j;
  }
  return parts.join("");
}
 
function reverseWords(s: string): string {
  return s.trim().split(/\s+/).reverse().join(" ");
}

Hash maps & sets

OperationAverageWorstTS Map / SetPython dict / set
Insert / updateO(1)O(1)O(n)O(n)m.set(k, v), s.add(x)d[k] = v, s.add(x)
LookupO(1)O(1)O(n)O(n)m.get(k), m.has(k), s.has(x)d[k], d.get(k), k in d
DeleteO(1)O(1)O(n)O(n)m.delete(k)del d[k], d.pop(k), s.discard(x)
SizeO(1)O(1)m.sizelen(d)
IterateO(n)O(n)insertion orderinsertion order (dict); arbitrary (set)
Union / intersectionO(n+m)O(n + m) / O(min⁡)O(\min)a.union(b), a.intersection(b) (ES2025) or loopsa | b, a & b

How hashing works

 key "cat" ─ hash() ─► 7304 ─ mod 8 ─► bucket 0
 
 bucket 0: ("cat", 3) ─► ("tac", 1)   collision: 2 keys
 bucket 1: empty                       share one bucket
 bucket 2: ("dog", 5)
 …
 load factor = items / buckets; past a threshold the
 table doubles and every key is re-hashed:
 O(n) once, O(1) amortized
IdeaDetail
Hash functionmaps a key to an integer; equal keys must hash equal
Bucket indexhash mod capacity (or a bit mask when capacity is a power of 2)
Collision: chainingeach bucket holds a small list; lookup scans it
Collision: open addressingprobe other slots until an empty one (CPython dict does this)
Resizinggrow when the load factor passes a threshold (about 2/3 in CPython); amortized O(1)O(1)
Worst caseevery key in one bucket: O(n)O(n); Python randomizes str hashes per process to prevent crafted collisions
Key costhashing a string of length LL is O(L)O(L)

What can be a key

KeyJS Map / SetJS plain objectPython dict / set
1 vs "1"two keysone key ("1")two keys
1 vs 1.0 vs true / True1 and 1.0 one key (same number); true separate1, 1.0 → "1"; true → "true"one key: 1 == 1.0 == True hash equal
NaN, -0 / +0NaN matches NaN; -0 is +0 (SameValueZero)"NaN", "0"nan only matches itself by identity
Object / arrayby identity: a new [1, 2] is a different keycoerced to "[object Object]" or "1,2"list, dict, set: TypeError, unhashable
Composite keyencode: `${r},${c}` or nested mapssametuple: (r, c) hashes by value
Custom classidentity only@dataclass(frozen=True), or __eq__ + __hash__

// Composite key: encode to a string (or a number)
const key = (r: number, c: number): string => `${r},${c}`;
const seen = new Set<string>();
seen.add(key(1, 2));
const hasCell = seen.has(key(1, 2)); // true
 
// Objects are keys by identity, not by value
const a = [1, 2];
const byRef = new Map<number[], string>([[a, "x"]]);
const same = byRef.get(a);      // "x"
const other = byRef.get([1, 2]); // undefined

Counter, defaultdict & TS equivalents

PythonTypeScript equivalent
Counter(xs)counts(xs) below
c[k] (0 when missing)m.get(k) ?? 0
c.most_common(k)sort [...m] by count, slice(0, k)
c1 == c2 (same multiset)same size and every get equal
c1 - c2, c1 + c2, c1 & c2loop over entries
defaultdict(list)groupBy below, or Map.groupBy(xs, fn) (ES2024)
defaultdict(int)m.set(k, (m.get(k) ?? 0) + 1)
d.get(k, default)m.get(k) ?? fallback
d.setdefault(k, [])m.get(k), and m.set(k, []) if missing
OrderedDictMap (always insertion-ordered)

// Counter
function counts<T>(xs: Iterable<T>): Map<T, number> {
  const m = new Map<T, number>();
  for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1);
  return m;
}
 
// defaultdict(list)
function groupBy<T, K>(
  xs: Iterable<T>,
  key: (x: T) => K,
): Map<K, T[]> {
  const m = new Map<K, T[]>();
  for (const x of xs) {
    const k = key(x);
    const group = m.get(k);
    if (group) group.push(x);
    else m.set(k, [x]);
  }
  return m;
}
 
const c = counts("banana"); // a→3, b→1, n→2
const top2 = [...c].sort((p, q) => q[1] - p[1]).slice(0, 2);
const byLen = groupBy(["hi", "yo", "hey"], (w) => w.length);

Linked lists

Nodes live anywhere in memory and point to the next one (and, in a doubly linked list, the previous one). No indexing, but splicing at a node you already hold is O(1)O(1).

OperationSinglyDoublyArray, for comparison
Access ii-thO(i)O(i)O(i)O(i)O(1)O(1)
Insert / delete at headO(1)O(1)O(1)O(1)O(n)O(n)
Insert / delete at tailO(1)O(1) with a tail pointer / O(n)O(n) deleteO(1)O(1)O(1)O(1) amortized
Insert after a known nodeO(1)O(1)O(1)O(1)O(n)O(n)
Delete a known nodeO(n)O(n) (need the previous one)O(1)O(1)O(n)O(n)
SearchO(n)O(n)O(n)O(n)O(n)O(n)
Extra memory per item1 pointer2 pointersnone

Interview uses: reverse (all or part), merge sorted lists, find the middle or a cycle with fast and slow pointers, remove the kk-th from the end, and the doubly linked list inside an LRU cache. In real code, arrays nearly always win on speed (cache locality).

type Link<T> = ListNode<T> | null;
 
class ListNode<T> {
  val: T;
  next: Link<T>;
  constructor(val: T, next: Link<T> = null) {
    this.val = val;
    this.next = next;
  }
}
 
function fromArray<T>(xs: T[]): Link<T> {
  let head: Link<T> = null;
  for (let i = xs.length - 1; i >= 0; i--)
    head = new ListNode(xs[i], head);
  return head;
}
 
function toArray<T>(head: Link<T>): T[] {
  const out: T[] = [];
  for (let n = head; n; n = n.next) out.push(n.val);
  return out;
}
 
// O(n) time, O(1) space: flip every next pointer
function reverse<T>(head: Link<T>): Link<T> {
  let prev: Link<T> = null;
  let cur = head;
  while (cur) {
    const next = cur.next;
    cur.next = prev;
    prev = cur;
    cur = next;
  }
  return prev;
}

Dummy head & fast/slow pointers

A dummy (sentinel) node before the real head removes the "is the result list empty?" special case. Fast and slow pointers (fast moves two steps per slow step) find the middle and detect cycles in O(n)O(n) time and O(1)O(1) space.

// Merge two sorted lists: O(n + m) time, O(1) space
function merge(
  a: Link<number>,
  b: Link<number>,
): Link<number> {
  const dummy = new ListNode(0);
  let tail = dummy;
  while (a && b) {
    if (a.val <= b.val) {
      tail.next = a;
      a = a.next;
    } else {
      tail.next = b;
      b = b.next;
    }
    tail = tail.next;
  }
  tail.next = a ?? b;
  return dummy.next;
}
 
// Middle node (the second one if the length is even)
function middle<T>(head: ListNode<T>): ListNode<T> {
  let slow = head;
  let fast: Link<T> = head;
  while (fast && fast.next) {
    slow = slow.next!;
    fast = fast.next.next;
  }
  return slow;
}
 
// Floyd's cycle check
function hasCycle<T>(head: Link<T>): boolean {
  let slow = head;
  let fast = head;
  while (fast && fast.next) {
    slow = slow!.next;
    fast = fast.next.next;
    if (slow === fast) return true;
  }
  return false;
}

Stacks

Last in, first out. Both languages use the dynamic array: push and pop at the end are O(1)O(1).

Stack: last in, first out D (top) C B A push pop Queue: first in, first out A B C D front back JS: push / pop / at(-1) Python: append / pop / [-1] dequeue at the front, enqueue at the back JS: Deque pushBack / popFront Python: deque append / popleft deque: push/pop both ends, O(1)
Stacks work at one end; queues take in at the back and give out at the front
OperationCostTS (xs: T[])Python (xs: list)
PushO(1)O(1) amortizedxs.push(x)xs.append(x)
PopO(1)O(1)xs.pop() (undefined if empty)xs.pop() (IndexError if empty)
PeekO(1)O(1)xs.at(-1)xs[-1]
Empty?O(1)O(1)xs.length === 0not xs

Stacks show up for: matching brackets, undo, evaluating expressions, iterative DFS, "next greater element" (a monotonic stack, see Problem patterns), and replacing recursion that would overflow the call stack.

// Stack with O(1) min: store the running minimum too
class MinStack {
  private items: number[] = [];
  private mins: number[] = []; // mins[i] = min(items[..i])
 
  push(x: number): void {
    this.items.push(x);
    const m = this.mins.at(-1);
    this.mins.push(m === undefined ? x : Math.min(m, x));
  }
 
  pop(): number | undefined {
    this.mins.pop();
    return this.items.pop();
  }
 
  top(): number | undefined {
    return this.items.at(-1);
  }
 
  min(): number | undefined {
    return this.mins.at(-1);
  }
}

Queues & deques

First in, first out. A deque (double-ended queue) pushes and pops at both ends in O(1)O(1).

NeedTypeScriptPython
Queue, simplearray + head index (never shift())collections.deque: append / popleft
Queue / deque, generalthe Deque<T> ring buffer belowcollections.deque
Bounded "last k items"ring buffer that overwritesdeque(maxlen=k)
Priority queuea heap: MinHeap<T>heapq
Thread-safe queuenot applicable (single thread)queue.Queue (locks; slower, not for algorithms)
OperationDeque<T> belowcollections.deque
Push back / frontpushBack, pushFront: O(1)O(1) amortizedappend, appendleft: O(1)O(1)
Pop back / frontpopBack, popFront: O(1)O(1)pop, popleft: O(1)O(1)
PeekpeekFront, peekBackd[0], d[-1]
Index iget(i): O(1)O(1)d[i]: O(n)O(n) toward the middle
Lengthlengthlen(d)

JavaScript has no built-in deque or queue: shift() is O(n)O(n). This ring buffer keeps items in a circular array with a moving head, doubling when full.

export class Deque<T> {
  private buf: (T | undefined)[];
  private head = 0; // index of the front item
  private size = 0;
 
  constructor(capacity = 16) {
    this.buf = new Array<T | undefined>(capacity);
  }
 
  get length(): number {
    return this.size;
  }
 
  // physical slot of logical index i
  private slot(i: number): number {
    return (this.head + i) % this.buf.length;
  }
 
  get(i: number): T | undefined {
    return i >= 0 && i < this.size
      ? this.buf[this.slot(i)]
      : undefined;
  }
 
  pushBack(x: T): void {
    if (this.size === this.buf.length) this.grow();
    this.buf[this.slot(this.size)] = x;
    this.size++;
  }
 
  pushFront(x: T): void {
    if (this.size === this.buf.length) this.grow();
    this.head = this.slot(this.buf.length - 1);
    this.buf[this.head] = x;
    this.size++;
  }
 
  popFront(): T | undefined {
    if (this.size === 0) return undefined;
    const x = this.buf[this.head];
    this.buf[this.head] = undefined; // allow GC
    this.head = this.slot(1);
    this.size--;
    return x;
  }
 
  popBack(): T | undefined {
    if (this.size === 0) return undefined;
    const i = this.slot(this.size - 1);
    const x = this.buf[i];
    this.buf[i] = undefined;
    this.size--;
    return x;
  }
 
  peekFront(): T | undefined {
    return this.get(0);
  }
 
  peekBack(): T | undefined {
    return this.get(this.size - 1);
  }
 
  private grow(): void {
    const next = new Array<T | undefined>(
      Math.max(1, this.buf.length * 2));
    for (let i = 0; i < this.size; i++)
      next[i] = this.buf[this.slot(i)];
    this.buf = next;
    this.head = 0;
  }
}

Use it as a queue with pushBack + popFront, a stack with pushBack + popBack, or a sliding-window monotonic deque with all four. For a BFS where the queue is only appended to, an array plus a head index is just as fast and simpler.

Choosing a structure

You needUseCost
Index by position, appenddynamic arrayO(1)O(1)
"Have I seen x?"Set / setO(1)O(1) average
Value by key, counts, groupsMap / dict, Counter, defaultdictO(1)O(1) average
Keys in insertion order, move to endMap / OrderedDictO(1)O(1)
Last in, first outarray as a stackO(1)O(1)
First in, first outdeque (or array + head index)O(1)O(1)
Both endsdequeO(1)O(1)
Smallest / largest repeatedlyheap (Trees & graphs)O(log⁡n)O(\log n)
Sorted order with insertsbalanced BST, or sorted list + bisectO(log⁡n)O(\log n) / O(n)O(n) insert
Prefix lookupstrie (Trees & graphs)O(L)O(L)
Range sums, staticprefix-sum arrayO(1)O(1) per query
O(1) removal of a known item in orderdoubly linked list + hash mapO(1)O(1)

Recipes

LRU cache

Use when asked for a fixed-size cache that evicts the least recently used key ("LRU Cache"). Map and OrderedDict both remember insertion order, so "most recent" is simply "last". O(1)O(1) per operation.

class LRUCache<K, V> {
  private readonly map = new Map<K, V>();
  private readonly capacity: number;
 
  constructor(capacity: number) {
    this.capacity = capacity;
  }
 
  get(key: K): V | undefined {
    if (!this.map.has(key)) return undefined;
    const v = this.map.get(key)!;
    this.map.delete(key); // re-insert as most recent
    this.map.set(key, v);
    return v;
  }
 
  put(key: K, value: V): void {
    this.map.delete(key);
    this.map.set(key, value);
    if (this.map.size > this.capacity) {
      // first key in iteration order = least recent
      const oldest = this.map.keys().next().value!;
      this.map.delete(oldest);
    }
  }
}

If the interviewer forbids ordered maps, build the same thing by hand: a hash map from key to node, plus a doubly linked list with dummy head and tail; get unlinks the node and re-adds it at the tail.

Top k frequent

Use to rank items by count ("Top K Frequent Elements"). Bucket by frequency for O(n)O(n) time and space; sorting the counts is O(nlog⁡n)O(n \log n), a size-kk heap O(nlog⁡k)O(n \log k).

function topKFrequent(xs: number[], k: number): number[] {
  const count = new Map<number, number>();
  for (const x of xs) count.set(x, (count.get(x) ?? 0) + 1);
  // bucket[f] = values seen exactly f times
  const bucket: number[][] = Array.from(
    { length: xs.length + 1 }, () => []);
  for (const [x, f] of count) bucket[f].push(x);
  const out: number[] = [];
  for (let f = xs.length; f > 0 && out.length < k; f--)
    out.push(...bucket[f]);
  return out.slice(0, k);
}

Balanced brackets

Use for "Valid Parentheses" and any nesting check: push openers, and each closer must match the top. O(n)O(n) time, O(n)O(n) space.

const OPEN = new Map([
  [")", "("],
  ["]", "["],
  ["}", "{"],
]);
 
function isBalanced(s: string): boolean {
  const stack: string[] = [];
  for (const ch of s) {
    if ("([{".includes(ch)) stack.push(ch);
    else if (OPEN.has(ch) && stack.pop() !== OPEN.get(ch))
      return false;
  }
  return stack.length === 0;
}

Group anagrams

Use when items are equal "up to rearrangement" ("Group Anagrams"): hash each item to a canonical key. Sorted-letters key: O(n⋅Llog⁡L)O(n \cdot L \log L); a 26-count key: O(n⋅L)O(n \cdot L).

function groupAnagrams(words: string[]): string[][] {
  const groups = new Map<string, string[]>();
  for (const w of words) {
    const key = [...w].sort().join(""); // canonical form
    const g = groups.get(key);
    if (g) g.push(w);
    else groups.set(key, [w]);
  }
  return [...groups.values()];
}

Queue from two stacks

Use when asked to build a queue from stacks ("Implement Queue using Stacks"). Each item moves from inbox to outbox once, so every operation is O(1)O(1) amortized.

class TwoStackQueue<T> {
  private inbox: T[] = [];
  private outbox: T[] = [];
 
  push(x: T): void {
    this.inbox.push(x);
  }
 
  pop(): T | undefined {
    if (this.outbox.length === 0) {
      while (this.inbox.length > 0)
        this.outbox.push(this.inbox.pop()!);
    }
    return this.outbox.pop();
  }
 
  get length(): number {
    return this.inbox.length + this.outbox.length;
  }
}

References