← Tutorials ☕ Java 🧩 DSA 🌿 Spring Boot 🚀 DevOps 🗂 MySQL 🏗 System Design ❓ FAQ 🌐 HTML 🔇 JavaScript ⚛ ReactJS 🌻 NodeJS 🐍 Python 🍏 MongoDB 📋 Cheatsheet
Tutorials › DSA

What are Data Structures?

Foundations · 5 min read

A data structure is a way of organising and storing data in memory so it can be accessed and modified efficiently. Choosing the right data structure is often the most important decision in solving a problem.

Think of it like this

Imagine you're organising books:

  • Stacked in a pile → Stack (last in, first out)
  • In a queue at checkout → Queue (first in, first out)
  • In alphabetical order on a shelf → Sorted Array / BST
  • In a filing cabinet with folders → Hash Map
  • In a family tree → Tree
  • Road network between cities → Graph

Linear vs Non-Linear

TypeStructureExamples
LinearElements in a sequenceArray, Linked List, Stack, Queue
Non-LinearElements in a hierarchy or networkTree, Graph

Abstract Data Types (ADTs)

An ADT defines what operations are supported, not how they are implemented. For example, a Stack ADT says "push, pop, peek" — it can be implemented using an array OR a linked list underneath.

Why Data Structures Matter in Interviews

  • Every coding interview tests which structure you choose and why
  • Wrong structure = slower code (can fail time limit)
  • Interviewers look for: correct choice, knowledge of trade-offs, time/space complexity justification
💡 Interview tip: Before writing code, always think: "What operations do I need? Insert, delete, search, sort? What's my time constraint?" — then pick the structure.

Arrays

Data Structures · 8 min read

An array stores elements in contiguous memory. Every element is at index i and directly addressable in O(1). It's the most fundamental data structure.

Memory Layout

If an int array starts at memory address 100 and each int is 4 bytes:

// arr = [10, 20, 30, 40, 50] Index: 0 1 2 3 4 Address: 100 104 108 112 116 arr[2] → go to 100 + (2 × 4) = 108 → value: 30 // O(1) — that's why index access is instant

Time Complexity

OperationComplexityWhy
Access by indexO(1)Direct address calculation
Search (unsorted)O(n)Must check each element
Search (sorted)O(log n)Binary search
Insert at endO(1) amortizedDynamic array doubles capacity
Insert at middleO(n)Must shift elements right
Delete at middleO(n)Must shift elements left

Pattern 1 — Prefix Sum (Range Queries in O(1))

Pre-compute cumulative sums so any range sum [l, r] is answered in O(1).

int[] arr = {3, 1, 4, 1, 5}; int[] prefix = new int[arr.length + 1]; for (int i = 0; i < arr.length; i++) prefix[i+1] = prefix[i] + arr[i]; // prefix = [0, 3, 4, 8, 9, 14] // Sum from index 1 to 3 (inclusive): prefix[4] - prefix[1] = 9 - 3 = 6 int rangeSum(int l, int r) { return prefix[r+1] - prefix[l]; }

Pattern 2 — Kadane's Algorithm (Max Subarray Sum)

