JavaScript DSA From Scratch — Implementations & Handwritten Traces
From-scratch JavaScript implementations of math, search, sort, stack, queue, linked list, hash table, graph, and BST — each with a handwritten execution-trace table, complexity, and demo calls.
Introduction
From-scratch JavaScript is the fastest way to own an algorithm — not a library call, the actual
loops, pointers, and recursive frames. This notebook walks every implementation in the code-evolution corpus: math
helpers, search and sort, stacks and queues, linked lists, hash tables, graphs, and binary search trees.
Each section has:
- The implementation — the code as written, cleaned for reading
- Complexity — time and space in a table
- Handwritten trace — step-by-step execution tables from the source files
- Demo calls — inputs with the output on the right
Related visualizers: Big O Notation · DSA Arrays · 90 Interview Problems
Quick index
Math
| # | Topic | Time | Space |
|---|---|---|---|
| 1 | Fibonacci | O(n) | O(n) |
| 2 | Factorial | O(n) | O(1) |
| 3 | Prime check | O(√n) | O(1) |
| 4 | Power of two | O(log n) | O(1) |
| 5 | Power of two (bitwise) | O(1) | O(1) |
Recursion
| # | Topic | Time | Space |
|---|---|---|---|
| 6 | Recursive Fibonacci | O(2ⁿ) | O(n) |
| 7 | Recursive factorial | O(n) | O(n) |
| 8 | Recursive binary search | O(log n) | O(log n) |
| 9 | Tower of Hanoi | O(2ⁿ) | O(n) |
Search & sort
| # | Topic | Time | Space |
|---|---|---|---|
| 10 | Linear search | O(n) | O(1) |
| 11 | Binary search | O(log n) | O(1) |
| 12 | Bubble sort | O(n²) | O(1) |
| 13 | Insertion sort | O(n²) | O(1) |
| 14 | Merge sort | O(n log n) | O(n) |
| 15 | Quick sort | O(n log n) avg | O(log n) |
Combinatorics & DP
| # | Topic | Time | Space |
|---|---|---|---|
| 16 | Cartesian product | O(m · n) | O(m · n) |
| 17 | Climbing staircase | O(n) | O(n) |
Stack
| # | Topic | Push | Pop |
|---|---|---|---|
| 18 | Stack with array | O(1) | O(1) |
| 19 | Stack with object | O(1) | O(1) |
| 20 | Linked-list stack | O(1) | O(n)* |
Queue
| # | Topic | Enqueue | Dequeue |
|---|---|---|---|
| 21 | Array queue | O(1) | O(n) |
| 22 | Optimized object queue | O(1) | O(1) |
| 23 | Circular queue | O(1) | O(1) |
| 24 | Linked-list queue | O(n) | O(1) |
Linked list
| # | Topic | Highlight |
|---|---|---|
| 25 | Singly linked list | insert / reverse |
| 26 | Linked list with tail | O(1) append |
| 27 | Doubly linked list | O(1) pop from tail |
Hash, graph, tree
| # | Topic | Avg lookup |
|---|---|---|
| 28 | Hash table | O(k) |
| 29 | Undirected graph | O(1) hasEdge |
| 30 | Binary search tree | O(log n) |
Math
1. Fibonacci
Time: O(n) · Space: O(n)
Build the first n Fibonacci numbers into an array. Each term is the sum of the previous two.
function fibonacci(n) {
const fib = [0, 1]
if (n === undefined) return []
if (n === 1) return [0]
if (n === 2) return fib
for (let i = 2; i < n; i++) {
fib[i] = fib[i - 1] + fib[i - 2]
}
return fib
}| Case | Time | Space | Why |
|---|---|---|---|
| Loop 2..n-1 | O(n) | O(n) | Array stores n numbers |
| n is 0/1/2 | O(1) | O(1) | Early return |
Handwritten trace — fibonacci(7)
| Step | i | Operation | Array state |
|---|---|---|---|
| 0 | — | Initialize fib = [0, 1] | [0, 1] |
| 1 | — | n === undefined? no | [0, 1] |
| 2 | — | n === 1? no | [0, 1] |
| 3 | — | n === 2? no | [0, 1] |
| 4 | 2 | fib[2] = 1 + 0 = 1 | [0, 1, 1] |
| 5 | 3 | fib[3] = 1 + 1 = 2 | [0, 1, 1, 2] |
| 6 | 4 | fib[4] = 2 + 1 = 3 | [0, 1, 1, 2, 3] |
| 7 | 5 | fib[5] = 3 + 2 = 5 | [0, 1, 1, 2, 3, 5] |
| 8 | 6 | fib[6] = 5 + 3 = 8 | [0, 1, 1, 2, 3, 5, 8] |
| 9 | 7 | 7 < 7 is false — loop ends | [0, 1, 1, 2, 3, 5, 8] |
Final result: [0, 1, 1, 2, 3, 5, 8]
Function calls with output
fibonacci() // → []
fibonacci(1) // → [0]
fibonacci(2) // → [0, 1]
fibonacci(3) // → [0, 1, 1]
fibonacci(7) // → [0, 1, 1, 2, 3, 5, 8]2. Factorial
Time: O(n) · Space: O(1)
n! is the product 1 × 2 × … × n. 0! and 1! are 1.
function factorial(n) {
let result = 1
if (n === 0) return 1
if (n === 1) return 1
for (let i = 2; i <= n; i++) {
result = result * i
}
return result
}| Case | Time | Space | Why |
|---|---|---|---|
| Loop 2..n | O(n) | O(1) | One accumulator |
| n is 0 or 1 | O(1) | O(1) | Immediate return |
Handwritten trace — factorial(5)
| Step | i | Operation | Result |
|---|---|---|---|
| 0 | — | result = 1 | 1 |
| 1 | — | n === 0? no | 1 |
| 2 | — | n === 1? no | 1 |
| 3 | 2 | 1 * 2 | 2 |
| 4 | 3 | 2 * 3 | 6 |
| 5 | 4 | 6 * 4 | 24 |
| 6 | 5 | 24 * 5 | 120 |
| 7 | 6 | 6 <= 5 is false | 120 |
Final result: 120
Function calls with output
factorial(0) // → 1
factorial(1) // → 1
factorial(5) // → 1203. Prime check
Time: O(√n) · Space: O(1)
A prime is greater than 1 with no divisor other than 1 and itself. If n = a × b and a > √n, then b < √n — so checking up to √n is enough.
function isPrime(n) {
if (n < 2) return false
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) return false
}
return true
}| Input | Bound √n | Checks without opt | Checks with √n |
|---|---|---|---|
| 10 | ~3.16 | 8 | 1 (hits 2) |
| 5 | ~2.24 | 3 | 1 |
| 10000 | 100 | 9998 | 1 (hits 2) |
Handwritten trace — isPrime(10)
√10 ≈ 3.16 so the loop would check i = 2, 3.
| Step | i | Operation | Result |
|---|---|---|---|
| 0 | — | 10 < 2? | false, continue |
| 1 | 2 | 10 % 2 === 0? | true → return false immediately |
Final result: false (checked 1 iteration instead of 8)
Handwritten trace — isPrime(5)
√5 ≈ 2.24 so the loop checks i = 2 only.
| Step | i | Operation | Result |
|---|---|---|---|
| 0 | — | 5 < 2? | false |
| 1 | 2 | 5 % 2 === 0? | false (5 % 2 = 1) |
| 2 | 3 | 3 <= 2.24? | false, loop ends |
| 3 | — | return true | 5 is prime |
Final result: true
Function calls with output
isPrime(1) // → false
isPrime(3) // → true
isPrime(4) // → false
isPrime(5) // → true
isPrime(10) // → false
isPrime(10000) // → false
isPrime(235) // → false (5 × 47)4. Power of two
Time: O(log n) · Space: O(1)
Keep dividing by 2. If you hit an odd number before 1, it is not a power of two.
function isPowerOfTwo(n) {
if (n < 1) return false
while (n > 1) {
if (n % 2 !== 0) return false
n = n / 2
}
return true
}Handwritten trace — isPowerOfTwo(5)
| Step | n | Check | Action |
|---|---|---|---|
| 0 | 5 | n < 1? | false |
| 1 | 5 | n > 1 | enter loop |
| 2 | 5 | 5 % 2 !== 0 | true |
| 3 | 5 | return false | stop |
Final result: false
Handwritten trace — isPowerOfTwo(8)
| Step | n | Check | Action |
|---|---|---|---|
| 0 | 8 | n < 1? | false |
| 1 | 8 | n > 1 | enter |
| 2 | 8 | 8 % 2 !== 0 | false |
| 3 | 8 | n = 8 / 2 | n becomes 4 |
| 4 | 4 | n > 1 | enter |
| 5 | 4 | 4 % 2 !== 0 | false |
| 6 | 4 | n = 4 / 2 | n becomes 2 |
| 7 | 2 | n > 1 | enter |
| 8 | 2 | 2 % 2 !== 0 | false |
| 9 | 2 | n = 2 / 2 | n becomes 1 |
| 10 | 1 | n > 1 | exit loop |
| 11 | 1 | return true | 8 = 2³ |
Final result: true
Function calls with output
isPowerOfTwo(1) // → true
isPowerOfTwo(2) // → true
isPowerOfTwo(4) // → true
isPowerOfTwo(6) // → false
isPowerOfTwo(8) // → true5. Power of two (bitwise)
Time: O(1) · Space: O(1)
A positive power of two has exactly one bit set. n & (n - 1) clears that bit and must be 0.
function isPowerOfTwoBitwise(n) {
if (n < 1) return false
return (n & (n - 1)) === 0
}Handwritten trace — isPowerOfTwoBitwise(8)
| Step | Expression | Binary | Result |
|---|---|---|---|
| 1 | n | 1000 | 8 |
| 2 | n - 1 | 0111 | 7 |
| 3 | 8 AND 7 | 0000 | 0 |
| 4 | 0 === 0 | — | true |
Final result: true
Handwritten trace — isPowerOfTwoBitwise(6)
| Step | Expression | Binary | Result |
|---|---|---|---|
| 1 | n | 0110 | 6 |
| 2 | n - 1 | 0101 | 5 |
| 3 | 6 AND 5 | 0100 | 4 |
| 4 | 4 === 0 | — | false |
Final result: false
Recursion
6. Recursive Fibonacci
Time: O(2ⁿ) · Space: O(n)
Each call branches into two more calls. Overlapping subproblems are recomputed — that is why this is slow.
function recursiveFibonacci(n) {
if (n < 2) return n
return recursiveFibonacci(n - 1) + recursiveFibonacci(n - 2)
}Handwritten trace — recursiveFibonacci(4)
Call tree (left child = n-1, right child = n-2):
recursiveFibonacci(4)
├─ recursiveFibonacci(3)
│ ├─ recursiveFibonacci(2)
│ │ ├─ recursiveFibonacci(1) → 1
│ │ └─ recursiveFibonacci(0) → 0
│ │ → 1
│ └─ recursiveFibonacci(1) → 1
│ → 2
└─ recursiveFibonacci(2)
├─ recursiveFibonacci(1) → 1
└─ recursiveFibonacci(0) → 0
→ 1| Return frame | Value |
|---|---|
fib(1) / fib(0) | 1 / 0 |
fib(2) = 1 + 0 | 1 |
fib(3) = 1 + 1 | 2 |
fib(4) = 2 + 1 | 3 |
Final result: 3
fib(2) is computed twice. That duplication is the O(2ⁿ) cost. Prefer the iterative version above, or memoize.
Function calls with output
recursiveFibonacci(0) // → 0
recursiveFibonacci(1) // → 1
recursiveFibonacci(6) // → 87. Recursive factorial
Time: O(n) · Space: O(n)
Unwind until 0! = 1, then multiply on the way back.
function recursiveFactorial(n) {
if (n === 0) return 1
return n * recursiveFactorial(n - 1)
}Handwritten trace — recursiveFactorial(4)
recursiveFactorial(4)
├─ recursiveFactorial(3)
│ ├─ recursiveFactorial(2)
│ │ ├─ recursiveFactorial(1)
│ │ │ └─ recursiveFactorial(0) → 1
│ │ │ → 1 * 1 = 1
│ │ └─ 2 * 1 = 2
│ └─ 3 * 2 = 6
└─ 4 * 6 = 24| Call | Waiting on | Returns |
|---|---|---|
fact(0) | base | 1 |
fact(1) | 1 * fact(0) | 1 |
fact(2) | 2 * fact(1) | 2 |
fact(3) | 3 * fact(2) | 6 |
fact(4) | 4 * fact(3) | 24 |
Final result: 24
Function calls with output
recursiveFactorial(0) // → 1
recursiveFactorial(1) // → 1
recursiveFactorial(5) // → 1208. Recursive binary search
Time: O(log n) · Space: O(log n)
Same idea as iterative binary search, but each half is a new stack frame. Works only on a sorted array.
function recursiveBinarySearch(arr, target) {
return search(arr, target, 0, arr.length - 1)
}
function search(arr, target, leftIndex, rightIndex) {
if (leftIndex > rightIndex) return -1
const middleIndex = Math.floor((leftIndex + rightIndex) / 2)
if (target === arr[middleIndex]) return middleIndex
if (target < arr[middleIndex]) {
return search(arr, target, leftIndex, middleIndex - 1)
}
return search(arr, target, middleIndex + 1, rightIndex)
}Visualizer: Binary Search
Handwritten trace — recursiveBinarySearch([-5, 2, 4, 6, 10], 6)
| Step | Call | Middle | arr[mid] | Compare | Next |
|---|---|---|---|---|---|
| 1 | search(arr, 6, 0, 4) | 2 | 4 | 4 < 6 | search right side |
| 2 | search(arr, 6, 3, 4) | 3 | 6 | 6 === 6 | return 3 |
Final result: 3
Function calls with output
recursiveBinarySearch([-5, 2, 4, 6, 10], 10) // → 4
recursiveBinarySearch([-5, 2, 4, 6, 10], 6) // → 3
recursiveBinarySearch([-5, 2, 4, 6, 10], 20) // → -19. Tower of Hanoi
Time: O(2ⁿ) · Space: O(n)
Move n disks from A to C using B. Never put a larger disk on a smaller one. Total moves = 2ⁿ − 1.
function towerOfHanoi(n, fromRod, toRod, usingRod) {
if (n === 1) {
console.log(`Move disk 1 from ${fromRod} to ${toRod}`)
return
}
towerOfHanoi(n - 1, fromRod, usingRod, toRod)
console.log(`Move disk ${n} from ${fromRod} to ${toRod}`)
towerOfHanoi(n - 1, usingRod, toRod, fromRod)
}Handwritten trace — towerOfHanoi(3, 'A', 'C', 'B')
| Step | Call | Action |
|---|---|---|
| 1 | hanoi(3, A, C, B) | move 2 disks A → B using C |
| 2 | hanoi(2, A, B, C) | move 1 disk A → C using B |
| 3 | hanoi(1, A, C, B) | move disk 1 from A to C |
| 4 | — | move disk 2 from A to B |
| 5 | hanoi(1, C, B, A) | move disk 1 from C to B |
| 6 | — | move disk 3 from A to C |
| 7 | hanoi(2, B, C, A) | move 2 disks B → C using A |
| 8 | hanoi(1, B, A, C) | move disk 1 from B to A |
| 9 | — | move disk 2 from B to C |
| 10 | hanoi(1, A, C, B) | move disk 1 from A to C |
Final move list: A→C, A→B, C→B, A→C, B→A, B→C, A→C
Search & sort
10. Linear search
Time: O(n) · Space: O(1)
Walk left to right. Return the index on match, otherwise -1. Works on unsorted data.
Visualizer: Linear Search
function linearSearch(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) return i
}
return -1
}Handwritten trace — linearSearch([-5, 2, 10, 4, 6], 10)
| Step | i | arr[i] | Compare | Result |
|---|---|---|---|---|
| 1 | 0 | -5 | -5 === 10? false | continue |
| 2 | 1 | 2 | 2 === 10? false | continue |
| 3 | 2 | 10 | 10 === 10? true | return 2 |
Final result: 2
Function calls with output
linearSearch([-5, 2, 10, 4, 6], 10) // → 2
linearSearch([-5, 2, 10, 4, 6], 6) // → 4
linearSearch([-5, 2, 10, 4, 6], 20) // → -111. Binary search
Time: O(log n) · Space: O(1)
Sorted array only. Each step halves the remaining window.
Visualizer: Binary Search
function binarySearch(arr, target) {
let leftIndex = 0
let rightIndex = arr.length - 1
while (leftIndex <= rightIndex) {
const middleIndex = Math.floor((leftIndex + rightIndex) / 2)
if (arr[middleIndex] === target) return middleIndex
if (target < arr[middleIndex]) {
rightIndex = middleIndex - 1
} else {
leftIndex = middleIndex + 1
}
}
return -1
}Handwritten trace — binarySearch([-5, 2, 4, 6, 10], 6)
| Step | left | right | mid | arr[mid] | Comparison | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 4 | 2 | 4 | 4 < 6 | move left to 3 |
| 2 | 3 | 4 | 3 | 6 | 6 === 6 | return 3 |
Final result: 3
Function calls with output
binarySearch([-5, 2, 4, 6, 10], 10) // → 4
binarySearch([-5, 2, 4, 6, 10], 6) // → 3
binarySearch([-5, 2, 4, 6, 10], 20) // → -112. Bubble sort
Time: O(n²) · Space: O(1)
Swap adjacent pairs until a full pass makes no swaps. Largest value bubbles to the end each pass.
Visualizer: Bubble Sort
function bubbleSort(arr) {
let swapped
do {
swapped = false
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
const temp = arr[i]
arr[i] = arr[i + 1]
arr[i + 1] = temp
swapped = true
}
}
} while (swapped)
}Handwritten trace — bubbleSort([8, 20, -2, 4, -6])
| Pass | Array before | Comparison | Array after |
|---|---|---|---|
| 1 | [8, 20, -2, 4, -6] | 8 > 20? no | [8, 20, -2, 4, -6] |
| 20 > -2? yes | [8, -2, 20, 4, -6] | ||
| 20 > 4? yes | [8, -2, 4, 20, -6] | ||
| 20 > -6? yes | [8, -2, 4, -6, 20] | ||
| 2 | [8, -2, 4, -6, 20] | 8 > -2? yes | [-2, 8, 4, -6, 20] |
| 8 > 4? yes | [-2, 4, 8, -6, 20] | ||
| 8 > -6? yes | [-2, 4, -6, 8, 20] | ||
| 3 | [-2, 4, -6, 8, 20] | -2 > 4? no | [-2, 4, -6, 8, 20] |
| 4 > -6? yes | [-2, -6, 4, 8, 20] | ||
| 4 | [-2, -6, 4, 8, 20] | -2 > -6? yes | [-6, -2, 4, 8, 20] |
Final result: [-6, -2, 4, 8, 20]
13. Insertion sort
Time: O(n²) · Space: O(1)
Take one element at a time and insert it into the already-sorted left prefix.
Visualizer: Insertion Sort
function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
const numberToInsert = arr[i]
let j = i - 1
while (j >= 0 && arr[j] > numberToInsert) {
arr[j + 1] = arr[j]
j = j - 1
}
arr[j + 1] = numberToInsert
}
}Handwritten trace — insertionSort([8, 20, -2, 4, -6])
| Step | i | insert | j | Condition | Action / array |
|---|---|---|---|---|---|
| 1 | 1 | 20 | 0 | 8 > 20? | no — [8, 20, -2, 4, -6] |
| 2 | 2 | -2 | 1 | 20 > -2? | shift 20 |
| 0 | 8 > -2? | shift 8 | |||
| -1 | stop | [-2, 8, 20, 4, -6] | |||
| 3 | 3 | 4 | 2 | 20 > 4? | shift 20 |
| 1 | 8 > 4? | shift 8 | |||
| 0 | -2 > 4? | no — [-2, 4, 8, 20, -6] | |||
| 4 | 4 | -6 | 3..0 | all larger | shift all, insert at 0 |
[-6, -2, 4, 8, 20] |
Final result: [-6, -2, 4, 8, 20]
14. Merge sort
Time: O(n log n) · Space: O(n)
Split until length < 2, then merge sorted halves by always taking the smaller front value.
Visualizer: Merge Sort
function mergeSort(arr) {
if (arr.length < 2) return arr
const mid = Math.floor(arr.length / 2)
const leftArr = arr.slice(0, mid)
const rightArr = arr.slice(mid)
return merge(mergeSort(leftArr), mergeSort(rightArr))
}
function merge(leftArr, rightArr) {
const sortedArr = []
while (leftArr.length && rightArr.length) {
if (leftArr[0] <= rightArr[0]) sortedArr.push(leftArr.shift())
else sortedArr.push(rightArr.shift())
}
return [...sortedArr, ...leftArr, ...rightArr]
}Handwritten trace — mergeSort([8, 20, -2, 4, -6])
| Step | Call | Action / result |
|---|---|---|
| 1 | mergeSort([8,20,-2,4,-6]) | split [8,20] / [-2,4,-6] |
| 2 | mergeSort([8,20]) | split [8] / [20] |
| 3 | mergeSort([8]) | base → [8] |
| 4 | mergeSort([20]) | base → [20] |
| 5 | merge([8],[20]) | [8, 20] |
| 6 | mergeSort([-2,4,-6]) | split [-2] / [4,-6] |
| 7 | mergeSort([-2]) | base → [-2] |
| 8 | mergeSort([4,-6]) | split [4] / [-6] |
| 9 | merge([4],[-6]) | [-6, 4] |
| 10 | merge([-2],[-6,4]) | [-6, -2, 4] |
| 11 | merge([8,20],[-6,-2,4]) | [-6, -2, 4, 8, 20] |
Final result: [-6, -2, 4, 8, 20]
15. Quick sort
Time: O(n log n) average, O(n²) worst · Space: O(log n) average
Last element is the pivot. Smaller values go left, larger go right, then recurse.
Visualizer: Quick Sort
function quickSort(arr) {
if (arr.length < 2) return arr
const pivot = arr[arr.length - 1]
const left = []
const right = []
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] < pivot) left.push(arr[i])
else right.push(arr[i])
}
return [...quickSort(left), pivot, ...quickSort(right)]
}Handwritten trace — quickSort([8, 20, -2, 4, -6])
| Step | Current array | Pivot | Left | Right | Result piece |
|---|---|---|---|---|---|
| 1 | [8, 20, -2, 4, -6] | -6 | [] | [8, 20, -2, 4] | pivot sits first |
| 2 | [8, 20, -2, 4] | 4 | [-2] | [8, 20] | recurse both sides |
| 3 | [-2] | -2 | [] | [] | base |
| 4 | [8, 20] | 20 | [8] | [] | base on 8 |
| 5 | combine | — | — | — | [-6] + [-2, 4, 8, 20] |
Final result: [-6, -2, 4, 8, 20]
Combinatorics & DP
16. Cartesian product
Time: O(m · n) · Space: O(m · n)
Every pair from two arrays. Nested loops, one pair per inner iteration.
function cartesianProduct(arr1, arr2) {
const result = []
for (let i = 0; i < arr1.length; i++) {
for (let j = 0; j < arr2.length; j++) {
result.push([arr1[i], arr2[j]])
}
}
return result
}Handwritten trace — cartesianProduct([1, 2], [3, 4, 5])
| Step | i | j | Pair | Result so far |
|---|---|---|---|---|
| 1 | 0 | 0 | [1,3] | [[1,3]] |
| 2 | 0 | 1 | [1,4] | [[1,3],[1,4]] |
| 3 | 0 | 2 | [1,5] | [[1,3],[1,4],[1,5]] |
| 4 | 1 | 0 | [2,3] | [[1,3],[1,4],[1,5],[2,3]] |
| 5 | 1 | 1 | [2,4] | [[1,3],[1,4],[1,5],[2,3],[2,4]] |
| 6 | 1 | 2 | [2,5] | [[1,3],[1,4],[1,5],[2,3],[2,4],[2,5]] |
Final result: [[1, 3], [1, 4], [1, 5], [2, 3], [2, 4], [2, 5]]
17. Climbing staircase
Time: O(n) · Space: O(n)
Ways to climb n steps taking 1 or 2 at a time. Same recurrence as Fibonacci: ways(i) = ways(i-1) + ways(i-2).
function climbingStaircase(n) {
const noOfWays = [1, 2]
for (let i = 2; i <= n; i++) {
noOfWays[i] = noOfWays[i - 1] + noOfWays[i - 2]
}
return noOfWays[n - 1]
}Handwritten trace — climbingStaircase(5)
| Step | i | Before | Formula | After |
|---|---|---|---|---|
| 1 | 2 | [1, 2] | 2 = 1 + 1 | [1, 2, 2] |
| 2 | 3 | [1, 2, 2] | 3 = 2 + 1 | [1, 2, 2, 3] |
| 3 | 4 | [1, 2, 2, 3] | 5 = 3 + 2 | [1, 2, 2, 3, 5] |
| 4 | 5 | [1, 2, 2, 3, 5] | 8 = 5 + 3 | [1, 2, 2, 3, 5, 8] |
Return noOfWays[4] → 8
Function calls with output
climbingStaircase(1) // → 1
climbingStaircase(2) // → 2
climbingStaircase(3) // → 3
climbingStaircase(4) // → 5
climbingStaircase(5) // → 8Stack
LIFO — last in, first out. Visualizer: Stacks
18. Stack with array
Push / pop / peek: O(1) · Space: O(n)
Array end is the top. Native push / pop are amortized O(1).
class Stack {
constructor() {
this.arr = []
}
push(value) {
this.arr.push(value)
return this.arr
}
pop() {
this.arr.pop()
return this.arr
}
lookup() {
return this.arr[this.arr.length - 1]
}
}Handwritten trace
| Step | Operation | Stack state | Return |
|---|---|---|---|
| 1 | new Stack | [] | created |
| 2 | push(5) | [5] | [5] |
| 3 | push(10) | [5, 10] | [5, 10] |
| 4 | push(15) | [5, 10, 15] | [5, 10, 15] |
| 5 | push(20) | [5, 10, 15, 20] | array |
| 6 | push(25) | [5, 10, 15, 20, 25] | array |
| 7 | pop() | [5, 10, 15, 20] | array |
| 8 | pop() | [5, 10, 15] | array |
| 9 | pop() | [5, 10] | array |
| 10 | lookup() | [5, 10] | 10 |
19. Stack with object
Push / pop / peek: O(1) · Space: O(n)
A count cursor is the top key. No array shifting.
class Stack {
constructor() {
this.database = {}
this.count = 0
}
push(value) {
this.count++
this.database[this.count] = value
return this.database
}
pop() {
delete this.database[this.count]
this.count--
return this.database
}
peek() {
return this.database[this.count]
}
}Handwritten trace
| Step | Operation | count | database | Return |
|---|---|---|---|---|
| 1 | new Stack | 0 | empty | created |
| 2 | push(5) | 1 | 1: 5 | object |
| 3 | push(10) | 2 | 1: 5, 2: 10 | object |
| 4 | push(15) | 3 | 1: 5, 2: 10, 3: 15 | object |
| 5 | push(20) | 4 | keys 1–4 | object |
| 6 | push(25) | 5 | keys 1–5, top = 25 | object |
| 7 | pop() | 4 | key 5 deleted | object |
| 8 | pop() | 3 | key 4 deleted | object |
| 9 | peek() | 3 | unchanged | 15 |
20. Linked-list stack
Push: O(1) · Pop: O(n) in this version · Space: O(n)
push prepends at head (O(1)). pop uses removeFromEnd — that walk is O(n). A proper list stack pops from the front.
class LinkedListStack {
constructor() {
this.list = new LinkedList() // head + tail
}
push(value) {
this.list.prepend(value) // O(1)
}
pop() {
return this.list.removeFromEnd() // O(n) — suboptimal
}
peek() {
return this.list.head.value
}
}Handwritten trace
| Step | Operation | List (head → tail) | size | Return | Time |
|---|---|---|---|---|---|
| 1 | new Stack | empty | 0 | created | O(1) |
| 2 | isEmpty() | empty | 0 | true | O(1) |
| 3 | push(10) | 10 | 1 | — | O(1) |
| 4 | push(20) | 20 → 10 | 2 | — | O(1) |
| 5 | push(30) | 30 → 20 → 10 | 3 | — | O(1) |
| 6 | push(40) | 40 → 30 → 20 → 10 | 4 | — | O(1) |
| 7 | pop() | 40 → 30 → 20 | 3 | 10 | O(n) |
| 8 | peek() | 40 → 30 → 20 | 3 | 40 | O(1) |
Queue
FIFO — first in, first out. Visualizer: Queues
21. Array queue
Enqueue: O(1) · Dequeue: O(n) · Space: O(n)
push at the end is cheap. shift from the front moves every remaining element.
class Queue {
constructor() {
this.items = []
}
enqueue(element) {
this.items.push(element)
}
dequeue() {
return this.items.shift()
}
isEmpty() {
return this.items.length === 0
}
peek() {
return this.isEmpty() ? null : this.items[0]
}
size() {
return this.items.length
}
}Handwritten trace
| Step | Operation | Queue state | Return |
|---|---|---|---|
| 1 | new Queue() | [] | created |
| 2 | isEmpty() | [] | true |
| 3 | enqueue(10) | [10] | — |
| 4 | enqueue(20) | [10, 20] | — |
| 5 | enqueue(30) | [10, 20, 30] | — |
| 6 | size() | [10, 20, 30] | 3 |
| 7 | dequeue() | [20, 30] | 10 |
| 8 | peek() | [20, 30] | 20 |
dequeue removes the front (FIFO). peek shows the next dequeue without removing it.
22. Optimized object queue
Enqueue / dequeue: O(1) · Space: O(n)
front and rear pointers — dequeue increments front instead of shifting.
class Queue {
constructor() {
this.items = {}
this.rear = 0
this.front = 0
}
enqueue(element) {
this.items[this.rear] = element
this.rear++
}
dequeue() {
const item = this.items[this.front]
delete this.items[this.front]
this.front++
return item
}
isEmpty() {
return this.rear - this.front === 0
}
peek() {
return this.items[this.front]
}
size() {
return this.rear - this.front
}
}Handwritten trace
| Step | Operation | items | front | rear | Return |
|---|---|---|---|---|---|
| 1 | new Queue() | empty | 0 | 0 | created |
| 2 | isEmpty() | empty | 0 | 0 | true |
| 3 | enqueue(10) | 0: 10 | 0 | 1 | — |
| 4 | enqueue(20) | 0: 10, 1: 20 | 0 | 2 | — |
| 5 | enqueue(30) | 0: 10, 1: 20, 2: 30 | 0 | 3 | — |
| 6 | size() | same | 0 | 3 | 3 |
| 7 | dequeue() | 1: 20, 2: 30 | 1 | 3 | 10 |
| 8 | peek() | same | 1 | 3 | 20 |
size() = rear - front = 3 - 1 = 2 after the dequeue.
23. Circular queue
Enqueue / dequeue: O(1) · Space: O(capacity)
Fixed buffer. rear = (rear + 1) % capacity wraps into slots freed by dequeue — no wasted holes at the front.
class CircularQueue {
constructor(capacity) {
this.items = new Array(capacity)
this.capacity = capacity
this.currentLength = 0
this.rear = -1
this.front = -1
}
isFull() {
return this.currentLength === this.capacity
}
isEmpty() {
return this.currentLength === 0
}
enqueue(element) {
if (this.isFull()) return
this.rear = (this.rear + 1) % this.capacity
this.items[this.rear] = element
this.currentLength += 1
if (this.front === -1) this.front = this.rear
}
dequeue() {
if (this.isEmpty()) return null
const item = this.items[this.front]
this.items[this.front] = null
this.front = (this.front + 1) % this.capacity
this.currentLength -= 1
if (this.isEmpty()) {
this.front = -1
this.rear = -1
}
return item
}
}Handwritten trace — CircularQueue(capacity = 5)
| Step | Operation | front | rear | len | items | Return |
|---|---|---|---|---|---|---|
| 1 | new CircularQueue(5) | -1 | -1 | 0 | empty slots | created |
| 2 | isEmpty() | -1 | -1 | 0 | — | true |
| 3 | enqueue(10) | 0 | 0 | 1 | [10, _, _, _, _] | — |
| 4 | enqueue(20) | 0 | 1 | 2 | [10, 20, _, _, _] | — |
| 5 | enqueue(30) | 0 | 2 | 3 | [10, 20, 30, _, _] | — |
| 6 | enqueue(40) | 0 | 3 | 4 | [10, 20, 30, 40, _] | — |
| 7 | enqueue(50) | 0 | 4 | 5 | [10, 20, 30, 40, 50] | — |
| 8 | isFull() | 0 | 4 | 5 | full | true |
| 9 | dequeue() | 1 | 4 | 4 | [null, 20, 30, 40, 50] | 10 |
| 10 | peek() | 1 | 4 | 4 | same | 20 |
| 11 | enqueue(60) | 1 | 0 | 5 | [60, 20, 30, 40, 50] | — |
Step 11: (4 + 1) % 5 = 0 — rear wraps and reuses index 0.
24. Linked-list queue
Enqueue: O(n) with head-only list · Dequeue: O(1) · Space: O(n)
append walks to the tail. removeFromFront is O(1). A tail pointer makes enqueue O(1) too (next section).
class LinkedListQueue {
constructor() {
this.list = new LinkedList()
}
enqueue(value) {
this.list.append(value) // O(n) without tail
}
dequeue() {
return this.list.removeFromFront() // O(1)
}
peek() {
return this.list.head.value
}
}Handwritten trace
| Step | Operation | Queue (head → tail) | size | Return | Time |
|---|---|---|---|---|---|
| 1 | new Queue() | empty | 0 | created | O(1) |
| 2 | isEmpty() | empty | 0 | true | O(1) |
| 3 | enqueue(10) | 10 | 1 | — | O(1) |
| 4 | enqueue(20) | 10 → 20 | 2 | — | O(n) |
| 5 | enqueue(30) | 10 → 20 → 30 | 3 | — | O(n) |
| 6 | dequeue() | 20 → 30 | 2 | 10 | O(1) |
| 7 | peek() | 20 → 30 | 2 | 20 | O(1) |
| Design | Enqueue | Dequeue |
|---|---|---|
| Array queue | O(1) | O(n) |
| This linked-list queue | O(n) | O(1) |
| Circular queue | O(1) | O(1) |
| List with tail | O(1) | O(1) |
Linked list
Visualizer: Linked Lists · Operations
25. Singly linked list
prepend: O(1) · append / insert / remove / search / reverse: O(n) · Space: O(n)
Head-only list. insert at 0 is prepend; otherwise walk to index - 1 and relink.
class Node {
constructor(value) {
this.value = value
this.next = null
}
}
class LinkedList {
constructor() {
this.head = null
this.size = 0
}
prepend(value) {
const node = new Node(value)
if (!this.isEmpty()) node.next = this.head
this.head = node
this.size++
}
insert(value, index) {
if (index < 0 || index > this.size) return
if (index === 0) return this.prepend(value)
const node = new Node(value)
let prev = this.head
for (let i = 0; i < index - 1; i++) prev = prev.next
node.next = prev.next
prev.next = node
this.size++
}
reverse() {
let prev = null
let curr = this.head
while (curr) {
const next = curr.next
curr.next = prev
prev = curr
curr = next
}
this.head = prev
}
}| Operation | Time | Why |
|---|---|---|
prepend | O(1) | rewrite head |
append | O(n) | walk to last node |
insert at index | O(n) | walk to previous |
removeFrom | O(n) | walk to previous |
search | O(n) | linear scan |
reverse | O(n) | one pointer pass |
Handwritten trace — insert sequence
| Step | Operation | List | size | Notes |
|---|---|---|---|---|
| 1 | new LinkedList | empty | 0 | head = null |
| 2 | insert(10, 0) | 10 | 1 | prepend |
| 3 | insert(20, 0) | 20 → 10 | 2 | prepend |
| 4 | insert(30, 1) | 20 → 30 → 10 | 3 | prev at 20, splice 30 |
| 5 | insert(40, 2) | 20 → 30 → 40 → 10 | 4 | prev at 30, splice 40 |
| 6 | reverse() | 10 → 40 → 30 → 20 | 4 | three-pointer walk |
26. Linked list with tail
prepend / append / removeFromFront: O(1) · removeFromEnd: O(n) · Space: O(n)
Tail makes append O(1). Removing the last node still needs the second-to-last pointer — that is the singly-linked tax.
class LinkedList {
constructor() {
this.head = null
this.tail = null
this.size = 0
}
prepend(value) {
const node = new Node(value)
if (this.isEmpty()) {
this.head = node
this.tail = node
} else {
node.next = this.head
this.head = node
}
this.size++
}
append(value) {
const node = new Node(value)
if (this.isEmpty()) {
this.head = node
this.tail = node
} else {
this.tail.next = node
this.tail = node
}
this.size++
}
removeFromFront() {
if (this.isEmpty()) return null
const value = this.head.value
this.head = this.head.next
this.size--
return value
}
removeFromEnd() {
if (this.isEmpty()) return null
const value = this.tail.value
if (this.size === 1) {
this.head = null
this.tail = null
} else {
let prev = this.head
while (prev.next !== this.tail) prev = prev.next
prev.next = null
this.tail = prev
}
this.size--
return value
}
}Handwritten trace
| Step | Operation | head | tail | List | size | Return |
|---|---|---|---|---|---|---|
| 1 | new LinkedList() | null | null | empty | 0 | created |
| 2 | append(1) | 1 | 1 | 1 | 1 | — |
| 3 | append(2) | 1 | 2 | 1 → 2 | 2 | — |
| 4 | append(3) | 1 | 3 | 1 → 2 → 3 | 3 | — |
| 5 | prepend(0) | 0 | 3 | 0 → 1 → 2 → 3 | 4 | — |
| 6 | removeFromFront() | 1 | 3 | 1 → 2 → 3 | 3 | 0 |
| 7 | removeFromEnd() | 1 | 2 | 1 → 2 | 2 | 3 |
| Variant | append | removeFromEnd |
|---|---|---|
| Head only | O(n) | O(n) |
| Head + tail | O(1) | O(n) |
| Doubly linked | O(1) | O(1) |
27. Doubly linked list
push / pop / unshift / shift: O(1) · Space: O(n)
Each node has prev and next. Tail pop does not scan — it follows tail.prev.
class Node {
constructor(value) {
this.value = value
this.next = null
this.prev = null
}
}
class DoublyLinkedList {
constructor() {
this.head = null
this.tail = null
this.length = 0
}
push(value) {
const newNode = new Node(value)
if (!this.head) {
this.head = newNode
this.tail = newNode
} else {
this.tail.next = newNode
newNode.prev = this.tail
this.tail = newNode
}
this.length++
return this
}
pop() {
if (!this.head) return null
const popped = this.tail
if (this.length === 1) {
this.head = null
this.tail = null
} else {
this.tail = popped.prev
this.tail.next = null
popped.prev = null
}
this.length--
return popped
}
unshift(value) {
const newNode = new Node(value)
if (!this.head) {
this.head = newNode
this.tail = newNode
} else {
newNode.next = this.head
this.head.prev = newNode
this.head = newNode
}
this.length++
return newNode
}
shift() {
if (!this.head) return null
const oldHead = this.head
if (this.length === 1) {
this.head = null
this.tail = null
} else {
this.head = oldHead.next
oldHead.next = null
this.head.prev = null
}
this.length--
return oldHead
}
}Handwritten trace — push(10), push(15), push(20), shift(), unshift('world!'), unshift('hello')
| Step | Operation | List (head → tail) | length | Return |
|---|---|---|---|---|
| 1 | push(10) | 10 | 1 | list |
| 2 | push(15) | 10 ⇄ 15 | 2 | list |
| 3 | push(20) | 10 ⇄ 15 ⇄ 20 | 3 | list |
| 4 | shift() | 15 ⇄ 20 | 2 | node 10 |
| 5 | unshift('world!') | world! ⇄ 15 ⇄ 20 | 3 | new head |
| 6 | unshift('hello') | hello ⇄ world! ⇄ 15 ⇄ 20 | 4 | new head |
showList() → ['hello', 'world!', 15, 20]
Hash, graph, tree
28. Hash table
set / get / remove: O(k) average, O(n) worst · Space: O(n + m)
Hash = sum of character codes % table.size. Collisions use separate chaining (an array of [key, value] pairs per bucket).
Visualizer: Hash Tables
class HashTable {
constructor(size) {
this.table = new Array(size)
this.size = size
}
hash(key) {
let total = 0
for (let i = 0; i < key.length; i++) total += key.charCodeAt(i)
return total % this.size
}
set(key, value) {
const index = this.hash(key)
const bucket = this.table[index]
if (!bucket) {
this.table[index] = [[key, value]]
return
}
const sameKeyItem = bucket.find((item) => item[0] === key)
if (sameKeyItem) sameKeyItem[1] = value
else bucket.push([key, value])
}
get(key) {
const bucket = this.table[this.hash(key)]
const sameKeyItem = bucket?.find((item) => item[0] === key)
return sameKeyItem ? sameKeyItem[1] : undefined
}
remove(key) {
const bucket = this.table[this.hash(key)]
const sameKeyItem = bucket?.find((item) => item[0] === key)
if (sameKeyItem) return bucket.splice(bucket.indexOf(sameKeyItem), 1)
}
}Hash math for size 50:
| Key | Character codes | Sum | sum % 50 |
|---|---|---|---|
name | 110+97+109+101 | 417 | 17 |
age | 97+103+101 | 301 | 1 |
mane | 109+97+110+101 | 417 | 17 |
name and mane collide at bucket 17.
Handwritten trace — new HashTable(50)
| Step | Operation | Index | Table state | Result |
|---|---|---|---|---|
| 1 | set('name', 'Bruce') | 17 | table[17] = [['name','Bruce']] | — |
| 2 | set('age', 25) | 1 | table[1] = [['age', 25]] | — |
| 3 | display() | — | prints buckets 1 and 17 | — |
| 4 | get('name') | 17 | find 'name' in chain | 'Bruce' |
| 5 | set('mane', 'Clark') | 17 | [['name','Bruce'], ['mane','Clark']] | collision |
| 6 | set('name', 'Diana') | 17 | update Bruce → Diana | update |
| 7 | remove('name') | 17 | [['mane','Clark']] | removed |
| 8 | display() | — | 1 → age, 17 → mane | — |
| Operation | Average | Worst | Extra space |
|---|---|---|---|
hash | O(k) | O(k) | O(1) |
set | O(k) | O(n) | O(1) |
get | O(k) | O(n) | O(1) |
remove | O(k) | O(n) | O(1) |
k = key length, n = chain length if every key lands in one bucket.
29. Undirected graph
addVertex / addEdge / hasEdge: O(1) average · removeVertex: O(d) · display: O(V + E) · Space: O(V + E)
Adjacency list: each vertex maps to a Set of neighbors. Edges are stored in both directions.
Visualizer: Graphs
class Graph {
constructor() {
this.adjacencyList = {}
}
addVertex(vertex) {
if (!this.adjacencyList[vertex]) this.adjacencyList[vertex] = new Set()
}
addEdge(vertex1, vertex2) {
this.addVertex(vertex1)
this.addVertex(vertex2)
this.adjacencyList[vertex1].add(vertex2)
this.adjacencyList[vertex2].add(vertex1)
}
removeEdge(vertex1, vertex2) {
this.adjacencyList[vertex1]?.delete(vertex2)
this.adjacencyList[vertex2]?.delete(vertex1)
}
removeVertex(vertex) {
if (!this.adjacencyList[vertex]) return
for (const adjacentVertex of this.adjacencyList[vertex]) {
this.removeEdge(vertex, adjacentVertex)
}
delete this.adjacencyList[vertex]
}
hasEdge(vertex1, vertex2) {
return !!this.adjacencyList[vertex1]?.has(vertex2) && !!this.adjacencyList[vertex2]?.has(vertex1)
}
}Handwritten trace
| Step | Operation | Internal change | State |
|---|---|---|---|
| 1 | addVertex(A/B/C) | three empty Sets | A:[], B:[], C:[] |
| 2 | addEdge(A, B) | A adds B, B adds A | A:[B], B:[A] |
| 2b | addEdge(B, C) | B adds C, C adds B | A:[B], B:[A,C], C:[B] |
| 3 | display() | walk lists | A→B; B→A,C; C→B |
| 4 | hasEdge(A, B) | both Sets contain each other | true |
| 5 | removeVertex(B) | drop B–A and B–C, delete B | A:[], C:[]; B gone |
| 6 | display() | remaining vertices | A→ ; C→ |
Before removal: A ←→ B ←→ C. After removeVertex(B): A and C are isolated.
| Operation | Time | Space |
|---|---|---|
addVertex | O(1) avg | O(1) vertex |
addEdge | O(1) avg | O(1) edge |
removeEdge | O(1) avg | O(1) |
removeVertex | O(d) avg | O(1) extra |
hasEdge | O(1) avg | O(1) |
display | O(V + E) | O(1) extra |
30. Binary search tree
insert / search / delete: O(log n) average, O(n) worst · traversals: O(n) · Space: O(n)
Left child < parent < right child. This walk uses inserts 10, 5, 15, 3 then deletes the leaf 3.
Visualizer: Binary Search Trees · Pre-order
class Node {
constructor(value) {
this.value = value
this.left = null
this.right = null
}
}
class BinarySearchTree {
constructor() {
this.root = null
}
insert(value) {
const newNode = new Node(value)
if (!this.root) this.root = newNode
else this.insertNode(this.root, newNode)
}
insertNode(root, newNode) {
if (newNode.value < root.value) {
if (!root.left) root.left = newNode
else this.insertNode(root.left, newNode)
} else {
if (!root.right) root.right = newNode
else this.insertNode(root.right, newNode)
}
}
search(root, value) {
if (!root) return false
if (root.value === value) return true
if (value < root.value) return this.search(root.left, value)
return this.search(root.right, value)
}
min(root) {
return root.left ? this.min(root.left) : root.value
}
delete(value) {
this.root = this.deleteNode(this.root, value)
}
deleteNode(root, value) {
if (!root) return root
if (value < root.value) root.left = this.deleteNode(root.left, value)
else if (value > root.value) root.right = this.deleteNode(root.right, value)
else {
if (!root.left && !root.right) return null
if (!root.left) return root.right
if (!root.right) return root.left
root.value = this.min(root.right)
root.right = this.deleteNode(root.right, root.value)
}
return root
}
levelOrder() {
const queue = [this.root]
while (queue.length) {
const curr = queue.shift()
console.log(curr.value)
if (curr.left) queue.push(curr.left)
if (curr.right) queue.push(curr.right)
}
}
}Tree after each insert:
insert(10) insert(5) insert(15) insert(3)
10 10 10 10
/ / \ / \
5 5 15 5 15
/
3Handwritten trace
| Step | Operation | Path | Result |
|---|---|---|---|
| 1 | isEmpty() | root is null | true |
| 2 | insert(10) | empty | 10 becomes root |
| 3 | insert(5) | 5 < 10, left null | 5 is left of 10 |
| 4 | insert(15) | 15 > 10, right null | 15 is right of 10 |
| 5 | insert(3) | 3 < 10, 3 < 5, left null | 3 is left of 5 |
| 6 | levelOrder() | queue 10 → 5,15 → 3 | prints 10, 5, 15, 3 |
| 7 | delete(3) | 3 < 10, 3 < 5, leaf | 5.left = null |
| 8 | levelOrder() | queue 10 → 5,15 | prints 10, 5, 15 |
| Operation | Average | Worst | Recursion stack |
|---|---|---|---|
insert | O(log n) | O(n) | O(h) |
search | O(log n) | O(n) | O(h) |
delete | O(log n) | O(n) | O(h) |
pre/in/post | O(n) | O(n) | O(h) |
levelOrder | O(n) | O(n) | O(w) |
min / max | O(log n) | O(n) | O(h) |
h = height, w = max width. Sorted inserts degrade the tree into a linked list — that is why AVL / red-black trees rebalance. See AVL Trees.
An iterative addChild variant (no recursion) walks with a while loop and rejects duplicates — same BST rule, different call stack.
Summary
This notebook is the code-evolution implementations, indexed the same way as the rest of the DSA series, with the handwritten tracers converted to tables.
| Family | Takeaway |
|---|---|
| Math | Loop when you can; √n and bit tricks matter |
| Recursion | Trace the stack — Fibonacci without memo is O(2ⁿ) |
| Search / sort | Binary search needs sorted input; merge is stable n log n |
| Stack / queue | Array shift is O(n); pointers or a ring buffer fix it |
| Linked list | Tail pointer buys O(1) append, not O(1) tail delete |
| Hash / graph / BST | Hash chains collisions; BST height is the real cost |
Next reads: Big O Notation · 90 Interview Coding Problems · DSA Arrays
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime