Loading learning content...
In a pointer-based tree, finding a node's parent or children is trivial: just follow the pointer. In an array-based heap, we have no pointers—only indices. Yet we need to navigate the implicit tree structure fluently: moving up to parents during bubble-up operations, moving down to children during bubble-down operations.
The solution is index arithmetic. Simple mathematical formulas relate any node's index to its parent and children. These formulas are the operational heart of array-based heaps. Every insert, every extract, every heap operation relies on these relationships.
While the formulas themselves are simple, understanding their derivation builds intuition for why they work. And choosing between 0-based and 1-based indexing—a seemingly minor decision—has implications for formula elegance and implementation clarity.
By the end of this page, you will: (1) Derive the parent-child index formulas from first principles, (2) Master both 0-based and 1-based conventions, (3) Understand why 1-based indexing yields cleaner formulas, (4) Know how to handle boundary conditions (root has no parent, leaves have no children), and (5) Implement robust helper functions for tree navigation.
Let's state the formulas upfront, then derive and explore them in depth.
For 1-based indexing (root at index 1):
For 0-based indexing (root at index 0):
| Relationship | 1-Based (root = 1) | 0-Based (root = 0) |
|---|---|---|
| Parent of i | ⌊i/2⌋ | ⌊(i-1)/2⌋ |
| Left child of i | 2i | 2i + 1 |
| Right child of i | 2i + 1 | 2i + 2 |
| Is left child? | i is even | i is odd |
| Is right child? | i is odd (and i > 1) | i is even (and i > 0) |
Observe that 1-based indexing produces cleaner, more symmetric formulas. The parent is simply half the index; the left child is double the index. This elegance isn't coincidental—it reflects the binary structure of the tree mapping naturally to binary arithmetic.
However, most programming languages use 0-based array indexing. Using 1-based indexing requires either wasting index 0 or adding offset adjustments throughout the code. We'll explore both approaches.
For 1-based: Parent = half, Children = double. For 0-based: Think of it as 1-based formulas with offset corrections. Parent subtracts 1 before halving; children add 1 or 2 after doubling.
The 1-based formulas aren't arbitrary—they emerge naturally from the level-order numbering of a complete binary tree. Let's derive them from first principles.
Setup:
With 1-based indexing:
1234567
[1] Level 0: 1 node / \ [2] [3] Level 1: 2 nodes / \ / \ [4] [5] [6] [7] Level 2: 4 nodes / \ / \ / \ / \ [8][9][10][11][12][13][14][15] Level 3: 8 nodesDeriving the child formulas:
Consider node at index i on level k. Its position within level k is (i - 2^k).
The left child is at level k+1, and its position within that level is 2(i - 2^k) (because each node in level k spawns two nodes in level k+1, maintaining left-to-right order).
The index of the left child:
The right child is simply one position after the left child: 2i + 1.
The formula 'left child = 2i' corresponds to left-shifting in binary: 2i = i << 1. Similarly, 'right child = 2i+1' is a left-shift followed by setting the lowest bit: (i << 1) | 1. The binary representation of a node's index encodes the path from root to that node!
Deriving the parent formula:
The parent formula is the inverse of the child formulas. If node p has left child 2p and right child 2p+1, then:
Both cases are captured by integer division: ⌊i/2⌋.
In programming terms, integer division of i by 2 automatically floors the result, handling both even and odd cases correctly.
Verification by example:
For index 11:
For index 6:
The 0-based formulas are derived by offsetting the 1-based formulas. The relationship is:
If 1-based index is j, then 0-based index is i = j - 1, so j = i + 1
Deriving children:
In 1-based terms, left child of node j is 2j. Converting:
Similarly, right child:
1234567
[0] Level 0: 1 node / \ [1] [2] Level 1: 2 nodes / \ / \ [3] [4] [5] [6] Level 2: 4 nodes / \ / \ / \ / \ [7][8][9][10][11][12][13][14] Level 3: 8 nodesDeriving parent:
In 1-based terms, parent of node j is ⌊j/2⌋. Converting:
Simplifying: ⌊(i + 1)/2⌋ - 1 = ⌊(i + 1 - 2)/2⌋ = ⌊(i - 1)/2⌋
Verification by example:
For index 10 (0-based):
For index 5 (0-based):
The most common bug in 0-based heap implementations is forgetting the offset. Using '2i' instead of '2i+1' for the left child, or 'i/2' instead of '(i-1)/2' for the parent, will produce incorrect results. Always verify your formulas against a diagram.
Let's translate these formulas into production-quality code with proper handling of edge cases.
Pattern 1: 0-based indexing (standard approach)
This is the most common pattern since most languages use 0-based arrays.
12345678910111213141516171819202122232425262728293031323334
class Heap<T> { private data: T[] = []; // Parent index (returns -1 for root to indicate no parent) private parent(i: number): number { if (i === 0) return -1; // Root has no parent return Math.floor((i - 1) / 2); } // Left child index (may be beyond array bounds) private leftChild(i: number): number { return 2 * i + 1; } // Right child index (may be beyond array bounds) private rightChild(i: number): number { return 2 * i + 2; } // Check if index has a left child private hasLeftChild(i: number): boolean { return this.leftChild(i) < this.data.length; } // Check if index has a right child private hasRightChild(i: number): boolean { return this.rightChild(i) < this.data.length; } // Check if index is a leaf node private isLeaf(i: number): boolean { return !this.hasLeftChild(i); }}Pattern 2: 1-based indexing with padding
Some implementations prefer the cleaner 1-based formulas by wasting index 0:
1234567891011121314151617181920212223242526272829303132333435363738
class Heap1Based<T> { private data: (T | null)[] = [null]; // Index 0 is unused padding // Actual heap size (excluding padding) get size(): number { return this.data.length - 1; } // Parent index (returns 0 for root, which is invalid) private parent(i: number): number { return Math.floor(i / 2); } // Left child index private leftChild(i: number): number { return 2 * i; } // Right child index private rightChild(i: number): number { return 2 * i + 1; } // Check if index has a left child private hasLeftChild(i: number): boolean { return this.leftChild(i) <= this.size; } // Check if index has a right child private hasRightChild(i: number): boolean { return this.rightChild(i) <= this.size; } // Access element at index (1-based) private get(i: number): T { return this.data[i] as T; }}For most practical purposes, 0-based indexing is preferred—it aligns with language conventions and doesn't waste space. However, 1-based indexing can be valuable for teaching, mathematical proofs, or when translating algorithms from textbooks that use 1-based notation.
The formulas compute indices mathematically, but not every computed index is valid. Several boundary conditions must be handled:
1. Root has no parent
The root (index 0 in 0-based, index 1 in 1-based) has no parent. Applying the parent formula returns an invalid result:
Always check before accessing the parent.
2. Leaves have no children
Leaf nodes have no children. The child formulas produce indices beyond the array bounds:
This last case is important: in a complete binary tree, the last internal node may have only a left child (the rightmost node on the second-to-last level).
1234567891011121314151617181920212223242526272829303132333435363738394041
class SafeHeap<T> { private data: T[] = []; // Safe parent access - returns undefined if no parent private getParentIndex(i: number): number | undefined { if (i <= 0) return undefined; return Math.floor((i - 1) / 2); } // Safe child access - returns undefined if no such child private getLeftChildIndex(i: number): number | undefined { const left = 2 * i + 1; return left < this.data.length ? left : undefined; } private getRightChildIndex(i: number): number | undefined { const right = 2 * i + 2; return right < this.data.length ? right : undefined; } // Get indices of all existing children (0, 1, or 2) private getChildIndices(i: number): number[] { const children: number[] = []; const left = this.getLeftChildIndex(i); const right = this.getRightChildIndex(i); if (left !== undefined) children.push(left); if (right !== undefined) children.push(right); return children; } // Find index of last non-leaf node // Useful for building heaps bottom-up private lastInternalNode(): number { return Math.floor(this.data.length / 2) - 1; } // Count of leaf nodes private leafCount(): number { return Math.ceil(this.data.length / 2); }}In a complete binary tree with n nodes, exactly ⌈n/2⌉ are leaves and ⌊n/2⌋ are internal nodes. This is useful for algorithms like heapify that only need to process internal nodes.
3. Empty heap
An empty heap (n = 0) has no valid indices. All operations should check for this condition before accessing any index.
4. Single-element heap
With only the root (n = 1), there is no parent and no children. The root is simultaneously the minimum/maximum and a leaf.
The heap navigation formulas involve multiplication by 2, division by 2, and addition of small constants. These operations map directly to efficient bit operations:
Multiplication by 2: Left shift
Division by 2 (integer): Right shift
Addition of 1: OR with 1
These aren't merely micro-optimizations—they reveal the underlying binary structure of heap indexing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// 1-based indexing with bit operationsclass BitHeap<T> { private data: T[] = []; // Parent: right shift by 1 private parent(i: number): number { return i >> 1; } // Left child: left shift by 1 private leftChild(i: number): number { return i << 1; } // Right child: left shift by 1, then set lowest bit private rightChild(i: number): number { return (i << 1) | 1; } // Check if node is a left child private isLeftChild(i: number): boolean { return (i & 1) === 0; // Even numbers are left children } // Check if node is a right child private isRightChild(i: number): boolean { return (i & 1) === 1 && i > 1; // Odd numbers (except 1) are right children } // Get sibling index private sibling(i: number): number { return i ^ 1; // XOR with 1 flips between left and right child }} // 0-based indexing equivalentsclass BitHeap0Based<T> { private data: T[] = []; // Parent: (i-1) >> 1 private parent(i: number): number { return (i - 1) >> 1; } // Left child: (i << 1) + 1 private leftChild(i: number): number { return (i << 1) + 1; } // Right child: (i << 1) + 2 private rightChild(i: number): number { return (i << 1) + 2; }}The XOR sibling trick:
For 1-based indexing, the sibling of node i is i XOR 1:
This elegant property arises because left and right children are consecutive integers.
Modern optimizing compilers typically convert 'i * 2' to 'i << 1' and 'i / 2' to 'i >> 1' automatically. Write whichever is more readable for your team. The bit operations are shown here for conceptual understanding, not because you must use them for performance.
A powerful application of the index formulas is computing the path from the root to any node, or vice versa. This is useful for:
Path computation via binary representation (1-based):
For 1-based indexing, the binary representation of a node's index encodes its path from the root. Excluding the leading 1-bit:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Get path from root to node at index i (1-based)function pathToNode(index: number): ('L' | 'R')[] { if (index <= 0) throw new Error('Invalid index'); if (index === 1) return []; // Root has empty path const path: ('L' | 'R')[] = []; // Find the position of the highest set bit let temp = index; let bits: number[] = []; while (temp > 0) { bits.push(temp & 1); temp >>= 1; } bits.reverse(); // Skip the leading 1, interpret remaining bits for (let i = 1; i < bits.length; i++) { path.push(bits[i] === 0 ? 'L' : 'R'); } return path;} // Alternative: iterative parent traversalfunction pathToNodeIterative(index: number): ('L' | 'R')[] { if (index <= 1) return []; const path: ('L' | 'R')[] = []; let current = index; while (current > 1) { const parent = current >> 1; if (current === parent * 2) { path.push('L'); } else { path.push('R'); } current = parent; } return path.reverse(); // Reverse to get root-to-node order} // Example usage:// pathToNode(11) returns ['L', 'R', 'R']// Binary of 11 is 1011: skip leading 1 -> 011 -> L, R, R| Index | Binary | Path (excluding leading 1) |
|---|---|---|
| 1 | 1 | [] (root) |
| 2 | 10 | [L] |
| 3 | 11 | [R] |
| 4 | 100 | [L, L] |
| 5 | 101 | [L, R] |
| 6 | 110 | [R, L] |
| 7 | 111 | [R, R] |
| 10 | 1010 | [L, R, L] |
| 13 | 1101 | [R, L, R] |
The depth (level) of a node equals the number of bits in its index minus 1. For index 13 (binary 1101, 4 bits), depth = 3. This is another way to see why height is O(log n)—the number of bits needed to represent n is ⌈log₂(n+1)⌉.
To cement understanding, let's verify the formulas exhaustively for a small heap. This kind of manual verification is essential when first learning these relationships.
1234567
[0] / \ [1] [2] / \ / \ [3] [4] [5] [6] / \ / \ / \ / \ [7][8] [9][10][11][12][13][14]| Node | Parent ⌊(i-1)/2⌋ | Left 2i+1 | Right 2i+2 | Level ⌊log₂(i+1)⌋ |
|---|---|---|---|---|
| 0 | — (root) | 1 | 2 | 0 |
| 1 | 0 ✓ | 3 | 4 | 1 |
| 2 | 0 ✓ | 5 | 6 | 1 |
| 3 | 1 ✓ | 7 | 8 | 2 |
| 4 | 1 ✓ | 9 | 10 | 2 |
| 5 | 2 ✓ | 11 | 12 | 2 |
| 6 | 2 ✓ | 13 | 14 | 2 |
| 7 | 3 ✓ | 15 (OOB) | 16 (OOB) | 3 |
| 8 | 3 ✓ | 17 (OOB) | 18 (OOB) | 3 |
| 9 | 4 ✓ | 19 (OOB) | 20 (OOB) | 3 |
| 10 | 4 ✓ | 21 (OOB) | 22 (OOB) | 3 |
| 11 | 5 ✓ | 23 (OOB) | 24 (OOB) | 3 |
| 12 | 5 ✓ | 25 (OOB) | 26 (OOB) | 3 |
| 13 | 6 ✓ | 27 (OOB) | 28 (OOB) | 3 |
| 14 | 6 ✓ | 29 (OOB) | 30 (OOB) | 3 |
Observations from the table:
Drawing out a small heap and computing all relationships by hand is one of the best ways to internalize these formulas. Do this once, and the formulas will feel natural rather than memorized.
Even simple formulas can harbor subtle bugs. Here are the most common mistakes when implementing heap navigation:
12345678910111213141516171819202122232425262728293031323334353637
class DefensiveHeap<T> { private data: T[] = []; private assertValidIndex(i: number, context: string): void { if (i < 0 || i >= this.data.length) { throw new Error( `Invalid heap index ${i} (size: ${this.data.length}) in ${context}` ); } } private parentIndex(i: number): number { this.assertValidIndex(i, 'parentIndex'); if (i === 0) { throw new Error('Root node has no parent'); } return Math.floor((i - 1) / 2); } private leftChildIndex(i: number): number | undefined { this.assertValidIndex(i, 'leftChildIndex'); const left = 2 * i + 1; return left < this.data.length ? left : undefined; } private rightChildIndex(i: number): number | undefined { this.assertValidIndex(i, 'rightChildIndex'); const right = 2 * i + 2; return right < this.data.length ? right : undefined; } // Safe access with explicit undefined handling private getLeftChild(i: number): T | undefined { const idx = this.leftChildIndex(i); return idx !== undefined ? this.data[idx] : undefined; }}In production hot paths, you might remove these checks for performance. But during development and testing, defensive checks catch bugs immediately. Consider using assertions that can be stripped in production builds.
The parent and child index formulas are the operational core of array-based heaps. Without them, we couldn't navigate the implicit tree structure. With them, traversal becomes simple arithmetic—no pointers, no chasing references, just numbers.
Let's consolidate the key takeaways:
What's next:
With navigation formulas in hand, we can appreciate the full elegance of array-based heaps. The next page explores why no pointers are needed—not just mechanically (we've seen the formulas), but philosophically. What makes complete binary trees special enough that pointers become redundant? Understanding this deepens appreciation for the heap data structure's design.
You now possess the technical machinery to navigate array-based heaps: computing parents, children, levels, and paths through simple index arithmetic. These formulas will appear in every heap operation you ever write. Master them, and heap implementation becomes straightforward.