CS · 101  ·  Data Structures · 2025

Data
Structures

A comprehensive deep-dive into every fundamental data structure — how they work internally, when to use them, and how to reason about their performance.

09Structures
30+Code Examples
Complexity Tables
Introduction

What is a Data Structure?

A data structure is a specialized format for organizing, processing, retrieving, and storing data in a computer's memory so that it can be accessed and modified efficiently. Choosing the right data structure for a problem is often more impactful than the choice of algorithm — and the two are deeply intertwined.

Every data structure encodes two things: how data is laid out in memory and what operations it supports efficiently. Understanding both dimensions is what separates engineers who know syntax from engineers who write systems that scale.

"Bad programmers worry about the code. Good programmers worry about data structures and their relationships." — Linus Torvalds

The Four Core Operations

Every data structure is evaluated on how efficiently it supports these fundamental operations:

Access

Read a specific element

Retrieve the value at a known position or key. Arrays excel here at O(1); linked lists struggle at O(n).

Search

Find an element by value

Locate an element without knowing its index. Hash tables achieve O(1); unsorted arrays require O(n).

Insertion

Add a new element

Where and how quickly you can insert matters enormously. Linked lists insert in O(1); arrays may need to shift.

Deletion

Remove an element

Removing without leaving gaps or breaking structure. Arrays shrink slowly; linked lists snip in O(1) with a pointer.

§ 01
Structure 01

Arrays & Dynamic Arrays

An array is the most fundamental data structure: a contiguous block of memory holding elements of the same type. Because elements are stored side-by-side, you can compute the memory address of any element directly from its index in O(1) time — this is the defining property of arrays.

Static Array — contiguous memory layout
0x1000 0x1004 0x1008 0x100C 0x1010 0x1014 12 34 7 99 56 21 [0] [1] [2] [3] ← [4] [5]

How Array Access Works in O(1)

The O(1) access time comes from simple arithmetic. If the array starts at memory address base and each element takes size bytes, then the address of element at index i is exactly base + i × size. This computation takes constant time regardless of the array's length — the CPU fetches it in a single memory operation.

# Static array address calculation
# base = 0x1000, element_size = 4 bytes (int32)
address(i) = 0x1000 + i * 4

# arr[3] → 0x1000 + 3 * 4 = 0x100C  → O(1)
# This is why arr[3] and arr[9999] cost the same!

int[] arr = {12, 34, 7, 99, 56, 21}
value = arr[3]   # → 99, one instruction

Dynamic Arrays — Amortized Growth

Static arrays have a fixed size. Dynamic arrays (Python list, Java ArrayList, C++ vector) automatically resize by allocating a new block of memory — typically twice the current size — when capacity is exceeded. This doubling strategy gives amortized O(1) appends: most appends are O(1), and the rare O(n) reallocation is spread across all the previous O(1) appends.

# Dynamic array growth pattern
# size:     1 → 2 → 4 → 8 → 16 → 32 …
# copies:   1   2   4   8   16   32 …
# Total copies for n inserts ≤ 2n → amortized O(1)

class DynamicArray:
    def __init__(self):
        self.data     = [None] * 1   # raw capacity
        self.size     = 0
        self.capacity = 1

    def append(self, val):
        if self.size == self.capacity:
            self._resize(self.capacity * 2)  # double!
        self.data[self.size] = val
        self.size += 1

    def _resize(self, new_cap):
        new_data = [None] * new_cap
        for i in range(self.size):
            new_data[i] = self.data[i]
        self.data, self.capacity = new_data, new_cap

Complexity Summary

OperationStatic ArrayDynamic ArrayNotes
Access by indexO(1)O(1)Direct address computation
Search (unsorted)O(n)O(n)Must scan all elements
Search (sorted)O(log n)O(log n)Binary search
Insert at endO(n)O(1)**amortized; O(n) on resize
Insert at indexO(n)O(n)Must shift elements right
Delete at endO(1)O(1)Just decrement size
Delete at indexO(n)O(n)Must shift elements left
Cache Locality

Arrays are the most cache-friendly structure. Because elements are contiguous, the CPU pre-fetches adjacent elements into L1/L2 cache automatically. Iterating over an array is often 10–100× faster than iterating over a linked list of the same size, even though both are O(n) — cache misses are that expensive.

