MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Data Structures Every Developer Should Know

Essential data structures: arrays, linked lists, trees, graphs, and hash tables.

Data StructuresAlgorithmsComputer Science

By MinhVo

Introduction

Data structures are the building blocks of software engineering. Every program you write, every algorithm you implement, and every system you design relies on choosing the right data structure for the job. The difference between an O(1) lookup and an O(n) lookup can mean the difference between a system that handles millions of requests per second and one that collapses under load.

Yet many developers learn data structures in a computer science course and never revisit them. They reach for arrays and objects by default, missing opportunities to use more efficient structures like hash maps, tries, or balanced binary trees. Understanding when and why to use each data structure is a skill that separates competent developers from exceptional ones.

Data structures visualization

This guide covers the essential data structures every developer should know, with practical TypeScript implementations, real-world use cases, and performance analysis. Rather than academic definitions, we focus on when to use each structure and the trade-offs involved.

Understanding Data Structures: The Fundamentals

A data structure is a way of organizing and storing data so that it can be accessed and modified efficiently. Different data structures are optimized for different operations—some excel at lookups, others at insertions, and others at maintaining order. The key insight is that there's no single "best" data structure; the right choice depends on your access patterns.

Data structures fall into two broad categories: linear (arrays, linked lists, stacks, queues) and non-linear (trees, graphs, hash tables). Linear structures store elements in a sequence, while non-linear structures store elements with relationships between them. Understanding these categories helps you reason about which structure fits your problem.

Computer science fundamentals

The performance of a data structure is measured by its time complexity (how long operations take) and space complexity (how much memory it uses). We use Big O notation to describe how performance scales with the number of elements. O(1) means constant time regardless of size, O(log n) means logarithmic growth, and O(n) means linear growth.

Arrays and Dynamic Arrays

Arrays are the most fundamental data structure—a contiguous block of memory storing elements of the same type. Arrays provide O(1) random access by index but O(n) insertion and deletion in the middle. Dynamic arrays (like JavaScript's Array or Python's list) automatically resize when they run out of capacity, amortizing the cost of resizing to O(1) per insertion.

// Dynamic array implementation
class DynamicArray<T> {
  private data: T[]
  private _size: number
  private _capacity: number
 
  constructor(initialCapacity: number = 4) {
    this.data = new Array(initialCapacity)
    this._size = 0
    this._capacity = initialCapacity
  }
 
  get size(): number { return this._size }
  get capacity(): number { return this._capacity }
 
  get(index: number): T {
    if (index < 0 || index >= this._size) {
      throw new Error('Index out of bounds')
    }
    return this.data[index]
  }
 
  push(value: T): void {
    if (this._size === this._capacity) {
      this.resize(this._capacity * 2)
    }
    this.data[this._size++] = value
  }
 
  private resize(newCapacity: number): void {
    const newData = new Array(newCapacity)
    for (let i = 0; i < this._size; i++) {
      newData[i] = this.data[i]
    }
    this.data = newData
    this._capacity = newCapacity
  }
}

When to use arrays: Sequential access, iteration, when you need index-based lookup, and when the size is known or changes infrequently. Arrays are cache-friendly because elements are stored contiguously in memory.

When NOT to use arrays: When you need frequent insertions/deletions in the middle, when the order matters and changes frequently, or when you need to search by value rather than index.

Hash Tables (Hash Maps)

Hash tables provide O(1) average-case lookup, insertion, and deletion. They work by computing a hash function that maps keys to array indices. When two keys hash to the same index (a collision), the hash table uses chaining (linked lists) or open addressing (probing) to resolve the conflict.

Hash tables are the workhorse of modern programming. JavaScript objects, Python dictionaries, and Java HashMaps are all hash table implementations. They're used for caching, counting, deduplication, and anywhere you need fast key-based lookups.

// Hash table with chaining for collision resolution
class HashTable<K, V> {
  private buckets: Array<Array<[K, V]>>
  private _size: number
  private readonly LOAD_FACTOR_THRESHOLD = 0.75
 
  constructor(initialCapacity: number = 16) {
    this.buckets = new Array(initialCapacity).fill(null).map(() => [])
    this._size = 0
  }
 
  private hash(key: K): number {
    const str = String(key)
    let hash = 5381
    for (let i = 0; i < str.length; i++) {
      hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0
    }
    return hash % this.buckets.length
  }
 
