Fundamental Data Structures: Memory Layout, Linear Structures & Amortization

Data structures organize, store, and manipulate data in memory. Choosing the appropriate data structure dictates asymptotic time complexity, hardware cache efficiency, and space overhead across applications. This guide covers contiguous arrays, dynamic arrays with amortized analysis, linked lists, stacks, queues, and hardware memory layout.


⚡ Quick Dive

Core Linear Data Structures Complexity Matrix

Data Structure Access (Index) Search (Value) Insertion (Head) Insertion (Tail) Insertion (Middle) Deletion (Head) Deletion (Tail) Space Overhead
Static Array $O(1)$ $O(n)$ N/A N/A N/A N/A N/A $O(1)$ (Zero pointer overhead)
Dynamic Array $O(1)$ $O(n)$ $O(n)$ $O(1)^*$ (Amortized) $O(n)$ $O(n)$ $O(1)$ Capacity padding
Singly Linked List $O(n)$ $O(n)$ $O(1)$ $O(1)$ (with tail) $O(1)^*$ (given node) $O(1)$ $O(n)$ 1 pointer / node
Doubly Linked List $O(n)$ $O(n)$ $O(1)$ $O(1)$ $O(1)^*$ (given node) $O(1)$ $O(1)$ 2 pointers / node
Stack (LIFO) $O(n)$ $O(n)$ $O(1)$ (Push) N/A N/A $O(1)$ (Pop) N/A Minimal
Queue (FIFO) $O(n)$ $O(n)$ N/A $O(1)$ (Enqueue) N/A $O(1)$ (Dequeue) N/A Minimal
Circular Deque $O(1)$ $O(n)$ $O(1)$ $O(1)$ $O(n)$ $O(1)$ $O(1)$ Fixed ring capacity

📖 Extended Guide

1. Memory Layout: Contiguous vs. Node-Based

Understanding CPU memory hierarchy explains why arrays outperform linked lists in real-world benchmarks despite having identical theoretical $O(n)$ search complexities:

Contiguous Array in RAM (High Cache Locality):
[ Element 0 ][ Element 1 ][ Element 2 ][ Element 3 ]  ◄── Fetched in single 64-byte Cache Line

Scattered Linked List Nodes (Pointer Chasing / Cache Misses):
[ Node A | Next* ] ───► [ Node B | Next* ] ───► [ Node C | Next* ]
 (Address 0x10A0)        (Address 0x8F40)        (Address 0x22C0)
  • Spatial Locality: Reading array[i] automatically loads subsequent elements into L1/L2 CPU cache lines (typically 64 bytes).
  • Pointer Overhead: On 64-bit systems, each node pointer consumes 8 bytes of RAM. A doubly linked list storing 4-byte integers consumes $4 + 8 + 8 = 20$ bytes per node (500% memory overhead).

2. Dynamic Array Growth & Amortized Analysis

Dynamic arrays (e.g., Go slices, Python list, Java ArrayList) allocate a fixed contiguous buffer and geometrically resize when full:

Growth Steps:
1. Buffer: [ A | B ] (Capacity: 2, Size: 2)
2. Append 'C' -> Allocate Capacity 4 -> Copy [ A | B ] -> Insert 'C': [ A | B | C | _ ]
3. Append 'D' -> Insert 'D': [ A | B | C | D ]
4. Append 'E' -> Allocate Capacity 8 -> Copy [ A | B | C | D ] -> Insert 'E': [ A | B | C | D | E | _ | _ | _ ]

Amortized Cost Proof (Aggregate Method):

  • Resizing from size $N$ requires allocating $2N$ slots and copying $N$ elements (Cost = $N$).
  • Doubling occurs at insertions $1, 2, 4, 8, 16, \dots, N$.
  • Total copy cost for $N$ insertions $= 1 + 2 + 4 + \dots + N = 2N - 1 < 2N$.
  • Amortized cost per append: $$\text{Cost} = \frac{N \text{ (regular inserts)} + 2N \text{ (copying)}}{N} = 3 = O(1)$$

3. Linked Lists: Sentinels and Doubly Linked Structures

Using dummy sentinel nodes at the head and tail completely eliminates edge-case nil/NULL pointer checks during insertion and deletion.

Doubly Linked List with Dummy Sentinels:
[ Head Sentinel ] <===> [ Node 1 (Val: 10) ] <===> [ Node 2 (Val: 20) ] <===> [ Tail Sentinel ]

Complete Go Implementation: Doubly Linked List with Sentinels

package main

import "fmt"

type Node struct {
	Value int
	Prev  *Node
	Next  *Node
}

type DoublyLinkedList struct {
	head *Node // Dummy head sentinel
	tail *Node // Dummy tail sentinel
	size int
}

func NewDoublyLinkedList() *DoublyLinkedList {
	head := &Node{}
	tail := &Node{}
	head.Next = tail
	tail.Prev = head
	return &DoublyLinkedList{head: head, tail: tail, size: 0}
}

// PushFront inserts a value at the beginning in O(1) time.
func (list *DoublyLinkedList) PushFront(val int) {
	newNode := &Node{Value: val, Prev: list.head, Next: list.head.Next}
	list.head.Next.Prev = newNode
	list.head.Next = newNode
	list.size++
}

// PushBack inserts a value at the end in O(1) time.
func (list *DoublyLinkedList) PushBack(val int) {
	newNode := &Node{Value: val, Prev: list.tail.Prev, Next: list.tail}
	list.tail.Prev.Next = newNode
	list.tail.Prev = newNode
	list.size++
}

// Remove deletes a specific node in O(1) time.
func (list *DoublyLinkedList) Remove(node *Node) {
	if node == list.head || node == list.tail {
		return
	}
	node.Prev.Next = node.Next
	node.Next.Prev = node.Prev
	list.size--
}

func (list *DoublyLinkedList) Display() {
	curr := list.head.Next
	for curr != list.tail {
		fmt.Printf("%d <-> ", curr.Value)
		curr = curr.Next
	}
	fmt.Println("nil")
}

func main() {
	dll := NewDoublyLinkedList()
	dll.PushBack(10)
	dll.PushBack(20)
	dll.PushFront(5)
	dll.Display() // 5 <-> 10 <-> 20 <-> nil
}

4. Stacks, Queues, and Circular Buffers

Circular Queue (Ring Buffer)

A fixed-size array avoiding $O(n)$ shifts on dequeue by maintaining head and tail indices modulo capacity:

type CircularQueue struct {
	buffer   []int
	head     int
	tail     int
	size     int
	capacity int
}

func NewCircularQueue(k int) *CircularQueue {
	return &CircularQueue{
		buffer:   make([]int, k),
		capacity: k,
	}
}

func (q *CircularQueue) Enqueue(val int) bool {
	if q.size == q.capacity {
		return false // Queue is full
	}
	q.buffer[q.tail] = val
	q.tail = (q.tail + 1) % q.capacity
	q.size++
	return true
}

func (q *CircularQueue) Dequeue() (int, bool) {
	if q.size == 0 {
		return 0, false // Queue is empty
	}
	val := q.buffer[q.head]
	q.head = (q.head + 1) % q.capacity
	q.size--
	return val, true
}