§ 02
Structure 02

Linked Lists

A linked list stores elements in nodes — each node holds a value and a pointer (reference) to the next node. Unlike arrays, nodes can live anywhere in memory; they are connected by pointers, not physical proximity. This makes insertion and deletion anywhere in the list O(1) given a pointer to the location, at the cost of O(1) random access.

Singly Linked List — nodes connected by next pointers
HEAD
[val: 10 | next →]
──▶
[val: 25 | next →]
──▶
[val: 47 | next →]
──▶
[val: 83 | next →]
──▶
[null]

Singly Linked List

Each node has a value and a single next pointer. Traversal is one-directional. Prepending is O(1); appending requires traversal to the tail (O(n)) unless a tail pointer is maintained.

class Node:
    def __init__(self, val):
        self.val  = val
        self.next = None

class LinkedList:
    def __init__(self):
        self.head = None

    def prepend(self, val):   # O(1)
        node = Node(val)
        node.next = self.head
        self.head = node

    def delete(self, val):    # O(n) search + O(1) deletion
        prev, cur = None, self.head
        while cur:
            if cur.val == val:
                if prev: prev.next = cur.next
                else:    self.head = cur.next
                return
            prev, cur = cur, cur.next

Doubly Linked List

Each node carries both a next and a prev pointer, enabling backward traversal. This doubles memory usage per node but allows O(1) deletion when you already have a reference to the node (no need to find its predecessor).

Doubly Linked List — bidirectional pointers
HEAD ←→
[←|10|→]
[←|25|→]
[←|47|→]
[←|83|→]
←→ TAIL

Key Applications

LRU Cache

Doubly linked list + hash map

The most-used cache eviction policy. Hash map for O(1) lookup; doubly linked list for O(1) move-to-front and evict-from-back.

OS Internals

Process & file management

Linux's kernel represents process lists, inode chains, and memory regions as doubly linked lists for O(1) insertion/removal.

Undo/Redo

Editor history

A doubly linked list of commands enables O(1) undo (go to prev) and redo (go to next) in text editors and image tools.

Circular List

Round-robin scheduling

Tail node points back to head. Used in OS schedulers to cycle through processes, and in multiplayer game turn management.

Complexity Summary

OperationSingly LLDoubly LLNotes
Access by indexO(n)O(n)Must traverse from head
SearchO(n)O(n)Linear scan
Prepend (at head)O(1)O(1)Just update head pointer
Append (at tail)O(1)*O(1)*O(1) if tail pointer kept
Insert at positionO(n)O(n)O(n) to find; O(1) to link
Delete with referenceO(n)O(1)Doubly LL: prev pointer known
Delete by valueO(n)O(n)Must search first
§ 03
Structure 03

Stacks, Queues, & Deques

Stacks and queues are abstract data types — they define behavior (which end you insert and remove from) rather than a specific memory layout. They can be implemented with arrays or linked lists; the choice of implementation affects constants but not the O(1) asymptotic complexity of their core operations.

Stack — LIFO (Last In, First Out)
42 ← TOP 17 85 31 PUSH ↑ POP ↑
Queue — FIFO (First In, First Out)
FRONT 10 25 47 83 ← DEQUEUE ENQUEUE →

Stack — LIFO

A stack supports exactly two primary operations: push (add to top) and pop (remove from top). Its LIFO order makes it the perfect model for any problem that requires "last action first undone" — including the actual call stack your programming language uses for function calls and recursion.

# Stack applications

# 1. Balanced parentheses checker
def is_balanced(s):
    stack, pairs = [], {')': '(', ']': '[', '}': '{'}
    for ch in s:
        if ch in '([{':     stack.append(ch)
        elif ch in pairs:
            if not stack or stack[-1] != pairs[ch]:
                return False
            stack.pop()
    return not stack

# 2. Evaluate reverse Polish notation: "2 3 4 * +"
def eval_rpn(tokens):
    stack = []
    for t in tokens:
        if t in '+-*/':
            b, a = stack.pop(), stack.pop()
            stack.append(int(eval(f"{a}{t}{b}")))
        else:
            stack.append(int(t))
    return stack[0]   # → 14