  set(key: K, value: V): void {
    const index = this.hash(key)
    const bucket = this.buckets[index]
 
    for (let i = 0; i < bucket.length; i++) {
      if (bucket[i][0] === key) {
        bucket[i][1] = value
        return
      }
    }
 
    bucket.push([key, value])
    this._size++
 
    if (this._size / this.buckets.length > this.LOAD_FACTOR_THRESHOLD) {
      this.resize(this.buckets.length * 2)
    }
  }
 
  get(key: K): V | undefined {
    const index = this.hash(key)
    const bucket = this.buckets[index]
 
    for (const [k, v] of bucket) {
      if (k === key) return v
    }
    return undefined
  }
 
  private resize(newCapacity: number): void {
    const oldBuckets = this.buckets
    this.buckets = new Array(newCapacity).fill(null).map(() => [])
    this._size = 0
 
    for (const bucket of oldBuckets) {
      for (const [key, value] of bucket) {
        this.set(key, value)
      }
    }
  }
}

When to use hash tables: Caching, frequency counting, deduplication, implementing sets, memoization, and any time you need O(1) key-based lookups.

When NOT to use hash tables: When you need ordered data, when you need range queries, or when memory is extremely constrained (hash tables have overhead for the array and pointers).

Linked Lists

Linked lists store elements in nodes, where each node contains a value and a pointer to the next node. Unlike arrays, linked lists don't require contiguous memory and provide O(1) insertion and deletion at the head. However, they have O(n) access by index and poor cache locality.

class ListNode<T> {
  constructor(
    public value: T,
    public next: ListNode<T> | null = null
  ) {}
}
 
class LinkedList<T> {
  private head: ListNode<T> | null = null
  private tail: ListNode<T> | null = null
  private _size: number = 0
 
  get size(): number { return this._size }
 
  prepend(value: T): void {
    const node = new ListNode(value, this.head)
    this.head = node
    if (!this.tail) this.tail = node
    this._size++
  }
 
  append(value: T): void {
    const node = new ListNode(value)
    if (!this.tail) {
      this.head = this.tail = node
    } else {
      this.tail.next = node
      this.tail = node
    }
    this._size++
  }
 
  delete(value: T): boolean {
    if (!this.head) return false
 
    if (this.head.value === value) {
      this.head = this.head.next
      if (!this.head) this.tail = null
      this._size--
      return true
    }
 
    let current = this.head
    while (current.next) {
      if (current.next.value === value) {
        current.next = current.next.next
        if (!current.next) this.tail = current
        this._size--
        return true
      }
      current = current.next
    }
    return false
  }
 
  toArray(): T[] {
    const result: T[] = []
    let current = this.head
    while (current) {
      result.push(current.value)
      current = current.next
    }
    return result
  }
}

When to use linked lists: When you need frequent insertions/deletions at the head, when implementing stacks and queues, and when you don't need random access.

When NOT to use linked lists: When you need index-based access, when cache performance matters, or when memory overhead is a concern (each node stores a pointer).

Stacks and Queues

Stacks (LIFO—Last In, First Out) and queues (FIFO—First In, First Out) are abstract data types that restrict how elements are accessed. Stacks are used for undo mechanisms, expression evaluation, and call stacks. Queues are used for task scheduling, breadth-first search, and message processing.

// Stack implementation using a linked list
class Stack<T> {
  private top: ListNode<T> | null = null
  private _size: number = 0
 
  push(value: T): void {
    this.top = new ListNode(value, this.top)
    this._size++
  }
 
  pop(): T | undefined {
    if (!this.top) return undefined
    const value = this.top.value
    this.top = this.top.next
    this._size--
    return value
  }
 
  peek(): T | undefined {
    return this.top?.value
  }
 
  get size(): number { return this._size }
  get isEmpty(): boolean { return this._size === 0 }
}
 
// Queue implementation using a circular buffer
class Queue<T> {
  private data: T[]
  private head: number = 0
  private tail: number = 0
  private _size: number = 0
 
  constructor(capacity: number = 16) {
    this.data = new Array(capacity)
  }
 
  enqueue(value: T): void {
    this.data[this.tail] = value
    this.tail = (this.tail + 1) % this.data.length
    this._size++
  }
 
  dequeue(): T | undefined {
    if (this._size === 0) return undefined
    const value = this.data[this.head]
    this.head = (this.head + 1) % this.data.length
    this._size--
    return value
  }
 
  get size(): number { return this._size }
}

Trees

Trees are hierarchical data structures with a root node and child nodes. Binary trees have at most two children per node. Binary Search Trees (BSTs) maintain the ordering property: left children are smaller, right children are larger. Balanced BSTs (AVL, Red-Black) guarantee O(log n) operations by maintaining balance.