int maxSubarray(int[] arr) { int maxSoFar = arr[0], maxEndingHere = arr[0]; for (int i = 1; i < arr.length; i++) { // Either extend the current subarray OR start fresh from current element maxEndingHere = Math.max(arr[i], maxEndingHere + arr[i]); maxSoFar = Math.max(maxSoFar, maxEndingHere); } return maxSoFar; } // Input: [-2, 1, -3, 4, -1, 2, 1, -5, 4] → Output: 6 (subarray [4,-1,2,1])

Pattern 3 — Two Pointers (Pair Sum in Sorted Array)

boolean hasPairWithSum(int[] sorted, int target) { int lo = 0, hi = sorted.length - 1; while (lo < hi) { int sum = sorted[lo] + sorted[hi]; if (sum == target) return true; else if (sum < target) lo++; // need bigger sum → move left ptr right else hi--; // need smaller sum → move right ptr left } return false; } // O(n) time, O(1) space

Pattern 4 — Sliding Window (Max Sum of K-size Window)

int maxSumWindow(int[] arr, int k) { int sum = 0; for (int i = 0; i < k; i++) sum += arr[i]; // first window int max = sum; for (int i = k; i < arr.length; i++) { sum += arr[i] - arr[i - k]; // slide: add new, remove old max = Math.max(max, sum); } return max; } // O(n) instead of O(n*k) brute force
💡 Interview tip: Arrays + Prefix Sum + Two Pointers + Sliding Window solve about 40% of all array interview questions.

Hash Tables

Data Structures · 8 min read

A hash table (also called hash map or dictionary) gives O(1) average lookup, insert, and delete. It maps keys to values using a hash function.

How It Works Internally

// Step 1: Compute hash of key hash("name") → 4 // some integer // Step 2: Map to bucket index index = hash % bucketCount // e.g. 4 % 7 = 4 → slot 4 // Step 3: Store key-value pair at that slot bucket[4] → ("name", "Aftab")

Handling Collisions (Two Keys → Same Slot)

  • Chaining — each bucket holds a linked list. If two keys hash to slot 4, both go in that slot's list. Java's HashMap uses chaining.
  • Open Addressing — if slot is taken, probe the next slot (linear probing, quadratic probing). Used in Python dicts.
// Chaining example (conceptual) bucket[4] → [("name", "Aftab")] → [("mane", "lion")] // both hashed to 4

Java HashMap — Core Operations

Map<String, Integer> map = new HashMap<>(); map.put("apple", 3); // insert/update O(1) map.get("apple"); // returns 3 O(1) map.containsKey("apple"); // true O(1) map.remove("apple"); // delete O(1) map.getOrDefault("x", 0); // safe get, no NPE // Iterate for (Map.Entry<String, Integer> e : map.entrySet()) System.out.println(e.getKey() + " = " + e.getValue()); // Frequency count pattern (most common hash map use) int[] nums = {1, 2, 2, 3, 3, 3}; Map<Integer, Integer> freq = new HashMap<>(); for (int n : nums) freq.put(n, freq.getOrDefault(n, 0) + 1); // freq = {1:1, 2:2, 3:3}

Pattern — Two Sum (O(n) with HashMap)

int[] twoSum(int[] nums, int target) { Map<Integer, Integer> seen = new HashMap<>(); // value → index for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (seen.containsKey(complement)) return new int[]{ seen.get(complement), i }; seen.put(nums[i], i); } return new int[]{-1, -1}; } // [2,7,11,15], target=9 → [0,1] (2+7=9)

Pattern — Group Anagrams

List<List<String>> groupAnagrams(String[] words) { Map<String, List<String>> map = new HashMap<>(); for (String w : words) { char[] ch = w.toCharArray(); Arrays.sort(ch); String key = new String(ch); // "eat","tea","ate" all → "aet" map.computeIfAbsent(key, k -> new ArrayList<>()).add(w); } return new ArrayList<>(map.values()); }

HashSet — For Uniqueness & O(1) Lookup

Set<Integer> set = new HashSet<>(); set.add(1); set.add(2); set.add(2); // {1, 2} — duplicates ignored set.contains(2); // true O(1) set.remove(1); // O(1) // Check duplicate in array — O(n) boolean hasDuplicate(int[] nums) { Set<Integer> seen = new HashSet<>(); for (int n : nums) if (!seen.add(n)) return true; // add() returns false if already exists return false; }
💡 When you see "find a pair", "check if exists", "count occurrences", or "group by" in an interview — reach for HashMap/HashSet first.

Linked Lists

Data Structures · 10 min read

A linked list is a chain of nodes where each node stores data and a reference (pointer) to the next node. Unlike arrays, nodes are not stored contiguously in memory — they can be anywhere in RAM.

Array vs Linked List

OperationArrayLinked List
Access by indexO(1)O(n)
SearchO(n)O(n)
Insert at headO(n) — must shiftO(1)
Insert at tailO(1) amortizedO(1) with tail pointer
Insert at middleO(n) — must shiftO(1) once you have the node
Delete at headO(n) — must shiftO(1)
MemoryCompact (no extra pointers)Extra pointer per node

Singly Linked List — Node & Core Operations

class ListNode { int val; ListNode next; ListNode(int val) { this.val = val; } } // Build: 1 → 2 → 3 ListNode head = new ListNode(1); head.next = new ListNode(2); head.next.next = new ListNode(3); // Traverse ListNode cur = head; while (cur != null) { System.out.print(cur.val + " "); cur = cur.next; } // Insert at head — O(1) ListNode insertAtHead(ListNode head, int val) { ListNode node = new ListNode(val); node.next = head; return node; // new head } // Delete a node with given value ListNode delete(ListNode head, int val) { ListNode dummy = new ListNode(0); // dummy head simplifies edge cases dummy.next = head; ListNode prev = dummy; while (prev.next != null) { if (prev.next.val == val) { prev.next = prev.next.next; break; } prev = prev.next; } return dummy.next; }

Reverse a Linked List (Most Common Interview Problem)

ListNode reverse(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode nextTemp = cur.next; // save next cur.next = prev; // reverse pointer prev = cur; // advance prev cur = nextTemp; // advance cur } return prev; // prev is the new head } // 1→2→3→null becomes null←1←2←3 → new head = 3

Fast & Slow Pointer — Find Middle + Detect Cycle

// Find middle node ListNode findMiddle(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; // moves 1 step fast = fast.next.next; // moves 2 steps } return slow; // when fast hits end, slow is at middle } // Floyd's Cycle Detection boolean hasCycle(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) return true; // they meet inside cycle } return false; }

Linked List Types

Data Structures · 8 min read

1. Singly Linked List

Each node has one pointer: next. Can only traverse forward.

// Structure: head → [1|next] → [2|next] → [3|null] class SinglyNode { int val; SinglyNode next; SinglyNode(int val) { this.val = val; } } // Use when: you only traverse forward, memory is tight, or you need a simple stack/queue.

2. Doubly Linked List

Each node has two pointers: prev and next. Can traverse both directions. Java's LinkedList is a doubly linked list.

class DNode { int val; DNode prev, next; DNode(int val) { this.val = val; } } // Structure: null ← [1|prev|next] ↔ [2|prev|next] ↔ [3|prev|next] → null class DoublyLinkedList { DNode head, tail; void addToFront(int val) { DNode node = new DNode(val); if (head == null) { head = tail = node; return; } node.next = head; head.prev = node; head = node; } void addToBack(int val) { DNode node = new DNode(val); if (tail == null) { head = tail = node; return; } tail.next = node; node.prev = tail; tail = node; } void deleteNode(DNode node) { // O(1) — this is the big advantage over singly! if (node.prev != null) node.prev.next = node.next; else head = node.next; // deleting head if (node.next != null) node.next.prev = node.prev; else tail = node.prev; // deleting tail } } // Use when: you need to traverse backwards, or need O(1) delete given a node pointer (e.g., LRU Cache).

3. Circular Linked List

The tail's next points back to the head instead of null. Can be singly or doubly circular.

// Singly circular: head → [1] → [2] → [3] → (back to head) class CircularList { SinglyNode head = null; void add(int val) { SinglyNode node = new SinglyNode(val); if (head == null) { head = node; node.next = head; return; } SinglyNode tail = head; while (tail.next != head) tail = tail.next; tail.next = node; node.next = head; } void traverse() { if (head == null) return; SinglyNode cur = head; do { System.out.print(cur.val + " "); cur = cur.next; } while (cur != head); // stop when we loop back to head } } // Use when: you need continuous looping (round-robin scheduling, circular buffers).

Interview — LRU Cache (Classic Doubly LL + HashMap)

class LRUCache { int capacity; Map<Integer, DNode> map = new HashMap<>(); DNode head = new DNode(0); // dummy head DNode tail = new DNode(0); // dummy tail LRUCache(int cap) { capacity = cap; head.next = tail; tail.prev = head; } int get(int key) { if (!map.containsKey(key)) return -1; DNode node = map.get(key); moveToFront(node); // recently used return node.val; } void put(int key, int value) { if (map.containsKey(key)) { DNode node = map.get(key); node.val = value; moveToFront(node); } else { if (map.size() == capacity) { map.remove(tail.prev.val); removeNode(tail.prev); // evict LRU (tail side) } DNode node = new DNode(value); map.put(key, node); addToFront(node); } } // Helper methods: addToFront, removeNode, moveToFront (standard doubly-LL ops) } // get() and put() both O(1)!

Stacks & Queues

Data Structures · 8 min read

Stack — Last In, First Out (LIFO)

Think of a stack of plates — you add to the top, remove from the top.

// Use Deque<Integer> (not java.util.Stack which is slow) Deque<Integer> stack = new ArrayDeque<>(); stack.push(10); // push to top O(1) stack.push(20); stack.peek(); // view top = 20 O(1) stack.pop(); // remove top = 20 O(1) stack.isEmpty(); // false

Classic — Balanced Parentheses

boolean isValid(String s) { Deque<Character> stack = new ArrayDeque<>(); for (char c : s.toCharArray()) { if (c == '(' || c == '[' || c == '{') { stack.push(c); } else { if (stack.isEmpty()) return false; char top = stack.pop(); if (c == ')' && top != '(') return false; if (c == ']' && top != '[') return false; if (c == '}' && top != '{') return false; } } return stack.isEmpty(); } // "{[]}" → true "([)]" → false

Monotonic Stack — Next Greater Element

int[] nextGreater(int[] arr) { int[] result = new int[arr.length]; Arrays.fill(result, -1); // default: no greater element Deque<Integer> stack = new ArrayDeque<>(); // stores indices for (int i = 0; i < arr.length; i++) { while (!stack.isEmpty() && arr[stack.peek()] < arr[i]) { result[stack.pop()] = arr[i]; } stack.push(i); } return result; } // [2, 1, 5, 3, 6] → [5, 5, 6, 6, -1]

Queue — First In, First Out (FIFO)

Think of a bank queue — first person in gets served first.

Queue<Integer> queue = new LinkedList<>(); queue.offer(10); // enqueue O(1) queue.offer(20); queue.peek(); // front = 10 O(1) queue.poll(); // dequeue = 10 O(1) // Deque — works as both stack and queue Deque<Integer> deque = new ArrayDeque<>(); deque.addFirst(1); // add to front deque.addLast(2); // add to back deque.removeFirst(); // remove from front deque.removeLast(); // remove from back
💡 Stack use cases: undo/redo, DFS, function call stack, expression evaluation
Queue use cases: BFS, task scheduling, print queue, message buffering

Binary Trees

Data Structures · 10 min read

A binary tree is a hierarchical structure where each node has at most two children: left and right. It's the foundation for BSTs, heaps, tries, and many more structures.

Terminology You Must Know

  • Root — top node, no parent
  • Leaf — node with no children
  • Height — longest path from root to a leaf
  • Depth — distance from root to a specific node
  • Level — depth + 1 (root is level 1)
  • Subtree — any node and all its descendants

Types of Binary Trees

TypeProperty
Full Binary TreeEvery node has 0 or 2 children (no node has exactly 1)
Complete Binary TreeAll levels filled except possibly last; last level filled left to right
Perfect Binary TreeAll internal nodes have 2 children; all leaves at same depth
Balanced Binary TreeHeight difference between left and right subtree ≤ 1 (for every node)
Degenerate/SkewedEvery node has only one child — behaves like a linked list

Node Structure

class TreeNode { int val; TreeNode left, right; TreeNode(int val) { this.val = val; } }

The 4 Traversals (Must Know All)

// Tree: 1 // / \ // 2 3 // / \ // 4 5 // 1. Inorder: Left → Root → Right (gives SORTED output for BST) void inorder(TreeNode node) { if (node == null) return; inorder(node.left); System.out.print(node.val + " "); // 4 2 5 1 3 inorder(node.right); } // 2. Preorder: Root → Left → Right (used to copy/serialize tree) void preorder(TreeNode node) { if (node == null) return; System.out.print(node.val + " "); // 1 2 4 5 3 preorder(node.left); preorder(node.right); } // 3. Postorder: Left → Right → Root (used to delete tree, evaluate expressions) void postorder(TreeNode node) { if (node == null) return; postorder(node.left); postorder(node.right); System.out.print(node.val + " "); // 4 5 2 3 1 } // 4. Level Order (BFS): Level by level (used for shortest path, level problems) List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> result = new ArrayList<>(); if (root == null) return result; Queue<TreeNode> q = new LinkedList<>(); q.offer(root); while (!q.isEmpty()) { int size = q.size(); // number of nodes at this level List<Integer> level = new ArrayList<>(); for (int i = 0; i < size; i++) { TreeNode node = q.poll(); level.add(node.val); if (node.left != null) q.offer(node.left); if (node.right != null) q.offer(node.right); } result.add(level); } return result; }

Height of a Binary Tree

int height(TreeNode node) { if (node == null) return 0; return 1 + Math.max(height(node.left), height(node.right)); } // Diameter = longest path between any two nodes int diameter = 0; int diameterOfTree(TreeNode node) { if (node == null) return 0; int left = diameterOfTree(node.left); int right = diameterOfTree(node.right); diameter = Math.max(diameter, left + right); // path through this node return 1 + Math.max(left, right); }
💡 Interview tip: Most binary tree problems are solved with recursion. Ask: "What should I return from each node? What do I do with left and right results?"

Binary Search Tree (BST)

Data Structures · 8 min read

A BST is a binary tree with one rule: left subtree values < node < right subtree values for every node. This makes search O(log n) on a balanced tree.

Search, Insert, Delete

// Search — O(log n) balanced, O(n) worst case (skewed) boolean search(TreeNode root, int val) { if (root == null) return false; if (val == root.val) return true; return val < root.val ? search(root.left, val) : search(root.right, val); } // Insert TreeNode insert(TreeNode root, int val) { if (root == null) return new TreeNode(val); if (val < root.val) root.left = insert(root.left, val); else root.right = insert(root.right, val); return root; } // Delete — 3 cases TreeNode delete(TreeNode root, int val) { if (root == null) return null; if (val < root.val) root.left = delete(root.left, val); else if (val > root.val) root.right = delete(root.right, val); else { if (root.left == null) return root.right; // case 1: no left child if (root.right == null) return root.left; // case 2: no right child // case 3: two children — replace with inorder successor (smallest in right subtree) TreeNode successor = root.right; while (successor.left != null) successor = successor.left; root.val = successor.val; root.right = delete(root.right, successor.val); } return root; }

Validate a BST

boolean isValidBST(TreeNode node, long min, long max) { if (node == null) return true; if (node.val <= min || node.val >= max) return false; return isValidBST(node.left, min, node.val) && isValidBST(node.right, node.val, max); } // call: isValidBST(root, Long.MIN_VALUE, Long.MAX_VALUE)
💡 Inorder traversal of a valid BST always produces a sorted sequence. Use this fact to validate or convert BSTs.

Graphs

Data Structures · 10 min read

A graph is a set of vertices (nodes) connected by edges. Unlike trees, graphs can have cycles and multiple paths between nodes.

Types of Graphs

  • Directed — edges have direction (A→B doesn't mean B→A). Example: Twitter follows.
  • Undirected — edges go both ways. Example: Facebook friends.
  • Weighted — edges have costs. Example: road distances.
  • Unweighted — all edges equal. Example: maze cells.
  • Cyclic — contains at least one cycle.
  • Acyclic — no cycles. A DAG (Directed Acyclic Graph) is used in build systems, scheduling.

Representations

// 1. Adjacency List — preferred for sparse graphs, O(V+E) space Map<Integer, List<Integer>> graph = new HashMap<>(); graph.computeIfAbsent(1, k -> new ArrayList<>()).add(2); graph.computeIfAbsent(1, k -> new ArrayList<>()).add(3); graph.computeIfAbsent(2, k -> new ArrayList<>()).add(4); // graph: 1→[2,3], 2→[4] // 2. Adjacency Matrix — O(V²) space, good for dense graphs int V = 5; int[][] matrix = new int[V][V]; matrix[1][2] = 1; // edge from 1 to 2 matrix[2][1] = 1; // undirected: also 2 to 1

Key Graph Terms

TermMeaning
DegreeNumber of edges connected to a vertex
PathSequence of vertices connected by edges
CyclePath that starts and ends at the same vertex
ConnectedEvery vertex reachable from every other (undirected)
Strongly ConnectedEvery vertex reachable from every other (directed)
Topological SortLinear ordering of vertices where u comes before v for every edge u→v (only for DAGs)
💡 Graph problems are solved with BFS (shortest path, levels) or DFS (cycles, connected components, topological sort). Covered in detail in the Searching section.

What is an Algorithm?

Algorithms · 5 min read

An algorithm is a finite, well-defined sequence of steps to solve a specific problem. It takes input, processes it, and produces output.

5 Properties Every Algorithm Must Have

  • Input — takes zero or more inputs
  • Output — produces at least one output
  • Definiteness — every step is clear and unambiguous
  • Finiteness — must terminate after a finite number of steps
  • Effectiveness — every step must be basic enough to be carried out

Algorithm vs Program

  • An algorithm is language-independent — it's the idea/logic
  • A program is the implementation of an algorithm in a specific language
  • The same algorithm (e.g., binary search) can be written in Java, Python, C++

How to Analyse an Algorithm

We measure two things:

  • Time Complexity — how many steps/operations as input grows
  • Space Complexity — how much extra memory as input grows
// Example: find max in an array int findMax(int[] arr) { // Input: array of n elements int max = arr[0]; // 1 operation for (int x : arr) { // n iterations if (x > max) max = x; // 1 operation each } return max; } // Time: O(n) Space: O(1) — only one extra variable regardless of input size
💡 A good algorithm is correct (gives right answer), efficient (fast and low memory), and simple (easy to understand and maintain).

Time & Space Complexity

Algorithms · 8 min read

Big-O notation describes how an algorithm's runtime or memory grows with input size n. We always analyse the worst case unless told otherwise.

Big-O Cheat Sheet (Fastest to Slowest)

Big-ONameExamplen=1000
O(1)ConstantArray index access, HashMap lookup1 op
O(log n)LogarithmicBinary search, BST ops~10 ops
O(n)LinearSingle loop, linear search1,000 ops
O(n log n)LinearithmicMerge sort, heap sort~10,000 ops
O(n²)QuadraticBubble/selection/insertion sort, nested loops1,000,000 ops
O(2ⁿ)ExponentialBrute-force subsets, naive recursionAstronomical
O(n!)FactorialPermutations, travelling salesman brute-forceImpossible

Rules for Calculating Big-O

// Rule 1: Drop constants // O(2n) → O(n), O(500) → O(1) // Rule 2: Drop lower-order terms // O(n² + n) → O(n²) // Rule 3: Loops multiply when nested, add when sequential for (int i = 0; i < n; i++) // O(n) for (int j = 0; j < n; j++) // O(n) doSomething(); // Total: O(n²) for (int i = 0; i < n; i++) {} // O(n) for (int j = 0; j < n; j++) {} // O(n) — sequential, not nested // Total: O(n) + O(n) = O(n) // Rule 4: Halving each step → O(log n) while (n > 1) n = n / 2; // O(log n)

Space Complexity

// O(1) space — fixed variables, no data structures growing with n int sum = 0; for (int x : arr) sum += x; // O(n) space — array/list that grows with n int[] copy = Arrays.copyOf(arr, arr.length); // O(log n) space — recursion depth of binary search / merge sort // Recursive call stack counts as space!
💡 In interviews, always state both time AND space complexity. Say: "This solution is O(n) time and O(1) space." Interviewers deduct marks if you don't mention both.

Recursion

Algorithms · 10 min read

Recursion is when a function calls itself to solve a smaller version of the same problem. Every recursive solution needs two things: a base case (stopping condition) and a recursive case (calling itself with smaller input).

How the Call Stack Works

int factorial(int n) { if (n == 0) return 1; // BASE CASE — must exist or infinite recursion return n * factorial(n - 1); // RECURSIVE CASE — smaller input each time } // factorial(4) call stack (grows downward): // factorial(4) → 4 * factorial(3) // factorial(3) → 3 * factorial(2) // factorial(2) → 2 * factorial(1) // factorial(1) → 1 * factorial(0) // factorial(0) → 1 ← base case, unwind starts // Returns: 4 * 3 * 2 * 1 * 1 = 24

Fibonacci — Naive vs Optimised

// Naive — O(2ⁿ): each call branches into 2 more calls int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); // fib(5) calls fib(3) TWICE — wasteful } // With memoization — O(n): each value computed once Map<Integer, Integer> memo = new HashMap<>(); int fibMemo(int n) { if (n <= 1) return n; if (memo.containsKey(n)) return memo.get(n); int result = fibMemo(n - 1) + fibMemo(n - 2); memo.put(n, result); return result; }

Backtracking Template

// Generate all subsets of [1,2,3] void backtrack(List<List<Integer>> res, List<Integer> curr, int[] nums, int start) { res.add(new ArrayList<>(curr)); // add snapshot of current path for (int i = start; i < nums.length; i++) { curr.add(nums[i]); // choose backtrack(res, curr, nums, i + 1); // explore curr.remove(curr.size() - 1); // un-choose (backtrack) } }
💡 3-step recursion recipe: 1) Define what the function returns. 2) Write the base case. 3) Assume recursive call works for n-1, use its result to solve n.

Stack Overflow

Algorithms · 6 min read

A Stack Overflow happens when the call stack runs out of memory because too many function calls are stacked up without returning. The most common cause is recursion with no base case or a base case that's never reached.

How the Call Stack Works

Every time you call a function, a new stack frame is pushed onto the call stack. It holds local variables, parameters, and the return address. When the function returns, the frame is popped off.

// Stack Overflow: missing base case — runs forever int badFactorial(int n) { return n * badFactorial(n - 1); // never stops! → StackOverflowError } // Stack Overflow: base case never reached int badSearch(int[] arr, int i) { if (i == 1000000) return -1; // arr.length might be 10 — never reaches 1000000 return badSearch(arr, i + 1); }

How to Fix

  • Add/fix the base case — always verify the recursion terminates
  • Convert to iterative — use an explicit stack (Deque) instead of call stack
  • Increase stack size-Xss4m JVM flag (rarely the real fix)
  • Use tail recursion — some languages optimise tail calls (Java doesn't, but the pattern is cleaner)
// Tail recursion — recursive call is the LAST operation (no work after it) int factTail(int n, int acc) { if (n == 0) return acc; return factTail(n - 1, n * acc); // result is passed forward, not multiplied after return } // call: factTail(5, 1) // Convert to iterative to avoid stack overflow entirely int factIterative(int n) { int result = 1; for (int i = 2; i <= n; i++) result *= i; return result; }
⚠️ Java's default thread stack is ~512KB. Deep recursion on large inputs (n > ~10,000) will throw java.lang.StackOverflowError. Always prefer iteration for large inputs.

Sorting Algorithms

Algorithms · 5 min read

Sorting arranges elements in order (ascending or descending). It's fundamental because many other algorithms (binary search, merging) require sorted input.

Comparison at a Glance

AlgorithmBestAverageWorstSpaceStable?Strategy
Bubble SortO(n)O(n²)O(n²)O(1)YesCompare adjacent
Selection SortO(n²)O(n²)O(n²)O(1)NoFind minimum
Insertion SortO(n)O(n²)O(n²)O(1)YesBuild sorted prefix
Merge SortO(n log n)O(n log n)O(n log n)O(n)YesDivide & conquer
Quick SortO(n log n)O(n log n)O(n²)O(log n)NoPartition
Heap SortO(n log n)O(n log n)O(n log n)O(1)NoHeap property

A sort is stable if equal elements maintain their original relative order. This matters when sorting objects by one field when they're already sorted by another.

💡 Java's Arrays.sort(int[]) uses Dual-Pivot Quicksort. Arrays.sort(Object[]) and Collections.sort() use TimSort (merge + insertion), which is stable.

Bubble Sort

Algorithms · 6 min read

Bubble sort repeatedly compares adjacent pairs and swaps them if out of order. The largest element "bubbles up" to its correct position after each pass.

Visualisation

// Array: [5, 3, 1, 4, 2] // Pass 1: compare neighbours, swap if needed // [5,3,1,4,2] → [3,5,1,4,2] → [3,1,5,4,2] → [3,1,4,5,2] → [3,1,4,2,5] ← 5 in place // Pass 2: [1,3,4,2,5] → ... → [1,3,2,4,5] ← 4 in place // Pass 3: [1,2,3,4,5] ← done

Implementation

void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { // n-1 passes boolean swapped = false; for (int j = 0; j < n - 1 - i; j++) { // last i elements are already sorted if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = temp; swapped = true; } } if (!swapped) break; // optimisation: already sorted → O(n) best case } } // Time: O(n²) average/worst, O(n) best (already sorted) Space: O(1)
💡 Bubble sort is almost never used in production. Its value is in simplicity and teaching. Interviewers may ask you to code it, explain it, or discuss when NOT to use it.

Selection Sort

Algorithms · 5 min read

Selection sort divides the array into a sorted part (left) and unsorted part (right). It repeatedly finds the minimum element in the unsorted part and moves it to the end of the sorted part.

Visualisation

// Array: [5, 3, 1, 4, 2] // Pass 1: min of [5,3,1,4,2] = 1 → swap with index 0 → [1, 3, 5, 4, 2] // Pass 2: min of [3,5,4,2] = 2 → swap with index 1 → [1, 2, 5, 4, 3] // Pass 3: min of [5,4,3] = 3 → swap with index 2 → [1, 2, 3, 4, 5] // Pass 4: min of [4,5] = 4 → already in place → [1, 2, 3, 4, 5]

Implementation

void selectionSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[minIdx]) minIdx = j; // track index of minimum } int temp = arr[minIdx]; arr[minIdx] = arr[i]; arr[i] = temp; // swap min to front } } // Time: O(n²) always — no early exit. Space: O(1) // Makes at most n-1 swaps — useful when write operations are expensive
⚠️ Selection sort is NOT stable — swapping can change the relative order of equal elements. Use when minimising the number of writes (e.g., writing to flash memory).

Insertion Sort

Algorithms · 6 min read

Insertion sort builds a sorted array one element at a time. Think of sorting playing cards in your hand — you pick each new card and insert it in its correct position among the already-sorted cards.

Visualisation

// Array: [5, 3, 1, 4, 2] // i=1: key=3, shift 5 right → [3, 5, 1, 4, 2] // i=2: key=1, shift 5,3 right → [1, 3, 5, 4, 2] // i=3: key=4, shift 5 right → [1, 3, 4, 5, 2] // i=4: key=2, shift 5,4,3 right → [1, 2, 3, 4, 5]

Implementation

void insertionSort(int[] arr) { int n = arr.length; for (int i = 1; i < n; i++) { int key = arr[i]; // element to place int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; // shift larger elements right j--; } arr[j + 1] = key; // insert key in its correct position } } // Time: O(n²) worst, O(n) best (already sorted) Space: O(1) Stable: Yes
💡 Insertion sort is adaptive (fast on nearly sorted data) and online (can sort a stream as elements arrive). Java's TimSort uses insertion sort for small sub-arrays (< 32 elements).

Divide & Conquer

Algorithms · 7 min read

Divide and Conquer is a problem-solving strategy with three steps:

  1. Divide — split the problem into smaller subproblems
  2. Conquer — recursively solve each subproblem
  3. Combine — merge the solutions of subproblems into the final answer

Classic Examples

  • Merge Sort — divide array in half, sort each half, merge
  • Quick Sort — partition around a pivot, sort each side
  • Binary Search — eliminate half the search space each step
  • Strassen Matrix Multiplication — O(n^2.81) instead of O(n³)

Recurrence Relations

// Merge Sort splits into 2 halves, each of size n/2, then O(n) merge // T(n) = 2·T(n/2) + O(n) → O(n log n) [Master Theorem Case 2] // Binary Search splits into 1 half of size n/2, constant work // T(n) = T(n/2) + O(1) → O(log n) // Master Theorem (quick reference): // T(n) = a·T(n/b) + f(n) // If f(n) = O(n^c) and c < log_b(a) → T(n) = O(n^log_b(a)) // If f(n) = O(n^c) and c = log_b(a) → T(n) = O(n^c · log n) // If f(n) = O(n^c) and c > log_b(a) → T(n) = O(f(n))

Template

int divideAndConquer(int[] arr, int lo, int hi) { if (lo == hi) return arr[lo]; // base case: single element int mid = lo + (hi - lo) / 2; int left = divideAndConquer(arr, lo, mid); // conquer left int right = divideAndConquer(arr, mid + 1, hi); // conquer right return combine(left, right); // combine }

Merge Sort

Algorithms · 8 min read

Merge sort divides the array in half recursively until single elements remain (always sorted), then merges them back in sorted order.

Visualisation

// [5, 3, 1, 4, 2] // Divide // [5,3,1] [4,2] // [5,3] [1] [4] [2] // [5][3] // Merge (conquer) // [3,5] [1] [2,4] // [1,3,5] [2,4] // [1,2,3,4,5]

Full Implementation

void mergeSort(int[] arr, int lo, int hi) { if (lo >= hi) return; // base: 0 or 1 element int mid = lo + (hi - lo) / 2; mergeSort(arr, lo, mid); // sort left half mergeSort(arr, mid + 1, hi); // sort right half merge(arr, lo, mid, hi); // merge both halves } void merge(int[] arr, int lo, int mid, int hi) { int[] temp = Arrays.copyOfRange(arr, lo, hi + 1); int i = 0, j = mid - lo + 1, k = lo; int leftEnd = mid - lo; int rightEnd = hi - lo; while (i <= leftEnd && j <= rightEnd) { if (temp[i] <= temp[j]) arr[k++] = temp[i++]; else arr[k++] = temp[j++]; } while (i <= leftEnd) arr[k++] = temp[i++]; // copy remaining left while (j <= rightEnd) arr[k++] = temp[j++]; // copy remaining right } // Usage: mergeSort(arr, 0, arr.length - 1) // Time: O(n log n) always Space: O(n) — needs auxiliary array Stable: Yes
💡 Merge sort is guaranteed O(n log n). It's the go-to for sorting linked lists (no random access needed) and for external sorting (data too big for RAM).

Quick Sort

Algorithms · 9 min read

Quick sort picks a pivot, partitions the array so elements smaller than pivot go left and larger go right, then recursively sorts each side. The partition step is done in place.

Visualisation

// [5, 3, 1, 4, 2] pivot = 2 (last element, Lomuto scheme) // Partition: elements < 2 go left, > 2 go right // → [1, 2, 5, 4, 3] pivot 2 is now at index 1 // Recurse left [1] and right [5,4,3] // Right: pivot=3 → [3, 4, 5] → pivot=4 → done // Final: [1, 2, 3, 4, 5]

Implementation (Lomuto Partition)

void quickSort(int[] arr, int lo, int hi) { if (lo >= hi) return; int pivot = partition(arr, lo, hi); quickSort(arr, lo, pivot - 1); quickSort(arr, pivot + 1, hi); } int partition(int[] arr, int lo, int hi) { int pivot = arr[hi]; // choose last element as pivot int i = lo - 1; // index of last element smaller than pivot for (int j = lo; j < hi; j++) { if (arr[j] <= pivot) { i++; int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } } int tmp = arr[i+1]; arr[i+1] = arr[hi]; arr[hi] = tmp; // place pivot return i + 1; // pivot's final position } // Usage: quickSort(arr, 0, arr.length - 1)

Pivot Choice Matters

// Worst case O(n²): always pick smallest/largest element as pivot // e.g., already sorted [1,2,3,4,5] with last-element pivot → n-1 partitions each of size n-1 // Fix: Randomised pivot — swap a random element with arr[hi] before partitioning int randIdx = lo + (int)(Math.random() * (hi - lo + 1)); int tmp = arr[randIdx]; arr[randIdx] = arr[hi]; arr[hi] = tmp; // Now worst case is extremely unlikely → average O(n log n) // Fix: Median-of-3 — pick median of first, middle, last as pivot (Java's Dual-Pivot uses this idea)
💡 Quick sort is the fastest in practice for in-memory sorting due to cache locality. It sorts the array in-place unlike merge sort. Use randomised pivot to avoid O(n²) worst case.

Selecting a Sorting Algorithm

Algorithms · 5 min read

Decision Guide

SituationBest ChoiceWhy
General purpose, primitivesQuick Sort / Arrays.sort()Fast in practice, in-place, cache-friendly
Need stable sortMerge Sort / Collections.sort()Equal elements maintain order
Nearly sorted dataInsertion SortO(n) on nearly sorted input
Small array (< 20 elements)Insertion SortLow overhead, fast in practice
Linked ListMerge SortNo random access needed; O(n log n) guaranteed
Limited writes to disk/flashSelection SortAt most n-1 swaps regardless
Teaching / simplest codeBubble SortEasiest to understand
Guaranteed O(n log n), any caseMerge Sort / Heap SortQuick sort has O(n²) worst case

Java Built-in Sorting

int[] arr = {5, 3, 1}; Arrays.sort(arr); // ascending, primitives — Dual-Pivot Quicksort String[] words = {"banana", "apple"}; Arrays.sort(words); // natural order — TimSort Arrays.sort(words, Comparator.reverseOrder()); // descending List<Integer> list = Arrays.asList(3, 1, 2); Collections.sort(list); // stable sort — TimSort list.sort((a, b) -> b - a); // descending with lambda comparator

Breadth-First Search (BFS)

Algorithms · 9 min read

BFS explores a graph (or tree) level by level, visiting all neighbours of the current node before going deeper. It uses a Queue.

Why BFS?

  • Finds the shortest path in an unweighted graph
  • Level-order tree traversal
  • Find all nodes at distance k from source
  • Check if a graph is bipartite
  • Web crawlers, social network friend suggestions

BFS on a Graph

void bfs(Map<Integer, List<Integer>> graph, int start) { Queue<Integer> queue = new LinkedList<>(); Set<Integer> visited = new HashSet<>(); queue.offer(start); visited.add(start); while (!queue.isEmpty()) { int node = queue.poll(); System.out.print(node + " "); for (int neighbour : graph.getOrDefault(node, List.of())) { if (!visited.contains(neighbour)) { visited.add(neighbour); queue.offer(neighbour); } } } }

BFS Shortest Path (with distance tracking)

Map<Integer, Integer> shortestPath(Map<Integer, List<Integer>> graph, int start) { Map<Integer, Integer> dist = new HashMap<>(); Queue<Integer> queue = new LinkedList<>(); dist.put(start, 0); queue.offer(start); while (!queue.isEmpty()) { int node = queue.poll(); for (int nb : graph.getOrDefault(node, List.of())) { if (!dist.containsKey(nb)) { dist.put(nb, dist.get(node) + 1); queue.offer(nb); } } } return dist; // dist.get(target) = shortest hops from start to target } // Time: O(V + E) Space: O(V)

BFS on a Grid (Common in Interviews)

// Shortest path in a maze — 0=open, 1=wall int bfsGrid(int[][] grid, int[] start, int[] end) { int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}}; Queue<int[]> q = new LinkedList<>(); q.offer(new int[]{start[0], start[1], 0}); // {row, col, distance} grid[start[0]][start[1]] = 1; // mark visited while (!q.isEmpty()) { int[] cur = q.poll(); if (cur[0] == end[0] && cur[1] == end[1]) return cur[2]; for (int[] d : dirs) { int r = cur[0]+d[0], c = cur[1]+d[1]; if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length && grid[r][c] == 0) { grid[r][c] = 1; q.offer(new int[]{r, c, cur[2]+1}); } } } return -1; // no path }

Depth-First Search (DFS)

Algorithms · 9 min read

DFS explores as deep as possible along each branch before backtracking. It uses a Stack (or recursion, which uses the call stack).

Why DFS?

  • Detect cycles in a graph
  • Find connected components
  • Topological sort (scheduling tasks with dependencies)
  • Solve mazes, puzzles, backtracking problems
  • All tree traversals (preorder, inorder, postorder)

DFS — Recursive (Graph)

void dfs(Map<Integer, List<Integer>> graph, int node, Set<Integer> visited) { visited.add(node); System.out.print(node + " "); for (int nb : graph.getOrDefault(node, List.of())) { if (!visited.contains(nb)) dfs(graph, nb, visited); } } // call: dfs(graph, startNode, new HashSet<>())

DFS — Iterative (using Stack)

void dfsIterative(Map<Integer, List<Integer>> graph, int start) { Deque<Integer> stack = new ArrayDeque<>(); Set<Integer> visited = new HashSet<>(); stack.push(start); while (!stack.isEmpty()) { int node = stack.pop(); if (visited.contains(node)) continue; visited.add(node); System.out.print(node + " "); for (int nb : graph.getOrDefault(node, List.of())) if (!visited.contains(nb)) stack.push(nb); } } // Time: O(V + E) Space: O(V)

Cycle Detection in Directed Graph

boolean hasCycle(Map<Integer, List<Integer>> graph, int node, Set<Integer> visited, Set<Integer> inStack) { visited.add(node); inStack.add(node); for (int nb : graph.getOrDefault(node, List.of())) { if (!visited.contains(nb) && hasCycle(graph, nb, visited, inStack)) return true; if (inStack.contains(nb)) return true; // back edge = cycle } inStack.remove(node); return false; }

Topological Sort (DFS-based)

List<Integer> topoSort(Map<Integer, List<Integer>> graph, List<Integer> nodes) { Set<Integer> visited = new HashSet<>(); Deque<Integer> stack = new ArrayDeque<>(); for (int node : nodes) if (!visited.contains(node)) topoHelper(graph, node, visited, stack); List<Integer> result = new ArrayList<>(stack); return result; // stack order = topological order } void topoHelper(Map<Integer, List<Integer>> g, int node, Set<Integer> visited, Deque<Integer> stack) { visited.add(node); for (int nb : g.getOrDefault(node, List.of())) if (!visited.contains(nb)) topoHelper(g, nb, visited, stack); stack.push(node); // push AFTER processing all neighbours }

Selecting a Searching Algorithm

Algorithms · 4 min read

SituationAlgorithmTime
Unsorted array / linked listLinear SearchO(n)
Sorted array, find exact valueBinary SearchO(log n)
Shortest path in unweighted graph/gridBFSO(V+E)
Explore all paths, detect cycles, topo sortDFSO(V+E)
Shortest path in weighted graph (no negative)DijkstraO((V+E) log V)
Shortest path with negative weightsBellman-FordO(V·E)
Search with constraints (sudoku, N-queens)DFS + BacktrackingExponential
💡 Interview rule of thumb: "Shortest path unweighted → BFS. Explore everything → DFS. Shortest path weighted → Dijkstra."

Dijkstra's Algorithm

Graph Algorithms · 10 min read

Dijkstra finds the shortest path from a source to all other vertices in a graph with non-negative edge weights. It is a greedy algorithm that always expands the lowest-cost unvisited node.

How It Works

  1. Set distance to source = 0, all others = ∞
  2. Add source to a min-heap (priority queue)
  3. While heap not empty: pop node with smallest distance
  4. For each neighbour: if dist[node] + edge_weight < dist[neighbour], update dist[neighbour] and push to heap
  5. Repeat until all nodes visited
// Graph: adjacency list of [neighbour, weight] pairs int[] dijkstra(Map<Integer, List<int[]>> graph, int src, int V) { int[] dist = new int[V]; Arrays.fill(dist, Integer.MAX_VALUE); dist[src] = 0; // Min-heap: [distance, node] PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]); pq.offer(new int[]{0, src}); while (!pq.isEmpty()) { int[] curr = pq.poll(); int d = curr[0], node = curr[1]; if (d > dist[node]) continue; // stale entry — skip for (int[] edge : graph.getOrDefault(node, List.of())) { int nb = edge[0], w = edge[1]; if (dist[node] + w < dist[nb]) { dist[nb] = dist[node] + w; pq.offer(new int[]{dist[nb], nb}); } } } return dist; // dist[i] = shortest distance from src to i } // Time: O((V + E) log V) Space: O(V + E)
💡 Dijkstra does NOT work with negative edge weights. Use Bellman-Ford instead.

Bellman-Ford Algorithm

Graph Algorithms · 8 min read

Bellman-Ford finds the shortest path from a source to all vertices and handles negative edge weights. It can also detect negative weight cycles.

How It Works

Repeatedly relax all edges V-1 times. A shortest path in a graph with V vertices has at most V-1 edges, so V-1 relaxation passes are always enough.

// Edge list: each edge = [u, v, weight] int[] bellmanFord(int[][] edges, int V, int src) { int[] dist = new int[V]; Arrays.fill(dist, Integer.MAX_VALUE); dist[src] = 0; // Relax all edges V-1 times for (int i = 0; i < V - 1; i++) { for (int[] edge : edges) { int u = edge[0], v = edge[1], w = edge[2]; if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) dist[v] = dist[u] + w; } } // V-th relaxation: if any distance still improves → negative cycle exists for (int[] edge : edges) { int u = edge[0], v = edge[1], w = edge[2]; if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) throw new RuntimeException("Negative cycle detected!"); } return dist; } // Time: O(V × E) Space: O(V)

Dijkstra vs Bellman-Ford

PropertyDijkstraBellman-Ford
Negative weights❌ Doesn't work✅ Handles them
Negative cycles❌ Doesn't detect✅ Detects them
Time complexityO((V+E) log V)O(V·E)
StrategyGreedy (priority queue)Dynamic programming (relaxation)
Best forDense graphs, non-negative weightsNegative weights, small graphs

Negative Edge Weights & Relaxation

Graph Algorithms · 7 min read

Why Negative Weights Break Dijkstra

Dijkstra assumes: once a node is popped from the priority queue with distance d, that's the shortest distance. Negative edges can create a shorter path to a node after it's already been finalised.

// Example: A→B weight=1, A→C weight=4, C→B weight=-5 // Dijkstra processes B first (dist=1 via A→B) // Then processes C (dist=4), finds C→B = 4 + (-5) = -1 < 1 // But B is already "finalised" at dist=1 → WRONG answer // Bellman-Ford would keep relaxing and correct this to dist[B] = -1

What is Relaxation?

Relaxation is the core operation of shortest-path algorithms. For every edge (u → v, weight w):

// Relax edge u → v with weight w if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; // found a shorter path to v via u } // "Relax" means: try to improve the current known distance to v // Both Dijkstra and Bellman-Ford use this exact formula // Dijkstra relaxes greedily (closest node first) // Bellman-Ford relaxes ALL edges V-1 times systematically

Negative Weight Cycles

A negative weight cycle is a cycle whose total weight is negative. Going around the cycle repeatedly reduces the path cost infinitely — so no finite shortest path exists.

// A → B → C → A with weights 1, -3, 1 // Total cycle weight = 1 + (-3) + 1 = -1 (negative!) // Each loop around the cycle: dist -= 1 → -∞ // Detection: if a distance improves on the V-th relaxation pass → negative cycle exists

Sparse Graphs

Graph Algorithms · 5 min read

A sparse graph has far fewer edges than the maximum possible. A dense graph has edges close to the maximum (V² for directed, V(V-1)/2 for undirected).

Sparse vs Dense

PropertySparseDense
Edge count EE ≈ O(V)E ≈ O(V²)
ExamplesRoad networks, social graphs, treesComplete graphs, fully connected networks
Best representationAdjacency List — O(V + E) spaceAdjacency Matrix — O(1) edge lookup
Dijkstra complexityO((V+E) log V) ≈ O(V log V)O((V+E) log V) ≈ O(V² log V)
Bellman-FordO(V·E) ≈ O(V²) — acceptableO(V·E) ≈ O(V³) — very slow

Why It Matters for Algorithm Choice

  • Sparse graphs: Adjacency list + Dijkstra with priority queue is fastest
  • Dense graphs: Adjacency matrix + simple Dijkstra O(V²) may beat heap-based Dijkstra
  • Very sparse with negatives: Bellman-Ford O(V·E) ≈ O(V²) — still usable
// Adjacency list (sparse): wastes no space on non-existent edges Map<Integer, List<int[]>> adjList = new HashMap<>(); // O(V + E) space // Adjacency matrix (dense): O(1) edge lookup at cost of O(V²) space int[][] adjMatrix = new int[V][V]; // adjMatrix[u][v] = weight, 0 if no edge

Dynamic Programming

Advanced · 12 min read

Dynamic Programming (DP) solves problems by breaking them into overlapping subproblems and storing results to avoid recomputation. It's applicable when the problem has optimal substructure (optimal solution built from optimal sub-solutions).

Two Approaches

  • Top-Down (Memoization) — recursive, store results in a map/array
  • Bottom-Up (Tabulation) — iterative, fill a DP table from smallest subproblem up

Classic 1 — Coin Change

// Minimum coins to make amount. coins=[1,5,10], amount=11 → 2 (10+1) int coinChange(int[] coins, int amount) { int[] dp = new int[amount + 1]; Arrays.fill(dp, amount + 1); // fill with "impossible" sentinel dp[0] = 0; // base: 0 coins needed for amount 0 for (int a = 1; a <= amount; a++) { for (int coin : coins) { if (coin <= a) dp[a] = Math.min(dp[a], dp[a - coin] + 1); } } return dp[amount] > amount ? -1 : dp[amount]; }

Classic 2 — 0/1 Knapsack

// Max value fitting in knapsack of capacity W int knapsack(int[] wt, int[] val, int W) { int n = wt.length; int[][] dp = new int[n + 1][W + 1]; for (int i = 1; i <= n; i++) { for (int w = 0; w <= W; w++) { dp[i][w] = dp[i-1][w]; // don't take item i if (wt[i-1] <= w) dp[i][w] = Math.max(dp[i][w], val[i-1] + dp[i-1][w-wt[i-1]]); } } return dp[n][W]; }

Classic 3 — Longest Common Subsequence (LCS)

int lcs(String a, String b) { int m = a.length(), n = b.length(); int[][] dp = new int[m+1][n+1]; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (a.charAt(i-1) == b.charAt(j-1)) dp[i][j] = dp[i-1][j-1] + 1; else dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]); } } return dp[m][n]; } // lcs("abcde","ace") = 3 (a,c,e)
💡 DP interview checklist: Can I define subproblem? Is there a recurrence relation? What's the base case? Should I go top-down or bottom-up?

Caching & Memoization

Advanced · 7 min read

Caching stores results of expensive computations so they can be reused instantly. Memoization is caching applied specifically to recursive functions — store the result of each unique input on first computation, return it immediately on subsequent calls.

Without Memoization — Exponential Work

// fib(5) call tree WITHOUT memoization: // fib(5) // / \ // fib(4) fib(3) // / \ / \ // fib(3) fib(2) fib(2) fib(1) // fib(3) called TWICE, fib(2) called THREE times → O(2ⁿ)

With Memoization — Linear Work

Map<Integer, Long> cache = new HashMap<>(); long fib(int n) { if (n <= 1) return n; if (cache.containsKey(n)) return cache.get(n); // HIT: return cached long result = fib(n-1) + fib(n-2); cache.put(n, result); // MISS: compute and store return result; } // Each fib(n) computed exactly once → O(n) time, O(n) space

Memoization vs Tabulation

PropertyMemoization (Top-Down)Tabulation (Bottom-Up)
DirectionRecursion — start at n, go to base caseIteration — start at base case, go to n
Stack usageUses call stack (risk of overflow)No recursion, no overflow risk
Only computes?Only needed subproblemsAll subproblems (even unused ones)
Code styleEasier to write (natural recursion)More efficient (no function call overhead)

Array-based Memo (Faster than HashMap)

long[] memo = new long[n + 1]; Arrays.fill(memo, -1); // -1 means "not computed yet" long fib(int n) { if (n <= 1) return n; if (memo[n] != -1) return memo[n]; return memo[n] = fib(n-1) + fib(n-2); } // Convert to tabulation (bottom-up) for max efficiency: long[] dp = new long[n + 1]; dp[0] = 0; dp[1] = 1; for (int i = 2; i <= n; i++) dp[i] = dp[i-1] + dp[i-2]; // Space optimised: only keep last 2 values → O(1) space long a = 0, b = 1; for (int i = 2; i <= n; i++) { long c = a + b; a = b; b = c; }
💡 Rule: If you see the same recursive call being made multiple times with the same arguments — memoize it. This pattern converts O(2ⁿ) to O(n) instantly.

Heap / Priority Queue

Advanced · 7 min read

A heap is a complete binary tree satisfying the heap property. In a min-heap, every parent is smaller than its children — so the minimum is always at the root (O(1) access). Java's PriorityQueue is a min-heap.

// Min-heap PriorityQueue<Integer> minHeap = new PriorityQueue<>(); minHeap.offer(5); minHeap.offer(2); minHeap.offer(8); minHeap.offer(1); minHeap.poll(); // 1 — always removes smallest O(log n) minHeap.peek(); // 2 — view min without removing O(1) // Max-heap PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder()); maxHeap.offer(5); maxHeap.offer(2); maxHeap.offer(8); maxHeap.poll(); // 8 — largest first

Top-K Elements (Most Common Heap Interview Pattern)

// Find top K largest elements — O(n log k) int[] topK(int[] nums, int k) { PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // size-k min-heap for (int n : nums) { minHeap.offer(n); if (minHeap.size() > k) minHeap.poll(); // evict smallest if over size k } return minHeap.stream().mapToInt(Integer::intValue).toArray(); } // [3,1,4,1,5,9,2,6], k=3 → [4,5,6] or [4,6,9] depending on input

Merge K Sorted Lists

ListNode mergeKLists(ListNode[] lists) { PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> a.val - b.val); for (ListNode head : lists) if (head != null) pq.offer(head); ListNode dummy = new ListNode(0), cur = dummy; while (!pq.isEmpty()) { ListNode node = pq.poll(); cur.next = node; cur = cur.next; if (node.next != null) pq.offer(node.next); } return dummy.next; } // Time: O(n log k) where n = total nodes, k = number of lists

Greedy Algorithms

Advanced · 6 min read

Greedy algorithms make the locally optimal choice at each step and never reconsider. They work when the problem has the greedy-choice property (local optimal leads to global optimal) and optimal substructure.

Activity Selection Problem

// Pick maximum non-overlapping activities — sort by end time, always pick earliest finish int maxActivities(int[] start, int[] end) { Integer[] idx = IntStream.range(0, start.length).boxed().toArray(Integer[]::new); Arrays.sort(idx, (a, b) -> end[a] - end[b]); // sort by end time int count = 1, lastEnd = end[idx[0]]; for (int i = 1; i < idx.length; i++) { if (start[idx[i]] >= lastEnd) { count++; lastEnd = end[idx[i]]; } } return count; }

Classic Greedy Problems

ProblemGreedy Choice
Activity SelectionAlways pick activity with earliest end time
Fractional KnapsackAlways take highest value/weight ratio first
Huffman CodingMerge two least-frequent nodes first
Jump GameTrack maximum reachable index, update greedily
Dijkstra's AlgorithmAlways expand lowest-cost unvisited node
Prim's / Kruskal's MSTAlways pick minimum weight edge
⚠️ Greedy doesn't always work. For 0/1 Knapsack (can't take fractions), greedy fails — use Dynamic Programming. If in doubt: prove greedy with an exchange argument, or use DP.