Queue — FIFO

A queue supports enqueue (add to back) and dequeue (remove from front). It models real-world waiting lines. Implemented naively with an array, dequeue requires O(n) shifting — this is why the standard approach is a circular buffer (both enqueue and dequeue O(1)) or a doubly linked list.

from collections import deque

# Python's deque: O(1) append and popleft
q = deque()
q.append(1)     # enqueue → [1]
q.append(2)     # enqueue → [1, 2]
q.append(3)     # enqueue → [1, 2, 3]
q.popleft()     # dequeue → 1, queue is [2, 3]

# Queue powers BFS (Breadth-First Search)
def bfs(graph, start):
    visited, queue = {start}, deque([start])
    while queue:
        node = queue.popleft()
        for nbr in graph[node]:
            if nbr not in visited:
                visited.add(nbr)
                queue.append(nbr)

Priority Queue

A priority queue dequeues the highest-priority element first, not the oldest. Under the hood it is almost always implemented as a heap (covered in section 06). It powers Dijkstra's shortest-path algorithm, A* search, hospital triage systems, and OS process schedulers.

import heapq

# Python heapq: min-heap (negate for max-heap)
tasks = []
heapq.heappush(tasks, (3, "low priority"))
heapq.heappush(tasks, (1, "urgent"))
heapq.heappush(tasks, (2, "medium"))

heapq.heappop(tasks)  # → (1, "urgent")  ← smallest first
heapq.heappop(tasks)  # → (2, "medium")
§ 04
Structure 04

Hash Tables

A hash table (hash map) maps keys to values in average O(1) time for get, set, and delete. It is the backbone of Python dictionaries, JavaScript objects, Java's HashMap, and Redis. The magic is a hash function that deterministically maps any key to an index in an underlying array.

Hash Table — keys mapped to buckets via hash function
"apple" "banana" "cherry" "date" hash() % size index value 0 1 apple → 5 2 cherry → 8 3 4 banana → 2

The Hash Function

A hash function maps a key of arbitrary type and size to a fixed-range integer. A good hash function distributes keys uniformly across all buckets to minimize collisions. For strings, a common approach is to treat each character as a digit in a large base:

# Polynomial rolling hash (used in many languages)
def hash_string(key, table_size):
    h, base, mod = 0, 31, 10**9 + 9
    for ch in key:
        h = (h * base + ord(ch)) % mod
    return h % table_size

# hash("apple", 10) → some index in [0,9]
# Good hash: uniform, deterministic, fast
# Bad hash: always returns 0  (all collisions!)

Collision Resolution

Two keys can hash to the same bucket — this is a collision. There are two major strategies:

Separate Chaining

Each bucket holds a list

Colliding entries form a linked list at that bucket. Used by Java's HashMap. Worst case: all keys hash to one bucket → O(n). Average: O(1) with a good hash function.

Open Addressing

Probe for an empty slot

On collision, search for the next open slot using linear probing, quadratic probing, or double hashing. All data stays in the array — better cache performance but needs careful load factor management.

Load Factor & Rehashing

The load factor α = (number of entries) / (table size) governs performance. When α exceeds a threshold (typically 0.7–0.75), the table is rehashed: a new table of roughly twice the size is allocated and all entries are re-inserted. Like dynamic arrays, this is amortized O(1) per insert.

# Python dict internals (simplified)
# Load factor threshold: ~0.67
# Probing: compact hash tables (since Python 3.6)

d = {}
d["key"] = 42         # hash("key") % size → slot
d["key"]              # → 42   O(1) average
"key" in d           # → True  O(1) average
del d["key"]          # O(1) average

# Classic interview: word frequency count
freq = {}
for word in words:
    freq[word] = freq.get(word, 0) + 1

Complexity Summary

OperationAverageWorst CaseNote
Search / GetO(1)O(n)Worst: all keys collide
InsertO(1)*O(n)*amortized; O(n) on rehash
DeleteO(1)O(n)Same conditions as search
Iterate all entriesO(n)O(n)Must visit all buckets
§ 05
Structure 05

Trees & BSTs