class TreeNode<T> {
  constructor(
    public value: T,
    public left: TreeNode<T> | null = null,
    public right: TreeNode<T> | null = null
  ) {}
}
 
class BinarySearchTree<T> {
  private root: TreeNode<T> | null = null
 
  insert(value: T): void {
    this.root = this.insertNode(this.root, value)
  }
 
  private insertNode(node: TreeNode<T> | null, value: T): TreeNode<T> {
    if (!node) return new TreeNode(value)
 
    if (value < node.value) {
      node.left = this.insertNode(node.left, value)
    } else if (value > node.value) {
      node.right = this.insertNode(node.right, value)
    }
 
    return node
  }
 
  search(value: T): boolean {
    return this.searchNode(this.root, value)
  }
 
  private searchNode(node: TreeNode<T> | null, value: T): boolean {
    if (!node) return false
    if (value === node.value) return true
    return value < node.value
      ? this.searchNode(node.left, value)
      : this.searchNode(node.right, value)
  }
 
  // In-order traversal returns sorted values
  inOrder(): T[] {
    const result: T[] = []
    this.inOrderTraversal(this.root, result)
    return result
  }
 
  private inOrderTraversal(node: TreeNode<T> | null, result: T[]): void {
    if (!node) return
    this.inOrderTraversal(node.left, result)
    result.push(node.value)
    this.inOrderTraversal(node.right, result)
  }
}

Tree data structure

Graphs

Graphs consist of vertices (nodes) and edges (connections). They model relationships—social networks, road maps, dependency trees, and network topologies. Graphs can be directed or undirected, weighted or unweighted, and cyclic or acyclic.

// Adjacency list representation
class Graph<T> {
  private adjacencyList: Map<T, Set<T>> = new Map()
 
  addVertex(vertex: T): void {
    if (!this.adjacencyList.has(vertex)) {
      this.adjacencyList.set(vertex, new Set())
    }
  }
 
  addEdge(from: T, to: T): void {
    this.addVertex(from)
    this.addVertex(to)
    this.adjacencyList.get(from)!.add(to)
    this.adjacencyList.get(to)!.add(from) // For undirected graph
  }
 
  // Breadth-First Search
  bfs(start: T): T[] {
    const visited = new Set<T>()
    const queue: T[] = [start]
    const result: T[] = []
 
    visited.add(start)
 
    while (queue.length > 0) {
      const vertex = queue.shift()!
      result.push(vertex)
 
      for (const neighbor of this.adjacencyList.get(vertex) || []) {
        if (!visited.has(neighbor)) {
          visited.add(neighbor)
          queue.push(neighbor)
        }
      }
    }
 
    return result
  }
 
  // Depth-First Search
  dfs(start: T): T[] {
    const visited = new Set<T>()
    const result: T[] = []
 
    const dfsHelper = (vertex: T) => {
      visited.add(vertex)
      result.push(vertex)
 
      for (const neighbor of this.adjacencyList.get(vertex) || []) {
        if (!visited.has(neighbor)) {
          dfsHelper(neighbor)
        }
      }
    }
 
    dfsHelper(start)
    return result
  }
 
  // Shortest path (unweighted)
  shortestPath(start: T, end: T): T[] | null {
    const visited = new Set<T>()
    const queue: T[][] = [[start]]
    visited.add(start)
 
    while (queue.length > 0) {
      const path = queue.shift()!
      const vertex = path[path.length - 1]
 
      if (vertex === end) return path
 
      for (const neighbor of this.adjacencyList.get(vertex) || []) {
        if (!visited.has(neighbor)) {
          visited.add(neighbor)
          queue.push([...path, neighbor])
        }
      }
    }
 
    return null
  }
}

Heaps (Priority Queues)

A heap is a complete binary tree that satisfies the heap property: in a min-heap, every parent is smaller than its children; in a max-heap, every parent is larger. Heaps are used to implement priority queues, heap sort, and algorithms like Dijkstra's shortest path.

class MinHeap<T> {
  private data: T[] = []
 
  get size(): number { return this.data.length }
  get isEmpty(): boolean { return this.data.length === 0 }
 
  peek(): T | undefined { return this.data[0] }
 
  insert(value: T): void {
    this.data.push(value)
    this.bubbleUp(this.data.length - 1)
  }
 
  extractMin(): T | undefined {
    if (this.data.length === 0) return undefined
    if (this.data.length === 1) return this.data.pop()
 
    const min = this.data[0]
    this.data[0] = this.data.pop()!
    this.sinkDown(0)
    return min
  }
 
  private bubbleUp(index: number): void {
    while (index > 0) {
      const parent = Math.floor((index - 1) / 2)
      if (this.data[parent] <= this.data[index]) break
      ;[this.data[parent], this.data[index]] = [this.data[index], this.data[parent]]
      index = parent
    }
  }
 
  private sinkDown(index: number): void {
    const length = this.data.length
    while (true) {
      let smallest = index
      const left = 2 * index + 1
      const right = 2 * index + 2
 
      if (left < length && this.data[left] < this.data[smallest]) smallest = left
      if (right < length && this.data[right] < this.data[smallest]) smallest = right
 
      if (smallest === index) break
      ;[this.data[index], this.data[smallest]] = [this.data[smallest], this.data[index]]
      index = smallest
    }
  }
}

Tries

A trie (prefix tree) is a tree-like data structure for storing strings. Each node represents a character, and paths from root to leaf represent words. Tries are used for autocomplete, spell checking, and IP routing.

class TrieNode {
  children: Map<string, TrieNode> = new Map()
  isEndOfWord: boolean = false
}
 
class Trie {
  private root = new TrieNode()
 
  insert(word: string): void {
    let node = this.root
    for (const char of word) {
      if (!node.children.has(char)) {
        node.children.set(char, new TrieNode())
      }
      node = node.children.get(char)!
    }
    node.isEndOfWord = true
  }
 
  search(word: string): boolean {
    let node = this.root
    for (const char of word) {
      if (!node.children.has(char)) return false
      node = node.children.get(char)!
    }
    return node.isEndOfWord
  }
 
  startsWith(prefix: string): string[] {
    let node = this.root
    for (const char of prefix) {
      if (!node.children.has(char)) return []
      node = node.children.get(char)!
    }
    const results: string[] = []
    this.collectWords(node, prefix, results)
    return results
  }
 
  private collectWords(node: TrieNode, prefix: string, results: string[]): void {
    if (node.isEndOfWord) results.push(prefix)
    for (const [char, child] of node.children) {
      this.collectWords(child, prefix + char, results)
    }
  }
}

Performance Comparison

StructureAccessSearchInsertDeleteSpaceBest For
ArrayO(1)O(n)O(1)*O(n)O(n)Index-based access
Linked ListO(n)O(n)O(1)O(1)O(n)Frequent head insertions
Hash TableN/AO(1)*O(1)*O(1)*O(n)Key-based lookups
BST (balanced)O(log n)O(log n)O(log n)O(log n)O(n)Ordered data
HeapO(1)**O(n)O(log n)O(log n)O(n)Priority queues
TrieN/AO(m)***O(m)O(m)O(n*m)Prefix search

*Amortized / **Peek only / ***m = word length

Real-World Applications

Caching with LRU Cache

An LRU (Least Recently Used) cache combines a hash map and a doubly linked list to provide O(1) get and put operations:

class LRUCache<K, V> {
  private cache = new Map<K, V>()
  private readonly maxSize: number
 
  constructor(maxSize: number) {
    this.maxSize = maxSize
  }
 
  get(key: K): V | undefined {
    if (!this.cache.has(key)) return undefined
    const value = this.cache.get(key)!
    // Move to end (most recently used)
    this.cache.delete(key)
    this.cache.set(key, value)
    return value
  }
 
  put(key: K, value: V): void {
    if (this.cache.has(key)) {
      this.cache.delete(key)
    } else if (this.cache.size >= this.maxSize) {
      // Remove least recently used (first entry)
      const firstKey = this.cache.keys().next().value
      this.cache.delete(firstKey)
    }
    this.cache.set(key, value)
  }
}

Graph Algorithms for Social Networks

Social networks use graph algorithms for friend recommendations, community detection, and influence analysis. BFS finds shortest connection paths, while PageRank identifies influential nodes.

Conclusion

Data structures are not just academic exercises—they're practical tools that directly impact your application's performance and scalability. Choosing the right data structure for your access pattern is one of the most impactful decisions you can make.

Key takeaways:

  1. Hash tables are your default for key-based lookups—O(1) average performance makes them the right choice for most mapping and caching scenarios.
  2. Arrays excel at sequential access and index-based operations—use them when you need fast iteration and random access.
  3. Trees provide ordered data with O(log n) operations—use BSTs, heaps, and tries when ordering or hierarchical relationships matter.
  4. Graphs model relationships—use them for social networks, dependency analysis, and pathfinding.

Practice implementing these structures from scratch to build intuition. Refer to VisuAlgo for interactive visualizations and Big-O Cheat Sheet for quick performance reference.