A tree is a hierarchical data structure: a collection of nodes where each node has at most one parent and any number of children. The topmost node is the root; nodes with no children are leaves. Trees are ubiquitous — file systems, HTML DOMs, compiler ASTs, decision trees in ML, and database indexes all use them.

Binary Search Tree — left < parent < right
50 25 75 12 37 60 90 30 45 search(37)

Binary Search Trees (BST)

A BST is a binary tree where every node's left subtree contains only values smaller than the node, and the right subtree only larger values. This property enables binary search on a dynamic dataset: at each node, you eliminate half the remaining tree.

class BST:
    class Node:
        def __init__(self, val):
            self.val   = val
            self.left  = self.right = None

    def insert(self, root, val):   # O(log n) avg, O(n) worst
        if not root: return self.Node(val)
        if   val < root.val: root.left  = self.insert(root.left,  val)
        elif val > root.val: root.right = self.insert(root.right, val)
        return root

    def search(self, root, val):   # O(log n) avg
        if not root or root.val == val: return root
        if val < root.val: return self.search(root.left,  val)
        else:              return self.search(root.right, val)

    def inorder(self, root):       # O(n) — yields sorted order!
        if root:
            yield from self.inorder(root.left)
            yield root.val
            yield from self.inorder(root.right)
The Degenerate BST Problem

Inserting sorted data into a naive BST creates a degenerate tree — essentially a linked list — where all operations degrade to O(n). The fix: self-balancing trees that automatically rebalance on insert/delete to maintain O(log n) height.

Self-Balancing Trees

AVL Tree

Height difference ≤ 1

Maintains balance factor (height_left - height_right) ∈ {-1, 0, 1}. Uses rotations on insert/delete. Stricter balance than Red-Black; faster lookups but slower writes.

Red-Black Tree

Color-based balance

Nodes are colored red or black; 5 invariants keep height ≤ 2 log n. Used in C++ STL (map/set), Java TreeMap, and Linux's process scheduler. Fewer rotations than AVL.

B-Tree / B+ Tree

Multi-way branching

Nodes hold many keys, with branching factor in the hundreds. Minimizes disk reads by keeping tree height ≤ 3-4 even for billions of records. Used in every major database index and filesystem (NTFS, ext4).

Treap

BST + heap randomization

Each node has a random priority; the tree is heap-ordered by priority. Provides expected O(log n) for all operations with a simple implementation — popular in competitive programming.

Tree Traversals

TraversalOrderUse Case
In-orderLeft → Root → RightBST: yields sorted output. Expression trees: infix notation.
Pre-orderRoot → Left → RightCopy/serialize a tree. Build expression trees from prefix notation.
Post-orderLeft → Right → RootDelete a tree (children before parent). Evaluate expression trees.
Level-order (BFS)Layer by layerFind shortest path in unweighted tree. Serialize/deserialize binary trees.
§ 06
Structure 06

Heaps & Priority Queues

A heap is a complete binary tree (all levels fully filled except possibly the last, filled left to right) satisfying the heap property: in a max-heap, every parent is ≥ its children; in a min-heap, every parent is ≤ its children. This guarantee makes the root always the maximum (or minimum) element, accessible in O(1).

Max-Heap stored as array (parent at i, children at 2i+1 and 2i+2)
90 75 60 50 40 30 20 Array: [90, 75, 60, 50, 40, 30, 20] ← stored left-to-right, level by level

Heap Operations

The heap stores a complete binary tree in an array — no pointers needed. For a node at index i, its left child is at 2i + 1, right child at 2i + 2, and parent at ⌊(i-1)/2⌋. This layout is incredibly cache-friendly.

class MaxHeap:
    def __init__(self): self.h = []

    def push(self, val):               # O(log n)
        self.h.append(val)
        self._sift_up(len(self.h) - 1)

    def pop(self):                     # O(log n)
        self.h[0] = self.h[-1]         # put last at root
        self.h.pop()
        self._sift_down(0)
        return self.h[0]              # before sift, save it

    def _sift_up(self, i):
        parent = (i - 1) // 2
        while i > 0 and self.h[i] > self.h[parent]:
            self.h[i], self.h[parent] = self.h[parent], self.h[i]
            i, parent = parent, (parent - 1) // 2

    # Heapify: convert arbitrary array to heap in O(n)
    def heapify(self, arr):
        self.h = arr[:]
        for i in range(len(arr)//2, -1, -1):
            self._sift_down(i)          # O(n) total

Heap Sort

Heap sort uses the heap to sort in O(n log n) time with O(1) extra space — better space than merge sort, guaranteed O(n log n) unlike quick sort. It works by heapifying the array (O(n)), then repeatedly extracting the max and placing it at the end of the array.

Key Applications

Dijkstra's Algorithm

Always expand nearest node

A min-heap on (distance, node) ensures O((V+E) log V) time — the heap pop gives the unvisited node with minimum tentative distance.

Median Maintenance

Two-heap trick

Keep a max-heap for the lower half and min-heap for the upper half. Median is always one of the two roots. O(log n) insert, O(1) median query.

K-th Largest/Smallest

Fixed-size heap

Maintain a min-heap of size k. After processing all n elements, the root is the k-th largest. O(n log k) time — crucial for streaming data.

§ 07
Structure 07

Graphs — The Universal Model

A graph G = (V, E) is a set of vertices (nodes) V connected by edges E. Almost any relationship can be modeled as a graph: web pages and hyperlinks, people and friendships, cities and roads, dependencies between tasks, states in a finite automaton. Graphs are the most general and expressive data structure.

Graph Representations

The choice of representation affects the efficiency of every graph algorithm you run:

Adjacency List

Each vertex stores a list of its neighbors. Space: O(V + E). Efficient for sparse graphs (few edges). Standard representation for most graph algorithms.

# Space: O(V + E)
graph = {
  'A': ['B', 'C'],
  'B': ['D'],
  'C': ['B', 'D'],
  'D': []
}
# Check if A→B: O(degree(A))
# Iterate A's neighbors: O(degree(A))

Adjacency Matrix

An n×n boolean matrix where matrix[i][j] = 1 means edge i→j. Space: O(V²). Efficient edge lookup O(1) but wastes space for sparse graphs.

# Space: O(V²)  4×4 for 4 vertices
matrix = [
  [0,1,1,0],  # A→B, A→C
  [0,0,0,1],  # B→D
  [0,1,0,1],  # C→B, C→D
  [0,0,0,0],  # D: no edges
]
# Check if A→B: O(1) → matrix[0][1]

Types of Graphs

Directed (Digraph)

Edges have direction A→B

A→B ≠ B→A. Models: web links, dependencies (build systems, imports), citation networks, Twitter follows.

Undirected

Edges are symmetric A—B

If A connects to B, B connects to A. Models: Facebook friendships, road networks (two-way), collaboration graphs.

Weighted

Edges carry a cost

Edge weights represent distances, latencies, capacities, or costs. Used with Dijkstra, Bellman-Ford, MST algorithms, and network flow.

DAG

Directed Acyclic Graph

No directed cycles. Enables topological sort — the foundation of build systems, spreadsheet evaluation, course scheduling, and compiler pipelines.

Union-Find (Disjoint Set)

A specialized structure for tracking connected components. Supports two operations: find (which component does this element belong to?) and union (merge two components). With path compression and union by rank, both operations run in near O(1) amortized (inverse Ackermann time — effectively constant).

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))  # parent[i] = i initially
        self.rank   = [0] * n

    def find(self, x):                 # O(α(n)) ≈ O(1)
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # path compress
        return self.parent[x]

    def union(self, x, y):             # O(α(n)) ≈ O(1)
        rx, ry = self.find(x), self.find(y)
        if rx == ry: return False     # already connected
        if self.rank[rx] < self.rank[ry]: rx, ry = ry, rx
        self.parent[ry] = rx           # union by rank
        if self.rank[rx] == self.rank[ry]: self.rank[rx] += 1
        return True

# Used in Kruskal's MST: union edges greedily if no cycle forms
§ 08
Structure 08

Tries — Prefix Trees

A trie (pronounced "try", from retrieval) is a tree where each node represents a character, and paths from root to nodes spell out strings. It is the ideal structure for any problem involving string prefixes: autocomplete, spell checking, IP routing tables, and word games.

Trie storing: "car", "cat", "care", "card", "bat"
root b c a a t ✓bat r t ✓cat ✓car e/d

Trie Operations

Every operation on a trie takes O(m) time where m is the length of the string — completely independent of how many strings are stored. This is the key advantage over hash tables for prefix operations.

class TrieNode:
    def __init__(self):
        self.children  = {}          # char → TrieNode
        self.is_end    = False       # marks end of a word

class Trie:
    def __init__(self): self.root = TrieNode()

    def insert(self, word):     # O(m)
        node = self.root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
        node.is_end = True

    def search(self, word):     # O(m)
        node = self.root
        for ch in word:
            if ch not in node.children: return False
            node = node.children[ch]
        return node.is_end

    def starts_with(self, prefix):   # O(m) — key advantage!
        node = self.root
        for ch in prefix:
            if ch not in node.children: return False
            node = node.children[ch]
        return True

    def autocomplete(self, prefix):  # O(m + output)
        node = self.root
        for ch in prefix:
            if ch not in node.children: return []
            node = node.children[ch]
        results = []
        self._dfs(node, prefix, results)
        return results

    def _dfs(self, node, cur, results):
        if node.is_end: results.append(cur)
        for ch, child in node.children.items():
            self._dfs(child, cur + ch, results)
Trie vs Hash Table for String Problems

Hash tables offer O(1) average exact lookup. Tries offer O(m) lookup plus O(m) prefix search and O(m + output) autocomplete — operations that hash tables cannot support efficiently. For a dictionary of 1 million words, "find all words starting with 'pre'" takes O(6 + output) with a trie versus O(1,000,000) with a hash table scan.

§ 09
Reference

Master Comparison Table

Use this table to quickly identify the right data structure for a given operation requirement. Green = efficient, amber = acceptable, red = poor choice.

Structure Access Search Insert Delete Space Best For
Array O(1) O(n) O(n) O(n) O(n) Index access, iteration, caching
Dynamic Array O(1) O(n) O(1)* O(n) O(n) Append-heavy, general purpose
Singly Linked List O(n) O(n) O(1) O(n) O(n) Prepend/stream-style inserts
Doubly Linked List O(n) O(n) O(1) O(1)** O(n) LRU cache, undo/redo, OS lists
Stack O(1) O(n) O(1) O(1) O(n) Parsing, DFS, call stack, undo
Queue O(1) O(n) O(1) O(1) O(n) BFS, scheduling, rate limiting
Hash Table O(1)† O(1)† O(1)*† O(1)† O(n) Key-value lookup, frequency count
BST (balanced) O(log n) O(log n) O(log n) O(log n) O(n) Ordered data, range queries
Heap O(1)† O(n) O(log n) O(log n) O(n) Priority queue, k-th element
Graph (adj. list) O(V+E) O(1) O(E) O(V+E) Relationships, networks, paths
Trie O(m) O(m) O(m) O(m) O(n·m) Autocomplete, prefix search, IP routing

* Amortized    ** Given node reference    † Average case    m = string length

Decision Guide

Need O(1) random access?

→ Use an Array

Nothing beats arrays for index-based access. If you also need dynamic resizing, use a dynamic array (Python list, Java ArrayList).

Need O(1) key-value lookup?

→ Use a Hash Table

Hash tables are the default for any "does X exist" or "what's the count of X" problem. The workhorse of coding interviews.

Need ordered iteration + fast search?

→ Use a Balanced BST

When you need sorted order, range queries, floor/ceiling operations — use a BST (Java TreeMap, C++ map, Python sortedcontainers).

Need to repeatedly find the min/max?

→ Use a Heap

Whenever you have a sliding window of "best" elements, scheduling by priority, or shortest-path expansion — a heap is the answer.

Need prefix matching or autocomplete?

→ Use a Trie

Tries were built for this. Any problem where the query is a string prefix — autocomplete, IP longest prefix match, word games — reach for a trie.

Modeling relationships or paths?

→ Use a Graph

Networks, dependencies, social connections, maps, state machines — if your data has non-hierarchical relationships, it's a graph problem.