DSA in Python — Course Track
Learn to think like a computer scientist. This track takes you from raw memory all the way to dynamic programming, building genuine intuition at every step.
Why This Track Exists
Every time you use Google Maps, Spotify’s shuffle, or Instagram’s feed, data structures and algorithms are running underneath. The code that makes those products fast isn’t magic — it’s the same ideas you’ll learn here, applied with care.
This track is structured like a course with a deliberate progression:
- Start with memory and arrays — understand how data actually lives in RAM before touching any abstraction.
- Move to linear structures — linked lists and queues that flex where arrays are rigid.
- Build recursion intuition — train your brain to think in sub-problems.
- Master sorting — the proving ground for algorithmic thinking.
- Climb to advanced territory — trees, graphs, heaps, hashing, and dynamic programming.
Each section answers three questions: What is the data layout? Which operations are cheap? What trade-off is being made?
Sections
-
Introduction What is a data structure? What is an algorithm? Why do they power the modern world?
-
Algorithm Analysis Big O notation, time vs space complexity, and how to reason about performance mathematically.
Arrays
The foundation of almost every other data structure. Understand arrays and you understand memory.
- Overview
- RAM — How your computer actually stores data at the byte level.
- Static Arrays — Fixed-size, blazing fast, and the basis for everything else.
- Dynamic Arrays — How Python lists grow themselves automatically.
- Stacks — Last in, first out — the secret behind undo buttons and function calls.
- Kadane’s Algorithm — Find the maximum-sum subarray in a single pass.
- Sliding Window — Fixed Size — Efficiently process every window of size k.
- Sliding Window — Variable Size — Expand and shrink a window to meet a condition.
- Two Pointers — Solve array problems with two indices moving toward each other.
- Prefix Sums — Answer range-sum queries in O(1) after O(n) setup.
Linked Lists
Flexible structures where elements can live anywhere in memory, connected by pointers.
- Overview
- Singly Linked Lists — Chains of nodes, each pointing forward.
- Doubly Linked Lists — Chains that can walk backwards too.
- Queues — First in, first out — how printers and task schedulers work.
- Fast and Slow Pointers — Detect cycles and find midpoints with two pointers at different speeds.
Recursion
The art of solving a problem by solving a smaller version of itself.
- Overview
- Factorial — The classic entry point into recursive thinking.
- Fibonacci Sequence — Where naive recursion meets its first performance wall.
Sorting
Rearranging data is the most studied problem in computer science — and for good reason.
- Overview
- Insertion Sort — Simple, intuitive, and surprisingly fast on small inputs.
- Merge Sort — Divide, conquer, and merge — reliably O(n log n).
- Quick Sort — The algorithm powering most real-world sort implementations.
- Bucket Sort — When you know something about your data, you can beat O(n log n).
Binary Search
Find anything in a sorted collection in O(log n) — eliminate half the possibilities each step.
Trees
Hierarchical structures that power databases, file systems, and autocomplete.
- Overview
- Binary Tree — Each node has at most two children.
- Binary Search Tree — A sorted tree you can search in O(log n).
- BST Insert and Remove
- Depth-First Search — Explore a tree going deep before going wide.
- Breadth-First Search — Explore layer by layer, level by level.
- BST Sets and Maps — How Python’s
setanddictare built. - Trie — A tree for storing strings — the backbone of autocomplete.
- Union-Find — Track connected components with near-O(1) union and find.
- Segment Tree — Range queries and updates in O(log n).
- Iterative DFS — DFS without recursion using an explicit stack.
Backtracking
Explore every possibility systematically — and prune dead ends early.
- Overview
- Tree Maze — Navigate a maze by trying every path and retreating when stuck.
- Subsets — Generate every subset of a set systematically.
- Combinations — Choose k items from n — without repetition.
- Permutations — Every possible ordering of a set of elements.
Heap / Priority Queue
Always get the minimum (or maximum) element in O(log n). Used in Dijkstra’s and task scheduling.
- Overview
- Heap Properties
- Push and Pop
- Heapify — Build a heap from an array in O(n).
- Two Heaps — Use a min-heap and max-heap together to track medians in real time.
Hashing
The secret behind Python’s dict and set — O(1) average lookup via math.
- Overview
- Hash Usage — Using hash maps and sets to solve problems fast.
- Hash Implementation — How collision resolution actually works inside.
Graphs
The most general structure — social networks, maps, and the internet are all graphs.
- Overview
- Intro to Graphs — Nodes, edges, directed vs undirected.
- Matrix DFS
- Matrix BFS
- Adjacency List — The most efficient graph representation for sparse graphs.
- Dijkstra’s — Shortest path in a weighted graph using a priority queue.
- Prim’s — Build a minimum spanning tree greedily, edge by edge.
- Kruskal’s — Build an MST by sorting edges and avoiding cycles.
- Topological Sort — Order tasks so every dependency comes before the task that needs it.
Dynamic Programming
Solve complex problems by caching the results of overlapping sub-problems.
- Overview
- 1-Dimension DP — Single-variable state: climbing stairs, coin change.
- 2-Dimension DP — Grid problems and string comparisons.
- 0 / 1 Knapsack — Pick items with weights and values — each item used at most once.
- Unbounded Knapsack — Same as knapsack but items can be reused unlimited times.
- LCS — Find the longest sequence common to two strings.
- Palindromes — Detect and build palindromic substrings with DP.
Bit Manipulation
Use binary operations directly — the lowest level of computation, and often the fastest.
- Overview
- Bit Operations — AND, OR, XOR, shifts — and why they matter.
Introduction to Data Structures and Algorithms
The Two-Second Question
When you type a search query into Google, something remarkable happens. In under two seconds, Google’s servers sift through an index representing hundreds of billions of web pages and return exactly the results you were looking for — ranked by relevance, filtered for quality, personalised to you.
How is that physically possible?
The answer isn’t “more computers” or “faster hardware.” The answer is algorithms and data structures. The right choice of data structure turns a problem that would take centuries into one that takes milliseconds. That’s not an exaggeration — it’s mathematics.
This track teaches you those ideas from the ground up.
What Is a Data Structure?
A data structure is a way of organising data in memory so that certain operations are efficient.
The word “organising” is the key. The same collection of numbers can be arranged in dozens of different ways, and each arrangement makes some things fast and other things slow.
Think of it like a kitchen. You could throw all your ingredients into one giant bin — that’s “just a pile of data.” Or you could sort them into labelled shelves, refrigerated compartments, and spice racks. The ingredients are identical, but the organisation changes everything about how quickly you can cook.
graph TD
DS["Data Structures"]
DS --> Linear["Linear"]
DS --> NonLinear["Non-Linear"]
DS --> HashBased["Hash-Based"]
Linear --> Array["Array\nFast index access\nO(1) read"]
Linear --> LinkedList["Linked List\nFast insertion\nO(1) prepend"]
Linear --> Stack["Stack\nLast in, first out\nUndo / call stack"]
Linear --> Queue["Queue\nFirst in, first out\nPrinters / schedulers"]
NonLinear --> Tree["Tree\nHierarchical\nFile systems / DOM"]
NonLinear --> Graph["Graph\nArbitrary connections\nMaps / social networks"]
NonLinear --> Heap["Heap\nAlways fast min/max\nPriority queues"]
HashBased --> HashMap["Hash Map\nO(1) average lookup\nPython dict"]
HashBased --> HashSet["Hash Set\nO(1) membership test\nPython set"]
style DS fill:#EEEDFE,stroke:#534AB7,color:#3C3489
style Linear fill:#E6F1FB,stroke:#185FA5,color:#0C447C
style NonLinear fill:#E1F5EE,stroke:#0F6E56,color:#085041
style HashBased fill:#FAEEDA,stroke:#854F0B,color:#633806
A data structure is not just a container — it’s a contract. Each one promises certain performance characteristics, and violating those promises (using the wrong structure for the job) can make your program thousands of times slower than it needs to be.
What Is an Algorithm?
An algorithm is a precise, step-by-step procedure for solving a problem.
The word comes from the name of 9th-century Persian mathematician Muhammad ibn Musa al-Khwarizmi, whose works introduced systematic methods for solving equations to the Western world.
Algorithms have three properties:
- Correctness — it produces the right answer for every valid input.
- Finiteness — it terminates in a finite number of steps.
- Efficiency — it uses a reasonable amount of time and memory.
You already know algorithms intuitively. A recipe is an algorithm. The process of looking up a word in a paper dictionary (open to the middle, decide if your word comes before or after, repeat) is an algorithm — and a clever one. That’s binary search, and it finds any word in a 1,000-page dictionary in at most 10 steps.
Why Do They Matter?
Here’s a concrete example. Suppose you need to check whether a number appears in a list of one million numbers.
Approach 1: Check every number (linear search) At worst, check all 1,000,000 entries.
Approach 2: Sort the list first, then binary search Sort once (roughly 20,000,000 operations), then each search takes just 20 steps.
If you run 10,000 searches, approach 2 does 20,000,000 + 200,000 = 20.2 million operations. Approach 1 does 10,000,000,000 — ten billion. That’s a 500x difference in work, and it grows wider as the dataset grows.
import time
# Simulate a large dataset
data = list(range(1_000_000)) # [0, 1, 2, ..., 999999]
target = 999_983 # near the end — worst case for linear search
# Approach 1: Linear search — O(n)
start = time.time()
found_linear = False
for item in data:
if item == target:
found_linear = True
break
linear_ms = (time.time() - start) * 1000
# Approach 2: Binary search — O(log n)
start = time.time()
lo, hi = 0, len(data) - 1
found_binary = False
steps = 0
while lo <= hi:
steps += 1
mid = (lo + hi) // 2
if data[mid] == target:
found_binary = True
break
elif data[mid] < target:
lo = mid + 1
else:
hi = mid - 1
binary_ms = (time.time() - start) * 1000
print(f"Dataset size: {len(data):,} items")
print(f"Target: {target}")
print()
print(f"Linear search: {linear_ms:.3f} ms (scans up to every item)")
print(f"Binary search: {binary_ms:.4f} ms ({steps} steps total)")
print()
if binary_ms > 0:
print(f"Binary search was ~{linear_ms / binary_ms:.0f}x faster")
print(f"Binary search checked {steps} items vs up to 1,000,000")
The Core Idea: Three Connected Concepts
Every topic in this track connects back to three questions:
graph LR
ML["Memory Layout\nHow is data arranged\nin RAM?"]
OC["Operation Cost\nWhich actions are\ncheap vs expensive?"]
PP["Problem Patterns\nWhich structure fits\nthis problem?"]
ML --> OC
OC --> PP
PP --> ML
style ML fill:#E6F1FB,stroke:#185FA5,color:#0C447C
style OC fill:#FAEEDA,stroke:#854F0B,color:#633806
style PP fill:#E1F5EE,stroke:#0F6E56,color:#085041
- An array is fast because its elements sit in a contiguous block of memory. The CPU can jump directly to any element using arithmetic. That’s O(1) access.
- A linked list is flexible because its nodes can live anywhere in memory — you just follow the chain of pointers. That makes insertion O(1) but lookup O(n).
- Recursion is elegant because the call stack remembers suspended work for you, letting you express divide-and-conquer solutions naturally.
- Sorting changes the shape of later problems — a sorted array enables binary search, which enables a whole class of efficient solutions.
Understanding these connections is what separates someone who memorises solutions from someone who derives them.
What to Look for in Each Topic
As you work through this track, ask these four questions for every data structure you encounter:
| Question | What it reveals |
|---|---|
| What is the data layout? | How memory is organised |
| Which operations are cheap? | What this structure is built for |
| Which operations are expensive? | When to reach for something else |
| What trade-off is being made? | No structure is perfect for everything |
Real-World Uses
You’ll see DSA in action everywhere once you know what to look for:
- Google Search — inverted index (a hash map from word to list of pages) + PageRank (graph algorithm)
- GPS navigation — Dijkstra’s shortest-path algorithm (graph + priority queue)
- Git version control — directed acyclic graph of commits
- Database indexes — B-trees (generalised binary search trees)
- Browser history / undo — stack
- Streaming video buffering — queue
- Autocomplete / spell check — trie (a specialised tree)
- Python’s
dictandset— hash tables
How This Track Is Structured
graph TD
A["Memory & Arrays\nThe foundation"] --> B["Linked Lists\nFlexible chains"]
B --> C["Recursion\nThinking in sub-problems"]
C --> D["Sorting\nThe proving ground"]
D --> E["Binary Search\nEliminating possibilities"]
E --> F["Trees\nHierarchical structure"]
F --> G["Backtracking\nSystematic exploration"]
G --> H["Heaps\nAlways-fast min/max"]
H --> I["Hashing\nO(1) lookup"]
I --> J["Graphs\nThe most general structure"]
J --> K["Dynamic Programming\nCaching sub-problem results"]
K --> L["Bit Manipulation\nThe lowest level"]
style A fill:#E1F5EE,stroke:#0F6E56,color:#085041
style L fill:#EEEDFE,stroke:#534AB7,color:#3C3489
Each section builds on the last. Skip ahead and some concepts will seem mysterious. Follow the order and everything will click into place.
Let’s start with the language we use to talk about performance: Algorithm Analysis.
Algorithm Analysis — Complete Tutorial
A guide to Time Complexity, Space Complexity, Big O Notation, and the Turing Machine memory model — with runnable examples and real-world context.
Table of Contents
- What is Algorithm Analysis?
- Time Complexity
- Space Complexity
- Time vs Space Trade-offs
- Quick Reference Cheat Sheet
1. What is Algorithm Analysis?
When you write code to solve a problem, two questions always follow:
- How fast does it run? → Time Complexity
- How much memory does it use? → Space Complexity
Algorithm Analysis is the discipline of answering both questions mathematically, so your answers hold regardless of the machine, the programming language, or the size of the input.
This matters enormously in practice. A web server handling a million requests per day cannot afford an algorithm that is 100x slower than it could be. A mobile app cannot afford one that uses 10x more memory than necessary. Good engineers understand these costs before the code ships.
graph TD
A["Algorithm Analysis"]
A --> B["Time Complexity\nHow many operations?"]
A --> C["Space Complexity\nHow much memory?"]
B --> B1["Big O Notation\nUpper bound on growth"]
B --> B2["Best / Average / Worst Case\nWhich scenario to plan for?"]
B --> B3["Common Classes\nO(1), O(log n), O(n), O(n²)..."]
C --> C1["Auxiliary Space\nOnly the scratch space counts"]
C --> C2["Turing Machine Model\nWhy we exclude input and output"]
C --> C3["Input / Work / Output Tapes\nA mental model for classification"]
C --> C4["Space Complexity Classes\nO(1), O(log n), O(n), O(n²)"]
style A fill:#EEEDFE,stroke:#534AB7,color:#3C3489
style B fill:#E6F1FB,stroke:#185FA5,color:#0C447C
style C fill:#E1F5EE,stroke:#0F6E56,color:#085041
2. Time Complexity
Time complexity measures how the number of operations your algorithm performs scales as the input size n grows.
We never measure raw seconds — hardware varies. Instead we count operations, and express how that count scales. An algorithm that takes 1 second on your laptop might take 0.01 seconds on a server, but the ratio of work between two inputs will be the same on both machines. That ratio is what time complexity captures.
2.1 Big O Notation
Big O is a mathematical notation that describes the upper bound of an algorithm’s growth rate.
Formal definition:
f(n) = O(g(n))means there exist constantsc > 0andn₀such thatf(n) ≤ c · g(n)for alln ≥ n₀.
In plain English: “after a certain input size, the function never grows faster than g(n) by more than a constant factor.”
Think of it like a speed limit. If a road has a 100 km/h limit, you might sometimes go 80, sometimes 95 — but you’re guaranteed never to exceed 100. Big O is the speed limit for your algorithm’s growth.
The three asymptotic notations:
| Notation | Meaning | Used for |
|---|---|---|
O(f(n)) | Upper bound — at most this fast | Worst case guarantee |
Ω(f(n)) | Lower bound — at least this fast | Best case guarantee |
Θ(f(n)) | Tight bound — exactly this fast | Average / typical case |
In everyday engineering, O (Big O) is the one you will use 95% of the time. It’s a promise: “my algorithm will never do worse than this.”
Key rules for computing Big O:
- Drop constants —
O(3n)→O(n). Constants don’t describe growth behaviour. - Drop lower-order terms —
O(n² + n)→O(n²). The dominant term wins at large n. - Nested loops multiply — a loop inside a loop is
O(n × n) = O(n²). - Sequential steps add — two loops one after another is
O(n + n) = O(n).
# Rule 3 — nested loops multiply → O(n²)
# For every i, we do n iterations of j.
# Total: n × n = n² operations.
n = 4
print("Nested loops (O(n²)):")
for i in range(n):
for j in range(n):
print(f" ({i},{j})", end="")
print()
print()
# Rule 4 — sequential loops add → O(n + n) = O(n)
# First loop does n operations. Second loop does n more.
# Total: 2n → O(n). The 2 is a constant and gets dropped.
print("Sequential loops (O(n)):")
print("First pass: ", end="")
for i in range(n):
print(i, end=" ")
print()
print("Second pass:", end=" ")
for j in range(n):
print(j, end=" ")
print()
print(f"\nNested: {n*n} operations | Sequential: {n+n} operations")
2.2 Common Time Complexities
From fastest to slowest growth:
graph LR
A["O(1)\nConstant"] --> B["O(log n)\nLogarithmic"]
B --> C["O(n)\nLinear"]
C --> D["O(n log n)\nLinearithmic"]
D --> E["O(n²)\nQuadratic"]
E --> F["O(2ⁿ)\nExponential"]
F --> G["O(n!)\nFactorial"]
style A fill:#E1F5EE,stroke:#0F6E56,color:#085041
style B fill:#E6F1FB,stroke:#185FA5,color:#0C447C
style C fill:#EEEDFE,stroke:#534AB7,color:#3C3489
style D fill:#FAEEDA,stroke:#854F0B,color:#633806
style E fill:#FAECE7,stroke:#993C1D,color:#712B13
style F fill:#FCEBEB,stroke:#A32D2D,color:#791F1F
style G fill:#FBEAF0,stroke:#993556,color:#72243E
Growth at a glance — operations needed for n = 1,000:
| Complexity | Name | Operations | Verdict |
|---|---|---|---|
| O(1) | Constant | 1 | Instant |
| O(log n) | Logarithmic | ~10 | Instant |
| O(n) | Linear | 1,000 | Fast |
| O(n log n) | Linearithmic | ~10,000 | Fast |
| O(n²) | Quadratic | 1,000,000 | Slow |
| O(2ⁿ) | Exponential | 2¹⁰⁰⁰ (astronomical) | Impossible |
| O(n!) | Factorial | larger than above | Impossible |
Real-world examples for each class:
| Complexity | Where you encounter it |
|---|---|
| O(1) | Reading a Python dict value by key; array index access |
| O(log n) | Binary search; database B-tree index lookup |
| O(n) | Summing a list; finding the largest file in a folder |
| O(n log n) | Sorting (merge sort, Python’s Timsort); building a heap |
| O(n²) | Comparing every pair of items; naive substring search |
| O(2ⁿ) | Finding all subsets of a set; naive recursive Fibonacci |
| O(n!) | Solving the Travelling Salesman Problem by brute force |
Code examples for each class:
# O(1) — constant: index into array
# The array could have 10 elements or 10 million — the work is the same.
def get_first(arr):
return arr[0]
# O(log n) — logarithmic: binary search (iterative)
# Each step cuts the remaining search space in half.
# 1,000,000 items → at most 20 steps. 1,000,000,000 → at most 30.
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
steps = 0
while lo <= hi:
steps += 1
mid = (lo + hi) // 2
if arr[mid] == target:
return mid, steps
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1, steps
# O(n) — linear: find maximum
# Must look at every element at least once.
def find_max(arr):
max_val = arr[0]
for x in arr:
if x > max_val:
max_val = x
return max_val
# O(n²) — quadratic: check all pairs
# For each element, compare against every other element.
def has_duplicate(arr):
n = len(arr)
comparisons = 0
for i in range(n):
for j in range(i + 1, n):
comparisons += 1
if arr[i] == arr[j]:
return True, comparisons
return False, comparisons
# Demonstration
arr = list(range(1, 1001)) # [1, 2, 3, ..., 1000]
idx, steps = binary_search(arr, 731)
print(f"Binary search for 731 in 1000 items: found at index {idx} in {steps} steps")
maximum = find_max(arr)
print(f"Find max in 1000 items: {maximum} (required 1000 comparisons)")
small = [1, 3, 5, 7, 9]
found, comps = has_duplicate(small)
print(f"Duplicate check in {small}: {found}, took {comps} comparisons")
print(f"(n={len(small)}, n²={len(small)**2}, actual={comps})")
2.3 Best, Average, and Worst Case
The same algorithm can behave very differently depending on the input. We distinguish three scenarios:
graph TD
subgraph "Linear Search in [3, 7, 2, 9, 4]"
BC["Best Case — O(1)\nTarget is the first element\narr[0] = target → done immediately"]
AC["Average Case — O(n)\nTarget is somewhere in the middle\nCheck ~half the array on average"]
WC["Worst Case — O(n)\nTarget is last or not present\nMust check every element"]
end
style BC fill:#E1F5EE,stroke:#0F6E56,color:#085041
style AC fill:#FAEEDA,stroke:#854F0B,color:#633806
style WC fill:#FCEBEB,stroke:#A32D2D,color:#791F1F
Why worst case matters most:
In practice, engineers focus on the worst case because:
- It is a guarantee — the algorithm will never be slower than this.
- Users experience the worst case, not the average. If your app freezes for one user in a thousand, that user writes the one-star review.
- Systems must be designed to handle peak load, not average load.
def linear_search(arr, target):
comparisons = 0
for i, x in enumerate(arr):
comparisons += 1
if x == target:
return i, comparisons
return -1, comparisons
arr = [3, 7, 2, 9, 4, 6, 1, 8, 5, 0]
# Best case: target is first element
idx, comps = linear_search(arr, 3)
print(f"Best case — target=3 (first): found at {idx}, {comps} comparison")
# Average case: target is in the middle
idx, comps = linear_search(arr, 6)
print(f"Average — target=6 (middle): found at {idx}, {comps} comparisons")
# Worst case: target is last, or not present
idx, comps = linear_search(arr, 0)
print(f"Worst case — target=0 (last): found at {idx}, {comps} comparisons")
idx, comps = linear_search(arr, 99)
print(f"Worst case — target=99 (absent): found at {idx}, {comps} comparisons")
print(f"\nArray has {len(arr)} elements.")
print("Worst case always checks all of them.")
Quicksort — a dramatic example:
| Case | When it happens | Complexity |
|---|---|---|
| Best | Pivot always splits the array perfectly in half | O(n log n) |
| Average | Random pivot, typical distribution | O(n log n) |
| Worst | Pivot is always the smallest or largest element (sorted input!) | O(n²) |
This is why good quicksort implementations use random pivot selection — to avoid accidentally hitting the worst case on already-sorted data.
2.4 Timing It Yourself
Abstract complexity classes become real when you measure them. Here’s how O(n) vs O(n²) actually behaves on your hardware:
import time
def measure(fn, n):
start = time.time()
fn(n)
return (time.time() - start) * 1000 # milliseconds
# O(n) algorithm: sum of first n numbers
def linear(n):
total = 0
for i in range(n):
total += i
return total
# O(n²) algorithm: sum all pairs (i, j)
def quadratic(n):
total = 0
for i in range(n):
for j in range(n):
total += i + j
return total
sizes = [100, 500, 1000, 2000]
print(f"{'n':>6} | {'O(n) ms':>10} | {'O(n²) ms':>10} | {'Ratio':>8}")
print("-" * 45)
prev_linear = None
prev_quad = None
for n in sizes:
t_linear = measure(linear, n)
t_quad = measure(quadratic, n)
ratio = t_quad / t_linear if t_linear > 0 else float('inf')
print(f"{n:>6} | {t_linear:>10.3f} | {t_quad:>10.3f} | {ratio:>7.1f}x")
print()
print("Notice: when n doubles, O(n) roughly doubles.")
print("When n doubles, O(n²) roughly quadruples.")
print("This gap widens forever as n grows.")
3. Space Complexity
Space complexity measures how much extra memory an algorithm needs as input size n grows.
The critical word is extra. This is also called auxiliary space — the scratch space your algorithm needs to do its work, beyond what it was given.
3.1 Auxiliary Space
Auxiliary space is the temporary or extra space used by the algorithm, not counting the input itself.
Total Space = Input Space + Auxiliary Space
↑
This is what Space Complexity measures
Why exclude the input?
The input already exists before your algorithm runs. You didn’t create it. Penalising an algorithm for the size of data it was handed would be misleading — especially for algorithms that operate in sublinear space (needing less memory than the input itself).
3.2 The Turing Machine Model — DSPACE
The formal justification for excluding input and output comes from theoretical computer science. Here is the intuition without the heavy machinery.
Imagine a universal computing machine — not any specific laptop or server, but an abstract machine that captures the essence of computation. Alan Turing described exactly this in 1936: a machine with an infinitely long tape that reads and writes symbols, one cell at a time.
DSPACE is the formal measure of how much tape this machine uses to solve a given problem — specifically, how many tape cells it touches beyond the input and output.
The key insight: several important algorithms are sublinear in space — they use less memory than the size of their input. Binary search, for example, searches a million-item array using only three variables (lo, hi, mid). If you counted the array as part of the space, binary search would look like an O(n) algorithm. But it isn’t — the array was already there. The algorithm itself uses O(1) space.
To make this precise and fair, the Turing machine model separates the input, the working scratch space, and the output onto different tapes. Space complexity only counts the scratch (work) tape.
3.3 The Three-Tape Model
The multi-tape Turing machine uses three distinct tapes:
graph TD
IT["INPUT TAPE\nRead-only — never write to it\nNOT counted in space complexity\n\nExample: arr = [17, 5, 9, 3, 1]"]
WT["WORK TAPE\nRead and write freely\nTHIS IS what space complexity measures\n\nExample: right_max = 9, i = 2"]
OT["OUTPUT TAPE\nWrite-only — never read from it\nNOT counted in space complexity\n\nExample: ans = [-1, 9, 9, 17, ...]"]
IT -->|"algorithm reads input"| WT
WT -->|"algorithm writes result"| OT
style IT fill:#E6F1FB,stroke:#185FA5,color:#0C447C
style WT fill:#FAEEDA,stroke:#854F0B,color:#633806
style OT fill:#E1F5EE,stroke:#0F6E56,color:#085041
The kitchen analogy:
| Kitchen | Turing Machine |
|---|---|
| Bag of ingredients you were given | Input tape |
| Bowls, cutting boards, and tools you used | Work tape |
| Plate you serve to the guest | Output tape |
Space complexity only counts the bowls and tools — the scratch space you consumed while cooking. The ingredients and the finished dish don’t count.
Three-question test — apply in order:
- Is this variable the input? → Input tape. Do not count it.
- Is this variable what I return? → Output tape. Do not count it.
- Did I create this myself just to help compute the answer? → Work tape. Count it.
3.4 Space Complexity Classes
O(1) — Constant Space
The work tape size never grows, regardless of input size. Only a fixed number of scalar variables are used.
Real-world example: Reading through a million log entries and counting how many contain the word “ERROR” — you only need one counter variable, regardless of file size.
def replace_elements(arr):
"""
Replace each element with the maximum element to its right.
Last element becomes -1.
"""
n = len(arr)
ans = [0] * n # OUTPUT tape — not counted
right_max = -1 # WORK tape — 1 integer
i = n - 1 # WORK tape — 1 integer
while i >= 0:
ans[i] = right_max
right_max = max(arr[i], right_max)
i -= 1
return ans
# Tape assignment:
# arr → Input → NOT counted
# ans → Output → NOT counted
# right_max → Work → counted (1 integer)
# i → Work → counted (1 integer)
#
# Space complexity: O(1) — two integers on the work tape, always.
data = [17, 5, 9, 3, 1]
result = replace_elements(data)
print(f"Input: {data}")
print(f"Output: {result}")
print()
print("Work tape contents at any moment:")
print(" right_max = one integer")
print(" i = one integer")
print(" Total: O(1) — constant, no matter how large the array")
O(log n) — Logarithmic Space
The work tape grows, but very slowly — proportional to the logarithm of n.
The most common cause: recursion. Every recursive call adds a stack frame to the call stack. The call stack is part of the work tape.
Real-world example: Git’s internal tree operations are recursive. When you run git log, Git walks a tree structure recursively. The call stack depth is logarithmic in the number of commits for a balanced tree.
# Recursive binary search — O(log n) space from the call stack
def binary_search_recursive(arr, target, lo, hi, depth=0):
if lo > hi:
return -1, depth
mid = (lo + hi) // 2
print(f" Depth {depth}: checking arr[{mid}] = {arr[mid]}, range [{lo}..{hi}]")
if arr[mid] == target:
return mid, depth
elif arr[mid] < target:
return binary_search_recursive(arr, target, mid + 1, hi, depth + 1)
else:
return binary_search_recursive(arr, target, lo, mid - 1, depth + 1)
arr = list(range(0, 1000, 10)) # [0, 10, 20, ..., 990]
target = 730
print(f"Searching for {target} in array of {len(arr)} items:")
idx, max_depth = binary_search_recursive(arr, target, 0, len(arr) - 1)
print(f"\nFound at index {idx}, max recursion depth: {max_depth}")
print(f"Array size: {len(arr)}, log₂({len(arr)}) ≈ {len(arr).bit_length() - 1}")
print(f"Call stack grew to at most {max_depth + 1} frames deep — O(log n) space")
graph TD
F1["Stack frame 1\nlo=0, hi=99, mid=49"]
F2["Stack frame 2\nlo=50, hi=99, mid=74"]
F3["Stack frame 3\nlo=50, hi=73, mid=62"]
FN["... log₂(n) frames deep at most"]
F1 --> F2 --> F3 --> FN
style F1 fill:#FAEEDA,stroke:#854F0B,color:#633806
style F2 fill:#FAEEDA,stroke:#854F0B,color:#633806
style F3 fill:#FAEEDA,stroke:#854F0B,color:#633806
style FN fill:#FAEEDA,stroke:#854F0B,color:#633806
Compare iterative vs recursive binary search:
| Version | Work tape contents | Space |
|---|---|---|
| Iterative | lo, hi, mid — always 3 integers | O(1) |
| Recursive | call stack grows log₂(n) frames deep | O(log n) |
Same algorithm, same logic — different space complexity purely because of how it is written.
O(n) — Linear Space
The work tape grows proportionally to the input.
Real-world example: A web browser’s history list. Every page you visit gets added. The memory used is proportional to the number of pages visited.
def count_frequencies(arr):
"""Build a frequency table — classic O(n) space."""
freq = {} # WORK tape: grows with n unique elements
for x in arr:
freq[x] = freq.get(x, 0) + 1
return freq # OUTPUT tape: not counted as work
def find_two_sum(arr, target):
"""
Find two indices that sum to target.
Uses a hash map (O(n) work space) to achieve O(n) time
instead of the O(n²) brute-force approach.
"""
seen = {} # WORK tape: up to n entries
for i, x in enumerate(arr):
complement = target - x
if complement in seen:
return seen[complement], i
seen[x] = i
return None
# Example 1: frequency table
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
freq = count_frequencies(words)
print("Word frequencies:")
for word, count in sorted(freq.items()):
print(f" {word}: {count}")
print(f"Work tape (freq dict) holds {len(freq)} entries for {len(words)} words")
# Example 2: two-sum
print()
numbers = [2, 7, 11, 15, 1, 8]
target = 9
result = find_two_sum(numbers, target)
if result:
i, j = result
print(f"Two sum: {numbers[i]} + {numbers[j]} = {target} (indices {i} and {j})")
print("The 'seen' hash map trades O(n) space for O(n) time vs O(n²) brute-force")
O(n²) — Quadratic Space
The work tape holds a 2D structure — typically an n×n matrix or grid. These appear frequently in dynamic programming.
Real-world example: Spell checkers and DNA sequence alignment use the edit distance algorithm. Two strings of length n require an n×n grid to compute their minimum edit distance.
def edit_distance(s, t):
"""
Minimum number of insertions, deletions, or substitutions
to turn string s into string t.
Classic O(n²) space DP solution.
"""
m, n = len(s), len(t)
# WORK tape: (m+1) × (n+1) grid = O(m*n) ≈ O(n²)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Base cases
for i in range(m + 1):
dp[i][0] = i # delete all of s
for j in range(n + 1):
dp[0][j] = j # insert all of t
# Fill the grid
for i in range(1, m + 1):
for j in range(1, n + 1):
if s[i-1] == t[j-1]:
dp[i][j] = dp[i-1][j-1] # characters match, free
else:
dp[i][j] = 1 + min(
dp[i-1][j], # delete from s
dp[i][j-1], # insert into t
dp[i-1][j-1] # substitute
)
return dp[m][n]
def edit_distance_optimized(s, t):
"""
Same result, but O(n) space — only keep the previous row.
"""
m, n = len(s), len(t)
prev = list(range(n + 1)) # WORK tape: only one row at a time!
for i in range(1, m + 1):
curr = [i] + [0] * n
for j in range(1, n + 1):
if s[i-1] == t[j-1]:
curr[j] = prev[j-1]
else:
curr[j] = 1 + min(prev[j], curr[j-1], prev[j-1])
prev = curr
return prev[n]
pairs = [
("kitten", "sitting"),
("sunday", "saturday"),
("python", "python"),
("abc", ""),
]
print(f"{'s':>10} {'t':>10} {'distance':>8} {'optimized':>9}")
print("-" * 45)
for s, t in pairs:
d1 = edit_distance(s, t)
d2 = edit_distance_optimized(s, t)
print(f"{s:>10} {t:>10} {d1:>8} {d2:>9}")
print()
print("Both give identical results.")
print("Original: O(n²) space (full grid in memory)")
print("Optimized: O(n) space (two rows at a time)")
print("Same time complexity — O(n²) either way.")
The key optimization insight: Many O(n²) DP problems can be reduced to O(n) space by noticing you only ever need the previous row, not the entire grid. You trade readability for memory efficiency.
graph TD
subgraph "O(n²) Work tape — full grid"
M["n × n matrix\n\n[ 0, 1, 2, 3, ... ]\n[ 1, 0, 1, 2, ... ]\n[ 2, 1, 0, 1, ... ]\n[ 3, 2, 1, 0, ... ]\n ... ... ... "]
end
subgraph "O(n) Work tape — two rows only"
R["prev = [0, 1, 2, 3, ...]\ncurr = [1, 0, 1, 2, ...]"]
end
M -->|"Space optimization\n(same answers)"| R
style M fill:#FAECE7,stroke:#993C1D,color:#712B13
style R fill:#E1F5EE,stroke:#0F6E56,color:#085041
4. Time vs Space Trade-offs
In the real world, time and space often trade against each other. You can frequently buy speed by using more memory, or save memory by doing more computation.
graph LR
A["Problem"] --> B{"Trade-off decision"}
B -->|"More memory available"| C["Cache results\nMemoization / DP tables\nFaster time, higher space"]
B -->|"Memory is constrained"| D["Recompute as needed\nOptimized DP rows\nMore time, lower space"]
style C fill:#E6F1FB,stroke:#185FA5,color:#0C447C
style D fill:#E1F5EE,stroke:#0F6E56,color:#085041
style B fill:#FAEEDA,stroke:#854F0B,color:#633806
Classic examples:
| Technique | Time saved | Space cost |
|---|---|---|
| Memoization (top-down DP) | Exponential → polynomial | O(n) to O(n²) extra |
| Hash map for lookups | O(n) search → O(1) search | O(n) for the hash map |
| Precomputed prefix sums | O(n) range sum → O(1) range sum | O(n) for prefix array |
| Rolling window DP | Same time, much less space | O(n²) → O(n) |
Fibonacci — the textbook progression:
import sys
# Version 1: Naive recursion
# Time: O(2ⁿ) — recalculates the same values over and over
# Space: O(n) — the call stack
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
# Version 2: Memoized recursion
# Time: O(n) — each value computed exactly once
# Space: O(n) — the memo table + call stack
def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
# Version 3: Bottom-up DP table
# Time: O(n) — one pass
# Space: O(n) — the dp array
def fib_dp(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# Version 4: Space-optimized
# Time: O(n) — one pass
# Space: O(1) — only two variables!
def fib_optimized(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(n - 1):
a, b = b, a + b
return b
# Compare results (not timing naive for large n — it's too slow)
print(f"{'n':>4} | {'memo':>12} | {'dp':>12} | {'optimized':>12}")
print("-" * 50)
for n in [5, 10, 20, 30]:
print(f"{n:>4} | {fib_memo(n):>12} | {fib_dp(n):>12} | {fib_optimized(n):>12}")
print()
print("All four versions give identical answers.")
print("The difference is time and space efficiency:")
print(" fib_naive: O(2ⁿ) time, O(n) space — unusable for n > 40")
print(" fib_memo: O(n) time, O(n) space — fast, uses a dict")
print(" fib_dp: O(n) time, O(n) space — fast, uses an array")
print(" fib_optimized: O(n) time, O(1) space — fast, uses 2 variables")
Real-world decision: When you’re running a web server handling millions of requests, and each request needs the 10,000th Fibonacci number, you’d precompute it once and store it. That’s trading O(1) space for O(1) time per request — the ultimate win.
5. Quick Reference Cheat Sheet
Big O Complexity Ladder
graph TD
C1["O(1) — Constant\nArray index, dict lookup, stack push/pop\nDoes NOT grow with input"]
C2["O(log n) — Logarithmic\nBinary search, balanced BST lookup\nGrows 1 step per doubling of input"]
C3["O(n) — Linear\nLinear scan, sum, min/max\nGrows proportionally with input"]
C4["O(n log n) — Linearithmic\nMerge sort, heap sort, Timsort\nSlightly worse than linear"]
C5["O(n²) — Quadratic\nNested loops, bubble sort, comparing all pairs\nDoubling input → 4× the work"]
C6["O(2ⁿ) — Exponential\nAll subsets, naive recursion\nAdding 1 to input → doubles the work"]
C7["O(n!) — Factorial\nAll permutations, brute-force TSP\nPractically impossible for n > 15"]
C1 --> C2 --> C3 --> C4 --> C5 --> C6 --> C7
style C1 fill:#E1F5EE,stroke:#0F6E56,color:#085041
style C2 fill:#E6F1FB,stroke:#185FA5,color:#0C447C
style C3 fill:#EEEDFE,stroke:#534AB7,color:#3C3489
style C4 fill:#FAEEDA,stroke:#854F0B,color:#633806
style C5 fill:#FAECE7,stroke:#993C1D,color:#712B13
style C6 fill:#FCEBEB,stroke:#A32D2D,color:#791F1F
style C7 fill:#FBEAF0,stroke:#993556,color:#72243E
Time Complexity Reference
| Complexity | Name | Typical cause | Real-world example |
|---|---|---|---|
| O(1) | Constant | Direct access | Array index, dict lookup |
| O(log n) | Logarithmic | Halving the input | Binary search, database index |
| O(n) | Linear | Single pass | Find max, count occurrences |
| O(n log n) | Linearithmic | Divide and merge | Merge sort, Python’s sorted() |
| O(n²) | Quadratic | Nested loops | Bubble sort, comparing all pairs |
| O(2ⁿ) | Exponential | All subsets | Naive recursive Fibonacci |
| O(n!) | Factorial | All permutations | Brute-force Travelling Salesman |
Space Complexity Reference
| Complexity | Name | Typical cause | Real-world example |
|---|---|---|---|
| O(1) | Constant | Only scalar variables | Iterative binary search, two-pointer |
| O(log n) | Logarithmic | Recursive call stack | Recursive binary search, recursive DFS |
| O(n) | Linear | Array or hash map as scratch | Frequency table, BFS queue |
| O(n²) | Quadratic | 2D matrix as scratch | Edit distance DP, pairwise distances |
The Three-Tape Test
Is this variable the INPUT? → Input tape. Don't count it.
Is this variable the OUTPUT? → Output tape. Don't count it.
Did I CREATE it as a helper? → Work tape. COUNT IT. ← this is your space complexity
Big O Simplification Rules
Drop constants: O(3n) → O(n)
Drop lower-order terms: O(n² + n) → O(n²)
Nested loops multiply: O(n) × O(n) = O(n²)
Sequential steps add: O(n) + O(n) = O(n)
Recursion depth × work: log n levels × O(n) work = O(n log n)
The One-Sentence Summary
A good algorithm does the minimum necessary work (time) using the minimum necessary memory (space) — and understanding Big O is how you prove it.
Topics covered: Big O Notation · Ω and Θ · Best/Average/Worst Case · Timing Experiments · Auxiliary Space · DSPACE / Turing Machine Model · Input/Work/Output Tapes · O(1) · O(log n) · O(n) · O(n²) · Time–Space Trade-offs · Fibonacci progression.
Arrays
Every list in every app you have ever used is backed by an array. Your Spotify playlist, your Instagram feed, the leaderboard in your favourite game — all arrays under the hood. Understanding arrays means understanding the foundation that almost every other data structure is built on top of.
What is an array?
An array is a collection of items stored one after another in memory — like a row of labelled boxes on a shelf. Each box holds one value and has a unique number called an index starting from 0.
flowchart LR
I["my_list"] --> A
subgraph A["Array in memory"]
direction LR
B0["index 0\n---\n 'Alice' "]
B1["index 1\n---\n 'Bob' "]
B2["index 2\n---\n 'Carol' "]
B3["index 3\n---\n 'Dan' "]
end
Because items sit side-by-side in memory, the computer can jump straight to any slot using simple arithmetic. That is why reading arr[2] is just as fast as reading arr[0] — it is always a single step, no matter how large the array is.
A quick taste
players = ["Alice", "Bob", "Carol", "Dan"]
# Read any element in O(1) — instant, no matter the size
print("First player:", players[0])
print("Last player: ", players[-1])
# Iterate over all elements in O(n)
print("\nFull roster:")
for i, name in enumerate(players):
print(f" Slot {i}: {name}")
What you will learn in this section
This section builds your understanding layer by layer:
| Chapter | Topic | The big idea |
|---|---|---|
| RAM | Memory and addresses | Why index access costs O(1) |
| Static Arrays | Fixed-size arrays | Fast reads, expensive inserts |
| Dynamic Arrays | Resizable arrays | How Python lists grow automatically |
| Stacks | LIFO structure | One of the most useful tools in CS |
| Problems | Practice problems | Apply everything you learned |
Where arrays appear in the real world
- Social media feeds — posts stored in order, newest first
- Image pixels — a 1080p image is just an array of 2,073,600 colour values
- Audio samples — a 44 kHz audio track stores 44,000 numbers per second
- Undo history — every keystroke you type is pushed onto an array
- Game inventories — item slots are fixed-size arrays
- Spreadsheets — each row and column is an array of cells
The two flavours
Arrays come in two main varieties that this section explores in depth:
flowchart TD
A["Arrays"] --> B["Static Arrays\n(fixed capacity)"]
A --> C["Dynamic Arrays\n(auto-resize)"]
B --> D["Great when size is known\nC arrays, pixel buffers"]
C --> E["Great when size changes\nPython list, Java ArrayList"]
Start with RAM to understand why arrays are designed the way they are, then work through each chapter in order.
RAM
Before arrays make sense, memory needs to make sense. And once you understand memory, the clever design of arrays will feel completely obvious.
RAM is a giant hotel
Picture a huge hotel with millions of numbered rooms, side by side down an infinitely long corridor. Every room has a unique address (room number) printed on the door, and each room can hold exactly one small value (typically 1 byte).
Your program is a guest who has been given a key to a block of rooms. When it needs to store a number, it checks in to a room and remembers the address. When it needs the number back, it walks straight to that door — no searching, no asking at reception.
The crucial insight: the CPU can open any room in O(1) time because it knows every address in advance. It never has to wander down the hallway checking room numbers one by one.
flowchart LR
CPU["CPU"]
subgraph RAM["RAM (the hotel)"]
direction LR
R0["addr 1000\n---\n 42 "]
R1["addr 1001\n---\n 07 "]
R2["addr 1002\n---\n 19 "]
R3["addr 1003\n---\n 88 "]
R4["addr 1004\n---\n 55 "]
R5["addr 1005\n---\n 03 "]
end
CPU -- "read addr 1002" --> R2
CPU -- "write addr 1004" --> R4
How an array maps onto RAM
When you create an integer array, Python (or C, or any language) reserves a contiguous block of rooms — a stretch of addresses all in a row. Each element lives in the next room along.
flowchart LR
subgraph ARR["scores = [10, 30, 50, 70]"]
direction LR
A0["index 0\n---\n 10 "]
A1["index 1\n---\n 30 "]
A2["index 2\n---\n 50 "]
A3["index 3\n---\n 70 "]
end
subgraph MEM["RAM addresses (4 bytes each)"]
direction LR
M0["addr 2000\n---\n 10 "]
M1["addr 2004\n---\n 30 "]
M2["addr 2008\n---\n 50 "]
M3["addr 2012\n---\n 70 "]
end
A0 --> M0
A1 --> M1
A2 --> M2
A3 --> M3
The O(1) address formula
If the array starts at address base and each element occupies size bytes, then the address of element i is:
address(i) = base + i × size
For the example above with base = 2000 and size = 4:
| Index | Calculation | Address |
|---|---|---|
| 0 | 2000 + 0 × 4 | 2000 |
| 1 | 2000 + 1 × 4 | 2004 |
| 2 | 2000 + 2 × 4 | 2008 |
| 3 | 2000 + 3 × 4 | 2012 |
One multiplication, one addition — and the CPU is standing at exactly the right room door. That is why array index access is O(1) no matter whether the array has 4 elements or 4 million.
Seeing memory addresses in Python
Python hides raw memory management, but you can still peek at the underlying address of any object using id(). The value returned is the memory address where that object lives.
# id() returns the memory address of an object in CPython
a = 42
b = "hello"
c = [1, 2, 3]
print(f"Address of integer 42 : {id(a)}")
print(f"Address of string hello : {id(b)}")
print(f"Address of list [1,2,3] : {id(c)}")
# List elements are stored contiguously in a backing array.
# We can also examine the id of individual element objects.
numbers = [100, 200, 300, 400]
print("\nElement addresses inside the list:")
for i, val in enumerate(numbers):
print(f" numbers[{i}] = {val} → id = {id(numbers[i])}")
Static Arrays
Imagine buying an egg carton at the supermarket. It holds exactly 12 eggs — no more, no less. You cannot squeeze a 13th egg in, and the carton does not shrink if you only buy 6. That is a static array: a fixed-capacity container where every slot has a reserved place in memory.
Static arrays are the simplest and most efficient array type. They are used everywhere performance is critical — pixel buffers in images, audio sample arrays, network packet buffers, and low-level game engines all rely on them.
Reading and traversal
Because elements live at predictable addresses, reading any element by index costs O(1) — it is a single arithmetic calculation regardless of array size.
Walking the whole array visits n elements, so that costs O(n).
# Think of this as a fixed 5-slot container
scores = [88, 72, 95, 61, 84]
# O(1) random access — instant, no matter the size
print("Third score:", scores[2])
# O(n) traversal — visits every element once
print("\nAll scores:")
for i in range(len(scores)):
print(f" slot {i}: {scores[i]}")
# Index out of range is the classic static-array mistake
try:
print(scores[10])
except IndexError as e:
print(f"\nError: {e} <- static arrays have hard boundaries!")
Deleting from the middle
This is where static arrays get expensive. Memory must stay contiguous, so when you remove an element from the middle, every element to its right has to slide one slot to the left to close the gap.
flowchart TB
subgraph Before["BEFORE — delete index 1 (value 30)"]
direction LR
B0["0\n---\n 10 "]
B1["1\n---\n 30 "]
B2["2\n---\n 50 "]
B3["3\n---\n 70 "]
end
subgraph After["AFTER — shift left, zero out tail"]
direction LR
A0["0\n---\n 10 "]
A1["1\n---\n 50 "]
A2["2\n---\n 70 "]
A3["3\n---\n 0 "]
end
Before --> After
In the worst case (deleting the first element) every element shifts, so this is O(n).
def remove_at(arr, index, length):
"""Remove element at `index` by shifting later elements left."""
# Slide everything after `index` one position to the left
for i in range(index + 1, length):
arr[i - 1] = arr[i]
# Zero out the vacated last slot
arr[length - 1] = 0
return length - 1
arr = [10, 30, 50, 70, 0] # last slot is reserved padding
length = 4
print("Before:", arr[:length])
length = remove_at(arr, 1, length) # remove 30
print("After: ", arr[:length])
print("Full backing array:", arr) # see the zeroed tail
Inserting into the middle
The opposite problem: to make a gap at a chosen position, every element from that position onward has to slide one slot to the right before you can write the new value.
flowchart TB
subgraph Before["BEFORE — insert 99 at index 1"]
direction LR
B0["0\n---\n 10 "]
B1["1\n---\n 30 "]
B2["2\n---\n 50 "]
B3["3\n---\n _ "]
end
subgraph Shift["SHIFT RIGHT — make room at index 1"]
direction LR
S0["0\n---\n 10 "]
S1["1\n---\n _ "]
S2["2\n---\n 30 "]
S3["3\n---\n 50 "]
end
subgraph After["AFTER — write 99 into the gap"]
direction LR
A0["0\n---\n 10 "]
A1["1\n---\n 99 "]
A2["2\n---\n 30 "]
A3["3\n---\n 50 "]
end
Before --> Shift --> After
Inserting at the front is the worst case — every element moves — so this is also O(n).
def insert_at(arr, index, value, length, capacity):
"""Insert `value` at `index` by shifting later elements right."""
if length == capacity:
raise ValueError("Array is full — static arrays cannot grow!")
# Slide everything from `index` onward one position to the right
for i in range(length - 1, index - 1, -1):
arr[i + 1] = arr[i]
arr[index] = value
return length + 1
arr = [10, 30, 50, 0] # one spare slot at the end
length = 3
capacity = len(arr)
print("Before:", arr[:length])
length = insert_at(arr, 1, 99, length, capacity) # insert 99 at index 1
print("After: ", arr[:length])
Timing: reads vs inserts
Run this to see the time difference between O(1) reads and O(n) inserts at scale.
import time
SIZE = 100_000
arr = list(range(SIZE))
# O(1) read — blazing fast
start = time.perf_counter()
for _ in range(10_000):
_ = arr[SIZE // 2]
read_time = time.perf_counter() - start
# O(n) insert at front — much slower
start = time.perf_counter()
for _ in range(100):
arr.insert(0, -1) # Python list.insert is O(n) for middle/front
insert_time = time.perf_counter() - start
print(f"10,000 random reads : {read_time*1000:.2f} ms")
print(f" 100 front inserts : {insert_time*1000:.2f} ms")
print(f"\nInserts were ~{insert_time/read_time * 100:.0f}x slower per operation")
Operation complexity summary
| Operation | Time complexity | Why |
|---|---|---|
| Read by index | O(1) | Direct address calculation |
| Update by index | O(1) | Same — just write to that address |
| Traverse all | O(n) | Must visit every element |
| Insert at middle/front | O(n) | All later elements must shift right |
| Delete from middle/front | O(n) | All later elements must shift left |
| Insert at end (if space) | O(1) | No shifting needed |
| Delete from end | O(1) | No shifting needed |
Real-world uses
- Image pixel buffers — a 1920x1080 image is a flat static array of 2,073,600 colour values. Random access to any pixel is O(1).
- Audio samples — a WAV file stores 44,100 integer samples per second. The playback engine reads them in order at high speed.
- Lookup tables — precomputed values like sine/cosine tables are stored in static arrays for O(1) retrieval.
- Ring buffers in networking — fixed-size arrays used to buffer incoming packets before they are processed.
Takeaway
Static arrays shine when you know the size upfront and need fast reads. They become painful when you need to insert or delete from anywhere other than the end. The next chapter shows how dynamic arrays solve the growth problem.
Dynamic Arrays
Imagine a magical backpack that starts small but silently doubles in size the moment it gets full. You never have to think about how big it is — you just keep throwing things in. That is exactly how a dynamic array works.
Dynamic arrays give you the fast O(1) index access of static arrays while also letting the collection grow automatically as you add items. Python’s list, JavaScript’s Array, and Java’s ArrayList are all dynamic arrays under the hood.
The core idea
A dynamic array maintains two numbers internally:
- length — how many elements you have actually stored
- capacity — how many slots the underlying memory block can hold
When length == capacity and you try to add another element, the array quietly allocates a bigger block (usually 2x the current capacity), copies everything over, and continues as if nothing happened.
flowchart LR
subgraph S1["Step 1: capacity=2, length=2\n(FULL)"]
direction LR
A0["0\n---\n 5 "]
A1["1\n---\n 6 "]
end
subgraph S2["Step 2: allocate new block\ncapacity=4"]
direction LR
B0["0\n---\n 5 "]
B1["1\n---\n 6 "]
B2["2\n---\n _ "]
B3["3\n---\n _ "]
end
subgraph S3["Step 3: append 7\nlength=3"]
direction LR
C0["0\n---\n 5 "]
C1["1\n---\n 6 "]
C2["2\n---\n 7 "]
C3["3\n---\n _ "]
end
S1 -- "resize!" --> S2 --> S3
Building a dynamic array from scratch
This implementation mirrors exactly what Python’s built-in list does behind the scenes:
class DynamicArray:
def __init__(self):
self.capacity = 2 # starting size of the backing store
self.length = 0 # number of real elements
self.arr = [0] * self.capacity
def pushback(self, value):
"""Append a value. Resize first if the array is full."""
if self.length == self.capacity:
self._resize()
self.arr[self.length] = value
self.length += 1
def popback(self):
"""Remove and return the last element."""
if self.length == 0:
raise IndexError("popback from empty array")
value = self.arr[self.length - 1]
self.arr[self.length - 1] = 0 # optional cleanup
self.length -= 1
return value
def get(self, index):
"""O(1) read by index."""
if index < 0 or index >= self.length:
raise IndexError("index out of range")
return self.arr[index]
def _resize(self):
"""Double the capacity and copy all elements over."""
self.capacity *= 2
new_arr = [0] * self.capacity
for i in range(self.length):
new_arr[i] = self.arr[i]
self.arr = new_arr
print(f" [resize] capacity doubled to {self.capacity}")
def values(self):
return self.arr[:self.length]
def __repr__(self):
return f"DynamicArray(values={self.values()}, length={self.length}, capacity={self.capacity})"
# Watch the resizes happen live
da = DynamicArray()
for v in [5, 6, 7, 8, 9, 10, 11, 12]:
da.pushback(v)
print(f" appended {v:2d} → {da}")
The capacity doubling chain
Every time the array fills up, capacity doubles. The sequence is predictable:
flowchart LR
C1["capacity\n1"] --> C2["capacity\n2"]
C2 --> C4["capacity\n4"]
C4 --> C8["capacity\n8"]
C8 --> C16["capacity\n16"]
C16 --> C32["capacity\n32"]
This geometric growth is the key to why appends stay cheap on average.
Why append is amortized O(1)
A resize copies n elements and costs O(n). But resizes are rare — they only happen at capacities 1, 2, 4, 8, 16, … So if you append n items total:
- The resize at capacity 1 copies 1 element
- The resize at capacity 2 copies 2 elements
- The resize at capacity 4 copies 4 elements
- …
- The final resize copies at most
nelements
Total copy work: 1 + 2 + 4 + ... + n = 2n. Spread across n appends, each append costs 2n / n = 2 on average — O(1) amortized.
# Prove it: measure the average cost of appending n elements
import time
def bench_appends(n):
start = time.perf_counter()
lst = []
for i in range(n):
lst.append(i)
elapsed = time.perf_counter() - start
return elapsed
sizes = [10_000, 100_000, 1_000_000]
for n in sizes:
t = bench_appends(n)
per_append = t / n * 1_000_000 # microseconds
print(f"n={n:>9,} total={t*1000:.1f}ms per_append={per_append:.4f}us")
print("\nPer-append time stays roughly constant as n grows: amortized O(1)!")
Watching Python list capacity grow
Python does not expose capacity directly, but the sys.getsizeof() trick lets you watch memory usage jump at the doubling points:
import sys
lst = []
prev_size = sys.getsizeof(lst)
print(f"elements={0:>3} memory={prev_size} bytes")
for i in range(1, 33):
lst.append(i)
size = sys.getsizeof(lst)
marker = " <-- RESIZE" if size != prev_size else ""
print(f"elements={i:>3} memory={size} bytes{marker}")
prev_size = size
Real-world dynamic arrays
| Language | Dynamic array type | Notes |
|---|---|---|
| Python | list | Overallocates by ~12.5% beyond capacity |
| JavaScript | Array | Engine-dependent, usually similar doubling |
| Java | ArrayList | Default capacity 10, grows by 50% |
| C++ | std::vector | Doubles on resize |
| C# | List<T> | Doubles on resize |
Operation complexity summary
| Operation | Time complexity | Notes |
|---|---|---|
| Read / write by index | O(1) | Direct address calculation |
| Append (pushback) | O(1) amortized | Occasional O(n) resize, but rare |
| Pop from end | O(1) | No shifting |
| Insert at middle | O(n) | Must shift elements right |
| Delete from middle | O(n) | Must shift elements left |
| Resize | O(n) | Triggered automatically, rarely |
Takeaway
Dynamic arrays solve the biggest limitation of static arrays (fixed size) while keeping O(1) random access and O(1) amortized appends. The price is occasional O(n) resize events and some wasted capacity. For most applications, that trade-off is completely worth it — which is why almost every language uses them as the default list type.
Next up: Stacks, a clever restriction on dynamic arrays that turns out to be one of the most powerful tools in computer science.
Stacks
Picture a stack of pancakes fresh off the griddle. You always add the newest pancake on top, and you always eat from the top first. You would never pull a pancake from the middle or the bottom — the whole structure works because there is only one end you can touch.
That is a stack in computer science: a collection that allows additions and removals at one end only — the top.
The LIFO principle
Stack stands for Last In, First Out. Whatever you added most recently is the first thing you get back. This single constraint makes stacks surprisingly powerful.
flowchart TB
subgraph Stack["The Stack"]
direction TB
TOP["TOP <-- always interact here"]
E4["pancake 4 (added last, eaten first)"]
E3["pancake 3"]
E2["pancake 2"]
E1["pancake 1 (added first, eaten last)"]
BOTTOM["BOTTOM"]
end
TOP --> E4 --> E3 --> E2 --> E1 --> BOTTOM
The three core operations
Every stack provides exactly three operations:
| Operation | Description | Cost |
|---|---|---|
push(value) | Add an item to the top | O(1) amortized |
pop() | Remove and return the top item | O(1) |
peek() | Look at the top item without removing it | O(1) |
Push: adding to the top
flowchart LR
subgraph Before["Before push(40)"]
direction TB
T1["top --> 30"]
V2["20"]
V1["10"]
end
subgraph After["After push(40)"]
direction TB
T2["top --> 40"]
V3["30"]
V2b["20"]
V1b["10"]
end
Before -- "push(40)" --> After
Pop: removing from the top
flowchart LR
subgraph Before["Before pop()"]
direction TB
T1["top --> 40"]
V3["30"]
V2["20"]
V1["10"]
end
subgraph After["After pop() returns 40"]
direction TB
T2["top --> 30"]
V2b["20"]
V1b["10"]
end
Before -- "pop() = 40" --> After
A complete Stack implementation
class Stack:
def __init__(self):
self._data = [] # dynamic array as the backing store
def push(self, value):
"""Add value to the top of the stack. O(1) amortized."""
self._data.append(value)
def pop(self):
"""Remove and return the top value. O(1). Raises if empty."""
if self.is_empty():
raise IndexError("pop from an empty stack")
return self._data.pop()
def peek(self):
"""Return the top value without removing it. O(1). Raises if empty."""
if self.is_empty():
raise IndexError("peek at an empty stack")
return self._data[-1]
def is_empty(self):
"""Return True if the stack has no elements."""
return len(self._data) == 0
def size(self):
"""Return the number of elements in the stack."""
return len(self._data)
def __repr__(self):
if self.is_empty():
return "Stack([] <-- top)"
items = " | ".join(str(x) for x in self._data)
return f"Stack([{items}] <-- top)"
# --- Demo ---
s = Stack()
print("=== Pushing 10, 20, 30, 40 ===")
for v in [10, 20, 30, 40]:
s.push(v)
print(f" push({v:2d}) --> {s}")
print(f"\npeek() = {s.peek()} (stack unchanged: {s})")
print("\n=== Popping everything ===")
while not s.is_empty():
val = s.pop()
print(f" pop() = {val:2d} --> {s}")
print("\nAttempting pop on empty stack:")
try:
s.pop()
except IndexError as e:
print(f" IndexError: {e}")
Real-world uses
Browser back button
Every time you navigate to a new page, the browser pushes the current URL onto a history stack. When you hit Back, it pops the top URL and navigates there.
class BrowserHistory:
def __init__(self, start_page):
self._back_stack = Stack()
self._current = start_page
def visit(self, url):
"""Navigate to a new page."""
self._back_stack.push(self._current)
self._current = url
print(f" Visited: {self._current}")
def back(self):
"""Go back to the previous page."""
if self._back_stack.is_empty():
print(" No history to go back to!")
return
self._current = self._back_stack.pop()
print(f" Back to: {self._current}")
def current(self):
return self._current
class Stack:
def __init__(self):
self._data = []
def push(self, v):
self._data.append(v)
def pop(self):
if not self._data:
raise IndexError("empty")
return self._data.pop()
def peek(self):
return self._data[-1]
def is_empty(self):
return len(self._data) == 0
browser = BrowserHistory("google.com")
browser.visit("github.com")
browser.visit("docs.python.org")
browser.visit("stackoverflow.com")
print(f"\nCurrent page: {browser.current()}")
print()
browser.back()
browser.back()
print(f"\nCurrent page: {browser.current()}")
Undo and redo
Text editors push every action onto an undo stack. Ctrl+Z pops the last action and reverses it, pushing it onto a redo stack so Ctrl+Y can restore it.
class Stack:
def __init__(self):
self._data = []
def push(self, v):
self._data.append(v)
def pop(self):
if not self._data:
return None
return self._data.pop()
def peek(self):
return self._data[-1] if self._data else None
def is_empty(self):
return len(self._data) == 0
def __repr__(self):
return str(self._data)
class TextEditor:
def __init__(self):
self.text = ""
self._undo_stack = Stack()
self._redo_stack = Stack()
def type(self, chars):
self._undo_stack.push(self.text) # save current state
self._redo_stack = Stack() # typing clears redo history
self.text += chars
print(f" type({chars!r}) --> {self.text!r}")
def undo(self):
prev = self._undo_stack.pop()
if prev is None:
print(" Nothing to undo!")
return
self._redo_stack.push(self.text)
self.text = prev
print(f" undo() --> {self.text!r}")
def redo(self):
nxt = self._redo_stack.pop()
if nxt is None:
print(" Nothing to redo!")
return
self._undo_stack.push(self.text)
self.text = nxt
print(f" redo() --> {self.text!r}")
ed = TextEditor()
ed.type("Hello")
ed.type(", ")
ed.type("world")
ed.type("!")
print()
ed.undo()
ed.undo()
print()
ed.redo()
Balanced parentheses checker
A classic stack interview problem: scan a string left to right, push every opening bracket, and pop when you see a closing bracket. If the stack is empty at the end, the brackets are balanced.
class Stack:
def __init__(self):
self._data = []
def push(self, v):
self._data.append(v)
def pop(self):
return self._data.pop() if self._data else None
def is_empty(self):
return len(self._data) == 0
def is_balanced(s):
stack = Stack()
matching = {')': '(', ']': '[', '}': '{'}
for ch in s:
if ch in '([{':
stack.push(ch)
elif ch in ')]}':
if stack.is_empty() or stack.pop() != matching[ch]:
return False
return stack.is_empty()
tests = [
"((1 + 2) * (3 + 4))",
"{[()]}",
"([)]",
"(((",
"",
]
for expr in tests:
result = is_balanced(expr)
icon = "OK" if result else "FAIL"
print(f" {icon} {expr!r}")
Other places stacks appear
- Function call stack — every function call in every program pushes a frame, every return pops it. The “stack overflow” error literally means the call stack ran out of space.
- Depth-first search (DFS) — graph traversal uses a stack (explicitly or via recursion) to track the current path.
- Expression evaluation — compilers use stacks to evaluate arithmetic expressions and respect operator precedence.
- HTML/XML parsing — opening tags are pushed; closing tags must match the top of the stack.
Operation complexity summary
| Operation | Time complexity | Notes |
|---|---|---|
push | O(1) amortized | Backed by a dynamic array |
pop | O(1) | Remove last element of the backing array |
peek | O(1) | Read last element of the backing array |
is_empty | O(1) | Check length |
size | O(1) | Check length |
Takeaway
A stack is a dynamic array with a single rule: only touch the top. That constraint sounds limiting, but it perfectly models a huge class of real problems — anything with a “most recent first” or “undo last action” pattern. The call stack in your computer right now is a stack, and you have been using stacks every time you pressed Ctrl+Z or the browser back button.
Kadane’s Algorithm
Your stock trading app shows the best period to buy and sell. You glance at a chart of daily gains and losses and want to know: which consecutive stretch of days would have made you the most money? Behind that feature — and countless others — lives Kadane’s algorithm.
The Problem
Given an array of integers (positive and negative), find the contiguous subarray whose elements sum to the largest possible value.
Input: [-2, 1, -3, 4, -1, 2, 1, -5, 4]
Output: 6 (subarray [4, -1, 2, 1])
Why Brute Force Is Too Slow
The obvious approach checks every possible subarray:
def max_subarray_brute(arr):
n = len(arr)
max_sum = float('-inf')
best_start = best_end = 0
for i in range(n):
for j in range(i, n):
current = sum(arr[i:j+1])
if current > max_sum:
max_sum = current
best_start, best_end = i, j
return max_sum, arr[best_start:best_end+1]
arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
result, subarray = max_subarray_brute(arr)
print(f"Max sum: {result}")
print(f"Subarray: {subarray}")
# Show why this gets expensive
import time
import random
for size in [100, 1000, 5000]:
data = [random.randint(-10, 10) for _ in range(size)]
start = time.time()
max_subarray_brute(data)
elapsed = (time.time() - start) * 1000
print(f"n={size:5d}: {elapsed:.1f} ms")
With n = 5000 the brute force checks over 12 million subarrays. At n = 100 000 that becomes 5 billion. We need better.
Time complexity: O(n²) — two nested loops Space complexity: O(1)
The Key Insight
At each position i, you face exactly one decision:
Should the current element join the existing subarray, or should it start a fresh subarray of its own?
If the running sum so far is negative, it is dragging you down. Throw it away and start fresh from the current element.
current_sum = max(arr[i], current_sum + arr[i])
max_sum = max(max_sum, current_sum)
That single comparison is the entire algorithm.
Tracing Through the Example
Here is how current_sum and max_sum evolve over [-2, 1, -3, 4, -1, 2, 1, -5, 4]:
flowchart LR
A["i=0\nval=-2\ncur=-2\nmax=-2"] --> B["i=1\nval=1\ncur=1\nmax=1"]
B --> C["i=2\nval=-3\ncur=-2\nmax=1"]
C --> D["i=3\nval=4\ncur=4\nmax=4"]
D --> E["i=4\nval=-1\ncur=3\nmax=4"]
E --> F["i=5\nval=2\ncur=5\nmax=5"]
F --> G["i=6\nval=1\ncur=6\nmax=6"]
G --> H["i=7\nval=-5\ncur=1\nmax=6"]
H --> I["i=8\nval=4\ncur=5\nmax=6"]
At index 3, current_sum would have been -2 + 4 = 2, but starting fresh at 4 is better, so current_sum resets to 4. From there it grows to 6 before the -5 drags it back down.
Clean Implementation
def kadane(arr):
if not arr:
return 0
current_sum = arr[0]
max_sum = arr[0]
for i in range(1, len(arr)):
# Either extend the current subarray or start fresh
current_sum = max(arr[i], current_sum + arr[i])
max_sum = max(max_sum, current_sum)
return max_sum
# Basic test
arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
print(f"Max subarray sum: {kadane(arr)}") # 6
# Edge cases
print(f"All negative: {kadane([-3, -1, -4, -1, -5])}") # -1
print(f"Single element: {kadane([7])}") # 7
print(f"All positive: {kadane([1, 2, 3, 4])}") # 10
Time complexity: O(n) — one pass Space complexity: O(1) — no extra storage
Step-by-Step Trace Version
Seeing the algorithm think helps it click:
def kadane_verbose(arr):
print(f"{'i':>3} {'val':>5} {'cur_sum':>8} {'max_sum':>8} note")
print("-" * 45)
current_sum = arr[0]
max_sum = arr[0]
print(f"{'0':>3} {arr[0]:>5} {current_sum:>8} {max_sum:>8} (seed)")
for i in range(1, len(arr)):
extended = current_sum + arr[i]
fresh = arr[i]
current_sum = max(fresh, extended)
max_sum = max(max_sum, current_sum)
note = "start fresh" if fresh > extended else "extend"
print(f"{i:>3} {arr[i]:>5} {current_sum:>8} {max_sum:>8} {note}")
return max_sum
arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
result = kadane_verbose(arr)
print(f"\nAnswer: {result}")
Finding the Actual Subarray (Not Just the Sum)
Often you need the subarray itself, not only its sum:
def kadane_with_indices(arr):
if not arr:
return 0, 0, 0
current_sum = arr[0]
max_sum = arr[0]
start = end = 0 # best subarray found so far
temp_start = 0 # start of current candidate
for i in range(1, len(arr)):
if arr[i] > current_sum + arr[i]:
# Starting fresh is better
current_sum = arr[i]
temp_start = i
else:
current_sum = current_sum + arr[i]
if current_sum > max_sum:
max_sum = current_sum
start = temp_start
end = i
return max_sum, start, end
arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
total, s, e = kadane_with_indices(arr)
print(f"Max sum: {total}")
print(f"Indices: [{s}, {e}]")
print(f"Subarray: {arr[s:e+1]}")
Edge Cases
def kadane(arr):
if not arr:
return 0
current_sum = max_sum = arr[0]
for x in arr[1:]:
current_sum = max(x, current_sum + x)
max_sum = max(max_sum, current_sum)
return max_sum
# All negative — the "least bad" element wins
print(kadane([-5, -3, -8, -1, -4])) # -1
# Single element
print(kadane([42])) # 42
print(kadane([-7])) # -7
# Zeros in the array
print(kadane([0, -1, 0, -2, 0])) # 0
# Already optimal is entire array
print(kadane([1, 2, 3, 4, 5])) # 15
Real-World Applications
Stock Profit Analysis
The classic framing: arr[i] is the price change on day i. The maximum subarray sum is the best possible profit from buying and selling exactly once.
def best_trade_window(daily_changes):
"""
Find the buy day and sell day that maximise profit.
daily_changes[i] = price[i+1] - price[i]
"""
if not daily_changes:
return 0, -1, -1
current_sum = daily_changes[0]
max_sum = daily_changes[0]
temp_start = 0
start = end = 0
for i in range(1, len(daily_changes)):
if daily_changes[i] > current_sum + daily_changes[i]:
current_sum = daily_changes[i]
temp_start = i
else:
current_sum += daily_changes[i]
if current_sum > max_sum:
max_sum = current_sum
start = temp_start
end = i
buy_day = start
sell_day = end + 1 # sell at close of next day
return max_sum, buy_day, sell_day
prices = [7, 1, 5, 3, 6, 4]
changes = [prices[i+1] - prices[i] for i in range(len(prices)-1)]
profit, buy, sell = best_trade_window(changes)
print(f"Prices: {prices}")
print(f"Changes: {changes}")
print(f"Buy day: {buy} (price={prices[buy]})")
print(f"Sell day: {sell} (price={prices[sell]})")
print(f"Profit: {profit}")
Signal Processing
In audio or sensor data, Kadane’s algorithm finds the segment with the highest cumulative signal strength — useful for detecting bursts of activity:
def find_peak_signal_window(signal):
"""Find the start and end of the strongest signal burst."""
max_sum, start, end = float('-inf'), 0, 0
current_sum = signal[0]
temp_start = 0
max_sum = current_sum
for i in range(1, len(signal)):
if signal[i] > current_sum + signal[i]:
current_sum = signal[i]
temp_start = i
else:
current_sum += signal[i]
if current_sum > max_sum:
max_sum = current_sum
start = temp_start
end = i
return max_sum, start, end
# Simulated sensor readings (positive = above baseline, negative = below)
sensor = [-1, -2, 4, 6, -1, 3, -8, 2, 1]
strength, s, e = find_peak_signal_window(sensor)
print(f"Signal: {sensor}")
print(f"Peak window: indices {s}–{e} → {sensor[s:e+1]}")
print(f"Total strength: {strength}")
Complexity Summary
| Approach | Time | Space | Notes |
|---|---|---|---|
| Brute force | O(n²) | O(1) | Check all subarrays |
| Kadane’s | O(n) | O(1) | Single pass, constant space |
Kadane’s algorithm is a beautiful example of dynamic programming without a table: the only “state” you carry forward is two numbers — current_sum and max_sum.
Sliding Window — Fixed Size
Find the maximum sum of any 3 consecutive elements in an array. The naive way recomputes the sum from scratch for every window. The sliding window technique throws away just the leftmost element and adds the new rightmost one — turning O(n·k) into O(n).
The Core Idea
Imagine a physical window of width k sliding across an array. As it moves one step right:
- Add the element entering from the right
- Remove the element leaving from the left
The sum updates in O(1) per step.
arr = [2, 1, 5, 1, 3, 2], k = 3
Window [2, 1, 5] → sum = 8
[1, 5, 1] → sum = 8 - 2 + 1 = 7 (remove 2, add 1)
[5, 1, 3] → sum = 7 - 1 + 3 = 9 (remove 1, add 3)
[1, 3, 2] → sum = 9 - 5 + 2 = 6 (remove 5, add 2)
Visualising the Window
flowchart LR
subgraph arr["Array: [2, 1, 5, 1, 3, 2] k=3"]
direction LR
W1["Window 1\n[2, 1, 5]\nsum=8"]
W2["Window 2\n[1, 5, 1]\nsum=7"]
W3["Window 3\n[5, 1, 3]\nsum=9 ✓"]
W4["Window 4\n[1, 3, 2]\nsum=6"]
W1 -->|"−2 +1"| W2
W2 -->|"−1 +3"| W3
W3 -->|"−5 +2"| W4
end
Problem 1 — Maximum Sum Subarray of Size k
def max_sum_subarray(arr, k):
n = len(arr)
if n < k:
return None
# Build the first window
window_sum = sum(arr[:k])
max_sum = window_sum
# Slide: add right element, remove left element
for i in range(k, n):
window_sum += arr[i] - arr[i - k]
if window_sum > max_sum:
max_sum = window_sum
return max_sum
arr = [2, 1, 5, 1, 3, 2]
print(f"Array: {arr}")
print(f"Max sum of size 3: {max_sum_subarray(arr, 3)}") # 9
arr2 = [1, 4, 2, 9, 7, 3, 8, 2]
print(f"\nArray: {arr2}")
print(f"Max sum of size 4: {max_sum_subarray(arr2, 4)}") # 27 (9+7+3+8)
Time: O(n) — one pass after the initial window setup Space: O(1) — only three variables
Trace version
def max_sum_trace(arr, k):
n = len(arr)
window_sum = sum(arr[:k])
max_sum = window_sum
best_start = 0
print(f"Initial window {arr[:k]}: sum = {window_sum}")
for i in range(k, n):
removed = arr[i - k]
added = arr[i]
window_sum += added - removed
window_start = i - k + 1
marker = " ← new max" if window_sum > max_sum else ""
print(f"Remove {removed}, add {added} → {arr[window_start:i+1]}: sum = {window_sum}{marker}")
if window_sum > max_sum:
max_sum = window_sum
best_start = window_start
print(f"\nBest window: {arr[best_start:best_start+k]}, sum = {max_sum}")
return max_sum
max_sum_trace([2, 1, 5, 1, 3, 2], k=3)
Problem 2 — Average of Every Window of Size k
def window_averages(arr, k):
n = len(arr)
if n < k:
return []
averages = []
window_sum = sum(arr[:k])
averages.append(round(window_sum / k, 2))
for i in range(k, n):
window_sum += arr[i] - arr[i - k]
averages.append(round(window_sum / k, 2))
return averages
# Daily temperatures over a week, k=3 day rolling average
temps = [22, 24, 21, 26, 28, 25, 27]
avgs = window_averages(temps, k=3)
print("Day temperatures:", temps)
print("3-day rolling avg:", avgs)
# Show paired output
for i, avg in enumerate(avgs):
window = temps[i:i+3]
print(f" Days {i+1}–{i+3}: {window} → avg {avg}°C")
Problem 3 — First Negative Number in Every Window of Size k
def first_negative_in_windows(arr, k):
"""
For each window of size k, report the first negative number.
Report 0 if the window has no negatives.
Uses a deque to track indices of negative numbers.
"""
from collections import deque
n = len(arr)
result = []
neg_indices = deque() # stores indices of negatives in current window
for i in range(n):
# Add current element if it is negative
if arr[i] < 0:
neg_indices.append(i)
# Remove indices that have slid out of the window
if neg_indices and neg_indices[0] < i - k + 1:
neg_indices.popleft()
# Record result once the first full window is formed
if i >= k - 1:
result.append(arr[neg_indices[0]] if neg_indices else 0)
return result
arr = [-3, -1, 2, -4, 5, 3, -2]
k = 3
results = first_negative_in_windows(arr, k)
print(f"Array: {arr}, k = {k}\n")
for i, first_neg in enumerate(results):
window = arr[i:i+k]
msg = first_neg if first_neg != 0 else "none"
print(f"Window {window}: first negative = {msg}")
Comparing All Three in One Run
def sliding_window_summary(arr, k):
from collections import deque
n = len(arr)
max_sum = float('-inf')
window_sum = sum(arr[:k])
current_sum = window_sum
neg_indices = deque()
for j in range(k):
if arr[j] < 0:
neg_indices.append(j)
averages = [round(window_sum / k, 2)]
max_sum = window_sum
firsts = []
if neg_indices:
firsts.append(arr[neg_indices[0]])
else:
firsts.append(0)
for i in range(k, n):
window_sum += arr[i] - arr[i - k]
if arr[i] < 0:
neg_indices.append(i)
if neg_indices and neg_indices[0] < i - k + 1:
neg_indices.popleft()
averages.append(round(window_sum / k, 2))
max_sum = max(max_sum, window_sum)
firsts.append(arr[neg_indices[0]] if neg_indices else 0)
print(f"Array: {arr}, k = {k}")
print(f"Max sum of any window: {max_sum}")
print(f"Window averages: {averages}")
print(f"First negative per window: {firsts}")
sliding_window_summary([2, -1, 5, 1, -3, 2], k=3)
Real-World Applications
Moving Average in Finance
Financial dashboards smooth noisy price data with a rolling average. A 20-day or 50-day moving average is computed exactly like Problem 2 above — slide a fixed-size window over daily closing prices.
def moving_average(prices, window=5):
avgs = []
total = sum(prices[:window])
avgs.append(total / window)
for i in range(window, len(prices)):
total += prices[i] - prices[i - window]
avgs.append(round(total / window, 2))
return avgs
# Mock closing prices for 10 days
prices = [150, 152, 149, 153, 158, 155, 160, 162, 157, 165]
ma5 = moving_average(prices, window=5)
print("Prices: ", prices)
print("5-day MA: ", ma5)
Network Throughput Monitoring
A network monitor tracks bytes received per second. A sliding window gives the average throughput over the last k seconds, smoothing out momentary spikes.
def throughput_monitor(bytes_per_second, window_secs=3):
"""Return max observed throughput and rolling average."""
avg = moving_average(bytes_per_second, window=window_secs)
peak = max(avg)
return avg, peak
def moving_average(data, window):
avgs = []
total = sum(data[:window])
avgs.append(round(total / window, 1))
for i in range(window, len(data)):
total += data[i] - data[i - window]
avgs.append(round(total / window, 1))
return avgs
# MB/s sampled every second over 10 seconds
samples = [12, 15, 14, 20, 35, 30, 10, 8, 9, 11]
avgs, peak = throughput_monitor(samples, window_secs=3)
print("Raw MB/s: ", samples)
print("3-sec rolling: ", avgs)
print(f"Peak 3-sec avg: {peak} MB/s")
Complexity Summary
| Approach | Time | Space | Notes |
|---|---|---|---|
| Recompute each window | O(n·k) | O(1) | Nested loop — slow for large k |
| Sliding window | O(n) | O(1) | Add right, remove left |
| With deque (negatives) | O(n) | O(k) | Deque holds at most k entries |
The fixed-size sliding window is one of the easiest ways to cut an O(n·k) solution down to O(n) — and once you see it, you will recognise it everywhere.
Sliding Window — Variable Size
Find the smallest subarray with a sum ≥ 7. Unlike the fixed-size window, this window has no fixed width — it grows when you need more and shrinks when you have enough. Two pointers (left and right) mark its boundaries, and together they do the job in O(n).
The Core Idea
Use two pointers, both starting at index 0:
- Expand right — move
rightforward to grow the window and add more to the sum. - Shrink left — once the window satisfies the condition, try to make it smaller by moving
leftforward. - Record the best answer whenever the condition is satisfied.
Each pointer moves at most n steps, so the total work is O(n) even though it looks like two nested operations.
arr = [2, 3, 1, 2, 4, 3], target = 7
left=0, right grows until sum ≥ 7:
[2, 3, 1, 2] sum=8 ≥ 7 → length 4, try shrinking
[3, 1, 2] sum=6 < 7 → can't shrink, expand again
[3, 1, 2, 4] sum=10 ≥ 7 → length 4, try shrinking
[1, 2, 4] sum=7 ≥ 7 → length 3, try shrinking
[2, 4] sum=6 < 7 → can't shrink
...
Answer: 2 (subarray [4, 3])
Visualising Expand and Shrink
flowchart TD
Start["left=0, right=0\nwindow_sum=0\nmin_len=∞"] --> Expand
Expand["Expand: right++\nadd arr[right] to sum"] --> Check
Check{"sum ≥ target?"}
Check -->|No| Expand
Check -->|Yes| Record["Record window length\nif smaller than min_len"]
Record --> Shrink["Shrink: subtract arr[left]\nleft++"]
Shrink --> StillOK{"sum still ≥ target?"}
StillOK -->|Yes| Record
StillOK -->|No| Done{"right at end?"}
Done -->|No| Expand
Done -->|Yes| Answer["Return min_len"]
Problem 1 — Smallest Subarray with Sum ≥ Target
def min_subarray_len(arr, target):
left = 0
window_sum = 0
min_len = float('inf')
for right in range(len(arr)):
window_sum += arr[right] # expand
while window_sum >= target: # shrink while valid
min_len = min(min_len, right - left + 1)
window_sum -= arr[left]
left += 1
return min_len if min_len != float('inf') else 0
arr = [2, 3, 1, 2, 4, 3]
print(f"Array: {arr}, target = 7")
print(f"Smallest length: {min_subarray_len(arr, 7)}") # 2 ([4,3])
arr2 = [1, 1, 1, 1, 1, 1, 1]
print(f"\nArray: {arr2}, target = 11")
print(f"Smallest length: {min_subarray_len(arr2, 11)}") # 0 (impossible)
Time: O(n) — left and right each travel the array once
Space: O(1)
Trace version
def min_subarray_trace(arr, target):
left = 0
window_sum = 0
min_len = float('inf')
best = None
print(f"Target = {target}\n")
for right in range(len(arr)):
window_sum += arr[right]
print(f"Expand right={right} (+{arr[right]}) window={arr[left:right+1]} sum={window_sum}")
while window_sum >= target:
length = right - left + 1
if length < min_len:
min_len = length
best = arr[left:right+1][:]
print(f" Shrink left={left} (−{arr[left]}) len={length} ← {'new best!' if length == min_len else ''}")
window_sum -= arr[left]
left += 1
print(f"\nSmallest subarray: {best}, length = {min_len}")
return min_len
min_subarray_trace([2, 3, 1, 2, 4, 3], target=7)
Problem 2 — Longest Substring Without Repeating Characters
The window now tracks characters instead of a numeric sum. We use a set to know what is currently inside the window.
def longest_unique_substring(s):
char_set = set()
left = 0
max_len = 0
best_start = 0
for right in range(len(s)):
# Shrink until the duplicate is gone
while s[right] in char_set:
char_set.remove(s[left])
left += 1
char_set.add(s[right])
if right - left + 1 > max_len:
max_len = right - left + 1
best_start = left
return max_len, s[best_start:best_start + max_len]
tests = ["abcabcbb", "bbbbb", "pwwkew", "dvdf", ""]
for t in tests:
length, substr = longest_unique_substring(t)
print(f'"{t}" → length={length} window="{substr}"')
Trace to see the window in action
def longest_unique_trace(s):
char_set = set()
left = 0
max_len = 0
for right in range(len(s)):
while s[right] in char_set:
print(f" Shrink: remove '{s[left]}'")
char_set.remove(s[left])
left += 1
char_set.add(s[right])
current = s[left:right+1]
new_best = " ← new best" if len(current) > max_len else ""
print(f"Add '{s[right]}' window='{current}'{new_best}")
max_len = max(max_len, len(current))
print(f"\nMax length: {max_len}")
longest_unique_trace("abcabcbb")
Problem 3 — Longest Subarray with at Most k Distinct Characters
Generalise: allow at most k different characters. Use a dictionary to count each character’s frequency in the window.
def longest_k_distinct(s, k):
from collections import defaultdict
char_count = defaultdict(int)
left = 0
max_len = 0
best_start = 0
for right in range(len(s)):
char_count[s[right]] += 1
# Too many distinct chars — shrink
while len(char_count) > k:
char_count[s[left]] -= 1
if char_count[s[left]] == 0:
del char_count[s[left]]
left += 1
if right - left + 1 > max_len:
max_len = right - left + 1
best_start = left
return max_len, s[best_start:best_start + max_len]
tests = [
("eceba", 2),
("aa", 1),
("aabbcc", 2),
("aabbcc", 3),
]
for s, k in tests:
length, substr = longest_k_distinct(s, k)
print(f'"{s}" k={k} → length={length} "{substr}"')
Comparing Variable vs Fixed Windows
def demo_both():
arr = [2, 3, 1, 2, 4, 3]
# Fixed: max sum of exactly k=3 elements
k = 3
window_sum = sum(arr[:k])
max_sum = window_sum
for i in range(k, len(arr)):
window_sum += arr[i] - arr[i - k]
max_sum = max(max_sum, window_sum)
print(f"Fixed k={k}: max sum = {max_sum}")
# Variable: smallest subarray with sum >= 7
left = window_sum_v = 0
min_len = float('inf')
for right in range(len(arr)):
window_sum_v += arr[right]
while window_sum_v >= 7:
min_len = min(min_len, right - left + 1)
window_sum_v -= arr[left]
left += 1
print(f"Variable target=7: min length = {min_len}")
demo_both()
Real-World Applications
Network Rate Limiting
A rate limiter allows at most N requests in any rolling window. As new requests arrive, the window grows; as old ones expire, it shrinks. Variable window size is the natural fit.
def max_requests_in_window(timestamps_ms, window_ms):
"""
Find the maximum number of requests in any window of duration window_ms.
timestamps_ms: sorted list of request arrival times in milliseconds.
"""
left = 0
max_count = 0
for right in range(len(timestamps_ms)):
# Shrink while window exceeds allowed duration
while timestamps_ms[right] - timestamps_ms[left] > window_ms:
left += 1
max_count = max(max_count, right - left + 1)
return max_count
# Request timestamps (ms since start)
requests = [0, 100, 200, 300, 350, 400, 900, 1000]
window = 500 # 500 ms rolling window
peak = max_requests_in_window(requests, window)
print(f"Timestamps (ms): {requests}")
print(f"Window size: {window} ms")
print(f"Peak requests: {peak}")
Longest User Session Without Logout
Given a log of page-view events, find the longest streak of activity where the user never went idle for more than gap seconds.
def longest_active_session(event_times, max_gap):
"""
Find the length of the longest session where no gap between
consecutive events exceeds max_gap seconds.
"""
if not event_times:
return 0
left = 0
max_events = 1
for right in range(1, len(event_times)):
# A gap larger than allowed breaks the session
if event_times[right] - event_times[right - 1] > max_gap:
left = right # start a new session
max_events = max(max_events, right - left + 1)
return max_events
# Event timestamps in seconds
events = [0, 10, 25, 30, 80, 85, 90, 200, 210, 215, 220]
max_gap = 60 # seconds
length = longest_active_session(events, max_gap)
print(f"Events (s): {events}")
print(f"Max gap: {max_gap}s")
print(f"Longest session: {length} events")
Complexity Summary
| Problem | Time | Space | Notes |
|---|---|---|---|
| Min subarray sum ≥ target | O(n) | O(1) | Each pointer moves at most n steps |
| Longest substring no repeats | O(n) | O(1) | Set holds at most 26 chars |
| Longest k-distinct substring | O(n) | O(k) | Dict holds at most k entries |
The variable sliding window is powerful because the same two-pointer skeleton solves wildly different problems — just change what “condition met” means.
Two Pointers
Two detectives searching a sorted list from opposite ends — one starting at the smallest value, one at the largest. They compare, decide who moves inward, and meet in the middle in O(n). That is the two-pointer pattern: elegant, fast, and used everywhere from database joins to DNA sequencing.
The Core Idea
Place one pointer at the start (left = 0) and one at the end (right = n - 1). Each step, inspect both pointers and move one inward based on a condition. Because the array is sorted, moving a pointer always gives you meaningful information about what to try next.
Sorted: [1, 3, 5, 7, 9, 11], target = 12
left→1 right→11 sum=12 ✓ found!
Sorted: [1, 3, 5, 7, 9, 11], target = 10
left→1 right→11 sum=12 too big → right moves left
left→1 right→9 sum=10 ✓ found!
Visualising the Converging Pointers
flowchart LR
subgraph sorted["Sorted array — Two Sum example"]
direction LR
A["[1]"] --- B["[3]"] --- C["[5]"] --- D["[7]"] --- E["[9]"] --- F["[11]"]
L["left ↑\nstarts here"] -.-> A
R["right ↑\nstarts here"] -.-> F
end
subgraph logic["Decision at each step"]
direction TD
S{"arr[left]+arr[right]\nvs target"}
S -->|"== target"| Found["Found! Return indices"]
S -->|"< target"| MoveL["Sum too small\nleft++"]
S -->|"> target"| MoveR["Sum too big\nright--"]
end
Problem 1 — Two Sum on a Sorted Array
def two_sum_sorted(arr, target):
left = 0
right = len(arr) - 1
while left < right:
current = arr[left] + arr[right]
if current == target:
return left, right
elif current < target:
left += 1 # need a bigger sum → move left pointer right
else:
right -= 1 # need a smaller sum → move right pointer left
return None # no pair found
arr = [1, 3, 5, 7, 9, 11]
print(f"Array: {arr}")
for target in [12, 10, 20, 4]:
result = two_sum_sorted(arr, target)
if result:
i, j = result
print(f" target={target:2d} → [{i}]+[{j}] = {arr[i]}+{arr[j]}")
else:
print(f" target={target:2d} → no pair found")
Time: O(n) — each pointer moves at most n steps Space: O(1)
Trace version
def two_sum_trace(arr, target):
left, right = 0, len(arr) - 1
print(f"Array: {arr}, target = {target}\n")
print(f"{'left':>5} {'right':>6} {'sum':>5} action")
print("-" * 35)
while left < right:
s = arr[left] + arr[right]
if s == target:
print(f"{left:>5} {right:>6} {s:>5} FOUND: {arr[left]}+{arr[right]}")
return left, right
elif s < target:
print(f"{left:>5} {right:>6} {s:>5} too small → left++")
left += 1
else:
print(f"{left:>5} {right:>6} {s:>5} too big → right--")
right -= 1
print("No pair found.")
return None
two_sum_trace([1, 3, 5, 7, 9, 11], target=10)
Problem 2 — Remove Duplicates from a Sorted Array In-Place
Two pointers travelling in the same direction: slow marks the frontier of unique elements; fast scans ahead for the next new value.
def remove_duplicates(arr):
if not arr:
return 0
slow = 0 # last position of unique elements
for fast in range(1, len(arr)):
if arr[fast] != arr[slow]:
slow += 1
arr[slow] = arr[fast]
return slow + 1 # length of the deduplicated prefix
# Modify in-place and return new length
arr = [1, 1, 2, 3, 3, 3, 4, 5, 5]
print(f"Before: {arr}")
k = remove_duplicates(arr)
print(f"After: {arr[:k]}")
print(f"Unique count: {k}")
arr2 = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
k2 = remove_duplicates(arr2)
print(f"\nBefore: {[0,0,1,1,1,2,2,3,3,4]}")
print(f"After: {arr2[:k2]}")
How the two pointers move
def remove_duplicates_trace(arr):
slow = 0
print(f"{'fast':>5} {'slow':>5} arr[fast] action")
print("-" * 42)
for fast in range(1, len(arr)):
if arr[fast] != arr[slow]:
slow += 1
arr[slow] = arr[fast]
action = f"NEW → write {arr[slow]} at pos {slow}"
else:
action = f"DUP → skip"
print(f"{fast:>5} {slow:>5} {arr[fast]:>9} {action}")
return slow + 1
arr = [1, 1, 2, 3, 3, 4]
k = remove_duplicates_trace(arr)
print(f"\nResult: {arr[:k]}")
Problem 3 — Container With Most Water
Given heights of vertical lines at each index, find two lines that together with the x-axis hold the most water.
def max_water(heights):
left = 0
right = len(heights) - 1
best = 0
best_pair = (0, 0)
while left < right:
width = right - left
height = min(heights[left], heights[right])
water = width * height
if water > best:
best = water
best_pair = (left, right)
# Move the shorter wall — moving the taller one can only hurt
if heights[left] < heights[right]:
left += 1
else:
right -= 1
return best, best_pair
heights = [1, 8, 6, 2, 5, 4, 8, 3, 7]
water, (l, r) = max_water(heights)
print(f"Heights: {heights}")
print(f"Max water: {water}")
print(f"Between indices {l} (h={heights[l]}) and {r} (h={heights[r]})")
Why move the shorter wall? The water level is capped by the shorter line. Making the taller line even taller changes nothing. Moving the shorter line is the only way to potentially increase the water level.
Problem 4 — Three Sum (Find All Triplets Summing to Zero)
Fix one element, then use two pointers on the rest:
def three_sum(nums):
nums.sort()
result = []
n = len(nums)
for i in range(n - 2):
# Skip duplicate values at the fixed position
if i > 0 and nums[i] == nums[i - 1]:
continue
left = i + 1
right = n - 1
while left < right:
total = nums[i] + nums[left] + nums[right]
if total == 0:
result.append([nums[i], nums[left], nums[right]])
# Skip duplicates at both pointers
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
left += 1
right -= 1
elif total < 0:
left += 1
else:
right -= 1
return result
nums = [-1, 0, 1, 2, -1, -4]
print(f"Input: {nums}")
triplets = three_sum(nums)
print(f"Triplets summing to 0:")
for t in triplets:
print(f" {t}")
nums2 = [0, 0, 0, 0]
print(f"\nInput: {nums2}")
print(f"Triplets: {three_sum(nums2)}")
Time: O(n²) — outer loop O(n), inner two-pointer O(n) Space: O(1) excluding output
Palindrome Checking
Two pointers from both ends, comparing characters as they close in:
def is_palindrome(s):
# Normalise: keep only alphanumeric, lowercase
cleaned = ''.join(c.lower() for c in s if c.isalnum())
left, right = 0, len(cleaned) - 1
while left < right:
if cleaned[left] != cleaned[right]:
return False
left += 1
right -= 1
return True
tests = [
"racecar",
"A man, a plan, a canal: Panama",
"hello",
"Was it a car or a cat I saw?",
"",
]
for t in tests:
print(f'"{t}" → {is_palindrome(t)}')
Real-World Applications
Database Merge Joins
When two sorted tables are joined on a common column, a database engine walks both with one pointer each — no nested loops needed. This is the merge step in merge sort, generalised to relational data.
def merge_join(table_a, table_b):
"""
Given two sorted lists of (id, value) tuples, find all matching ids.
Simulates a database merge join.
"""
i, j = 0, 0
matches = []
while i < len(table_a) and j < len(table_b):
id_a, val_a = table_a[i]
id_b, val_b = table_b[j]
if id_a == id_b:
matches.append((id_a, val_a, val_b))
i += 1
j += 1
elif id_a < id_b:
i += 1
else:
j += 1
return matches
users = [(1, "Alice"), (3, "Bob"), (5, "Carol"), (7, "Dave")]
orders = [(2, "laptop"), (3, "phone"), (5, "tablet"), (6, "keyboard")]
joined = merge_join(users, orders)
print(f"{'ID':>3} {'User':>8} {'Order':>10}")
print("-" * 28)
for uid, user, order in joined:
print(f"{uid:>3} {user:>8} {order:>10}")
DNA Sequence Alignment
Checking if one DNA strand is the reverse complement of another is a classic two-pointer palindrome check:
def is_reverse_complement(strand_a, strand_b):
"""
In DNA: A pairs with T, C pairs with G.
strand_b is the reverse complement of strand_a if
reading strand_b backwards gives the complement of strand_a.
"""
complement = {'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C'}
if len(strand_a) != len(strand_b):
return False
left = 0
right = len(strand_b) - 1
while left < len(strand_a):
if strand_a[left] != complement.get(strand_b[right], '?'):
return False
left += 1
right -= 1
return True
print(is_reverse_complement("ATCG", "CGAT")) # True
print(is_reverse_complement("AAAA", "TTTT")) # True
print(is_reverse_complement("ATCG", "ATCG")) # False
Complexity Summary
| Problem | Time | Space | Notes |
|---|---|---|---|
| Two Sum (sorted) | O(n) | O(1) | Converging pointers |
| Remove duplicates in-place | O(n) | O(1) | Same-direction fast/slow pointers |
| Container with most water | O(n) | O(1) | Always move the shorter wall |
| Three Sum | O(n²) | O(1) | Fix + two-pointer inner loop |
| Palindrome check | O(n) | O(1) | Converging, compare and close in |
The two-pointer pattern works because sorted order lets each pointer movement eliminate a range of possibilities. Recognise when your input is sorted (or can be sorted cheaply) and ask whether two pointers can replace a nested loop.
Prefix Sums
Answer “what is the sum of elements from index 3 to 7?” in O(1) — after a one-time O(n) setup. Prefix sums trade a little memory for the ability to answer unlimited range queries instantly. Once you understand the trick, you will see it in financial dashboards, databases, image processing, and competitive programming alike.
The Core Idea
Build a prefix array where prefix[i] holds the sum of all elements from index 0 up to and including index i.
arr = [3, 1, 4, 1, 5, 9, 2, 6]
index = 0 1 2 3 4 5 6 7
prefix = [3, 4, 8, 9, 14, 23, 25, 31]
Now, the sum of any range [l, r] is:
range_sum(l, r) = prefix[r] - prefix[l-1] (when l > 0)
range_sum(0, r) = prefix[r]
From Array to Prefix Array to Range Query
flowchart TD
A["Original array\n[3, 1, 4, 1, 5, 9, 2, 6]"] --> B
B["Build prefix array — O(n)\nprefix[i] = prefix[i-1] + arr[i]\n[3, 4, 8, 9, 14, 23, 25, 31]"] --> C
C{"Range query\nsum(l, r)?"} --> D
D["prefix[r] − prefix[l−1]\nO(1) per query"]
D --> E["sum(2,5) = prefix[5]−prefix[1]\n= 23 − 4 = 19\n✓ (4+1+5+9=19)"]
Problem 1 — Range Sum Query (The Classic)
def build_prefix(arr):
prefix = [0] * len(arr)
prefix[0] = arr[0]
for i in range(1, len(arr)):
prefix[i] = prefix[i - 1] + arr[i]
return prefix
def range_sum(prefix, l, r):
if l == 0:
return prefix[r]
return prefix[r] - prefix[l - 1]
arr = [3, 1, 4, 1, 5, 9, 2, 6]
prefix = build_prefix(arr)
print(f"Array: {arr}")
print(f"Prefix: {prefix}\n")
queries = [(0, 7), (2, 5), (3, 3), (0, 3), (5, 7)]
for l, r in queries:
total = range_sum(prefix, l, r)
print(f"sum({l},{r}) = {arr[l:r+1]} → {total}")
Time: O(n) to build, O(1) per query Space: O(n) for the prefix array
Why this beats recomputing each time
import time
import random
arr = [random.randint(1, 100) for _ in range(10_000)]
prefix = build_prefix(arr)
queries = [(random.randint(0, 4999), random.randint(5000, 9999)) for _ in range(10_000)]
def build_prefix(a):
p = [0] * len(a)
p[0] = a[0]
for i in range(1, len(a)):
p[i] = p[i-1] + a[i]
return p
# Brute force: O(n) per query
t0 = time.time()
for l, r in queries:
_ = sum(arr[l:r+1])
brute_ms = (time.time() - t0) * 1000
# Prefix sums: O(1) per query
prefix = build_prefix(arr)
t1 = time.time()
for l, r in queries:
_ = prefix[r] - (prefix[l-1] if l > 0 else 0)
prefix_ms = (time.time() - t1) * 1000
print(f"10 000 queries on 10 000-element array:")
print(f" Brute force: {brute_ms:.1f} ms")
print(f" Prefix sums: {prefix_ms:.1f} ms")
print(f" Speedup: {brute_ms/max(prefix_ms,0.001):.0f}×")
Problem 2 — Number of Subarrays with Sum Equal to k
This is where prefix sums become genuinely clever. We want to count subarrays whose elements sum to exactly k.
The insight: a subarray arr[l..r] sums to k if and only if prefix[r] - prefix[l-1] == k, which means prefix[l-1] == prefix[r] - k. Use a hashmap to count how many times each prefix sum has appeared so far.
from collections import defaultdict
def count_subarrays_with_sum(arr, k):
count = 0
prefix_sum = 0
freq = defaultdict(int)
freq[0] = 1 # empty prefix has sum 0
for x in arr:
prefix_sum += x
# How many previous prefix sums equal (current - k)?
count += freq[prefix_sum - k]
freq[prefix_sum] += 1
return count
arr = [1, 1, 1]
print(f"Array: {arr}, k=2 → {count_subarrays_with_sum(arr, 2)} subarrays")
# Subarrays: [1,1] at (0,1) and (1,2) → 2
arr2 = [1, 2, 3]
print(f"Array: {arr2}, k=3 → {count_subarrays_with_sum(arr2, 3)} subarrays")
# [3] at index 2, and [1,2] at (0,1) → 2
arr3 = [3, 4, 7, 2, -3, 1, 4, 2]
print(f"Array: {arr3}, k=7 → {count_subarrays_with_sum(arr3, 7)} subarrays")
Trace to see the hashmap fill up
from collections import defaultdict
def count_subarrays_trace(arr, k):
count = 0
prefix_sum = 0
freq = defaultdict(int)
freq[0] = 1
print(f"k = {k}\n")
print(f"{'i':>3} {'arr[i]':>6} {'prefix':>7} {'need':>5} {'found':>6} count")
print("-" * 50)
for i, x in enumerate(arr):
prefix_sum += x
need = prefix_sum - k
found = freq[need]
count += found
freq[prefix_sum] += 1
print(f"{i:>3} {x:>6} {prefix_sum:>7} {need:>5} {found:>6} {count}")
print(f"\nTotal subarrays with sum={k}: {count}")
return count
count_subarrays_trace([1, 2, 3, 0, 3], k=3)
Problem 3 — Product of Array Except Self
For each index i, compute the product of all other elements without division and in O(n).
The trick: build a left prefix product (product of everything to the left of i) and a right suffix product (product of everything to the right). Multiply them together.
def product_except_self(arr):
n = len(arr)
result = [1] * n
# Pass 1: result[i] = product of arr[0..i-1]
prefix = 1
for i in range(n):
result[i] = prefix
prefix *= arr[i]
# Pass 2: multiply in product of arr[i+1..n-1]
suffix = 1
for i in range(n - 1, -1, -1):
result[i] *= suffix
suffix *= arr[i]
return result
arr = [1, 2, 3, 4]
print(f"Array: {arr}")
print(f"Product: {product_except_self(arr)}")
# [24, 12, 8, 6]
# index 0: 2*3*4=24, index 1: 1*3*4=12, etc.
arr2 = [-1, 1, 0, -3, 3]
print(f"\nArray: {arr2}")
print(f"Product: {product_except_self(arr2)}")
Visualising the two passes
def product_except_self_trace(arr):
n = len(arr)
result = [1] * n
prefix = 1
print("Left pass (prefix products):")
for i in range(n):
result[i] = prefix
prefix *= arr[i]
print(f" i={i} result[{i}]={result[i]} prefix becomes {prefix}")
suffix = 1
print("\nRight pass (multiply suffix):")
for i in range(n - 1, -1, -1):
result[i] *= suffix
suffix *= arr[i]
print(f" i={i} result[{i}]={result[i]} suffix becomes {suffix}")
print(f"\nFinal: {result}")
return result
product_except_self_trace([1, 2, 3, 4])
Real-World Applications
Cumulative Sales Totals
Every business dashboard shows “sales from date A to date B”. Build the prefix once at report-generation time; every date-range query is then O(1).
def build_prefix(arr):
p = [0] * len(arr)
p[0] = arr[0]
for i in range(1, len(arr)):
p[i] = p[i-1] + arr[i]
return p
def range_sum(prefix, l, r):
return prefix[r] - (prefix[l-1] if l > 0 else 0)
# Daily sales for January (31 days, indexed 0–30)
daily_sales = [
120, 95, 140, 200, 180, 160, 175, # week 1
130, 145, 190, 210, 170, 155, 165, # week 2
90, 100, 220, 195, 185, 175, 160, # week 3
140, 155, 200, 180, 170, 165, 175, # week 4
190, 195, 210 # final days
]
prefix = build_prefix(daily_sales)
print(f"Week 1 total: ${range_sum(prefix, 0, 6):,}")
print(f"Week 2 total: ${range_sum(prefix, 7, 13):,}")
print(f"Week 3 total: ${range_sum(prefix, 14, 20):,}")
print(f"Full January: ${range_sum(prefix, 0, 30):,}")
print(f"Days 10–20: ${range_sum(prefix, 10, 20):,}")
2D Prefix Sums — Image Integral (Computer Vision)
The same idea extends to two dimensions. The integral image (or summed-area table) is used in computer vision to compute the sum of any rectangular region of pixel values in O(1), enabling real-time face detection (Viola-Jones algorithm).
def build_2d_prefix(grid):
rows = len(grid)
cols = len(grid[0])
prefix = [[0] * (cols + 1) for _ in range(rows + 1)]
for r in range(1, rows + 1):
for c in range(1, cols + 1):
prefix[r][c] = (
grid[r-1][c-1]
+ prefix[r-1][c]
+ prefix[r][c-1]
- prefix[r-1][c-1]
)
return prefix
def rect_sum(prefix, r1, c1, r2, c2):
"""Sum of rectangle from (r1,c1) to (r2,c2), inclusive, 0-indexed."""
return (
prefix[r2+1][c2+1]
- prefix[r1][c2+1]
- prefix[r2+1][c1]
+ prefix[r1][c1]
)
# 5x5 grayscale pixel values (0-255)
image = [
[10, 20, 30, 40, 50],
[15, 25, 35, 45, 55],
[20, 30, 40, 50, 60],
[25, 35, 45, 55, 65],
[30, 40, 50, 60, 70],
]
prefix = build_2d_prefix(image)
# Sum of a 3x3 region starting at (1,1)
total = rect_sum(prefix, 1, 1, 3, 3)
print(f"Sum of rectangle (1,1)→(3,3): {total}")
# Verify by brute force
brute = sum(image[r][c] for r in range(1, 4) for c in range(1, 4))
print(f"Brute force verification: {brute}")
print(f"Match: {total == brute}")
# Full image sum in O(1)
print(f"Full image sum: {rect_sum(prefix, 0, 0, 4, 4)}")
Complexity Summary
| Problem | Build | Query | Space |
|---|---|---|---|
| Range sum query | O(n) | O(1) | O(n) |
| Subarrays with sum = k (hashmap) | O(n) | — | O(n) |
| Product except self | O(n) | — | O(1)* |
| 2D range sum (integral image) | O(n·m) | O(1) | O(n·m) |
* Two extra O(1) variables for prefix/suffix; result array is required output, not overhead.
Prefix sums embody a core engineering trade-off: spend O(n) time and O(n) space once, then answer queries in O(1) forever. Whenever you find yourself recomputing the same running total across many queries, ask whether a prefix array can replace the loop.
Array Problems
Practice problems for the Arrays section. Each problem includes multiple approaches, from brute force to optimal.
| # | Problem | Difficulty |
|---|---|---|
| 1 | Max Consecutive Ones | Easy |
| 2 | Remove Element | Easy |
Max Consecutive Ones
Difficulty: Easy Source: NeetCode
Problem
Given a binary array
nums, return the maximum number of consecutive1s in the array.Example 1: Input:
nums = [1, 1, 0, 1, 1, 1]Output:3Example 2: Input:
nums = [1, 0, 1, 1, 0, 1]Output:2Constraints:
1 <= nums.length <= 10^5nums[i]is either0or1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — understanding how to traverse and access array elements sequentially
- Basic Iteration — using loops to scan through elements and track state with counters
1. Brute Force
Intuition
For each position in the array, count how many consecutive 1s start from that position. Scan forward until hitting a 0 or the end of the array, then track the maximum count seen. This straightforward approach checks every possible starting position.
Algorithm
- Initialize
res = 0to track the maximum consecutive ones. - For each starting index
i:- Initialize a counter
cnt = 0. - Scan forward from
iwhile the current element is1, incrementingcnt. - Stop when encountering a
0or reaching the end. - Update
res = max(res, cnt).
- Initialize a counter
- Return
res.
Solution
def findMaxConsecutiveOnes(nums):
n, res = len(nums), 0
for i in range(n):
cnt = 0
for j in range(i, n):
if nums[j] == 0:
break
cnt += 1
res = max(res, cnt)
return res
print(findMaxConsecutiveOnes([1, 1, 0, 1, 1, 1])) # 3
print(findMaxConsecutiveOnes([1, 0, 1, 1, 0, 1])) # 2
Complexity
- Time:
O(n²) - Space:
O(1)
2. Iteration — I
Intuition
Only one pass through the array is needed. Maintain a running count of consecutive 1s. When we see a 1, increment the count. When we see a 0, compare the current count with the maximum, then reset to 0. After the loop, do one final comparison — the longest sequence might end at the last element.
Algorithm
- Initialize
res = 0andcnt = 0. - Iterate through each element in the array:
- If the element is
0: updateres = max(res, cnt), then resetcnt = 0. - If the element is
1: incrementcnt.
- If the element is
- Return
max(cnt, res)— handles sequences that end at the last element.
Solution
def findMaxConsecutiveOnes(nums):
res = cnt = 0
for num in nums:
if num == 0:
res = max(res, cnt)
cnt = 0
else:
cnt += 1
return max(cnt, res)
print(findMaxConsecutiveOnes([1, 1, 0, 1, 1, 1])) # 3
print(findMaxConsecutiveOnes([1, 0, 1, 1, 0, 1])) # 2
Complexity
- Time:
O(n) - Space:
O(1)
3. Iteration — II
Intuition
Simplify the logic by updating the maximum on every step. If we see a 1, increment the count; otherwise reset it to 0. Updating res after each element eliminates the need for a final comparison after the loop.
Algorithm
- Initialize
res = 0andcnt = 0. - For each element in the array:
- If the element is
1: incrementcnt. - Otherwise: set
cnt = 0. - Update
res = max(res, cnt).
- If the element is
- Return
res.
Solution
def findMaxConsecutiveOnes(nums):
res = cnt = 0
for num in nums:
cnt += 1 if num else -cnt
res = max(res, cnt)
return res
print(findMaxConsecutiveOnes([1, 1, 0, 1, 1, 1])) # 3
print(findMaxConsecutiveOnes([1, 0, 1, 1, 0, 1])) # 2
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Forgetting the final comparison.
When using Iteration — I, the longest sequence might end at the last element. If you only update res when hitting a 0, that final run is never captured. Always compare cnt with res after the loop ends — or use Iteration — II which updates res on every step.
Resetting the counter to 1 instead of 0.
When a 0 is encountered, cnt must reset to 0. The next 1 will increment it to 1. Resetting to 1 skips that increment and causes an off-by-one error.
Remove Element
Difficulty: Easy Source: NeetCode
Problem
Given an integer array
numsand an integerval, remove all occurrences ofvalinnumsin-place. The order of the elements may be changed. Returnk— the number of elements innumsthat are not equal toval.The first
kelements ofnumsmust contain the elements which are not equal toval. The remaining elements and the size of the array do not matter.Example 1: Input:
nums = [3, 2, 2, 3],val = 3Output:2,nums = [2, 2, _, _]Example 2: Input:
nums = [0, 1, 2, 2, 3, 0, 4, 2],val = 2Output:5,nums = [0, 1, 3, 0, 4, _, _, _]Constraints:
0 <= nums.length <= 1000 <= nums[i] <= 500 <= val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — understanding in-place modification and index-based access
- Two Pointers — using separate read and write indices to process an array in a single pass
1. Brute Force
Intuition
When an element equal to val is found, shift every element after it one position to the left to fill the gap, then shrink the logical length by one. Repeat until the full array has been scanned. This mimics how a naive deletion from a static array works.
Algorithm
- Set
k = len(nums)as the logical length. - Set
i = 0. Whilei < k:- If
nums[i] == val, shift all elements fromi+1tok-1one position left, then decrementk. - Otherwise, increment
i.
- If
- Return
k.
Solution
def removeElement(nums, val):
k = len(nums)
i = 0
while i < k:
if nums[i] == val:
for j in range(i + 1, k):
nums[j - 1] = nums[j]
k -= 1
else:
i += 1
return k
nums = [3, 2, 2, 3]
k = removeElement(nums, 3)
print(k, nums[:k]) # 2 [2, 2]
nums = [0, 1, 2, 2, 3, 0, 4, 2]
k = removeElement(nums, 2)
print(k, nums[:k]) # 5 [0, 1, 3, 0, 4]
Complexity
- Time:
O(n²) - Space:
O(1)
2. Two Pointers
Intuition
Use two pointers: a write pointer k that tracks where the next kept element should go, and a read pointer that iterates through every element. Whenever the current element is not val, copy it to position k and advance k. Elements equal to val are simply skipped. One pass through the array is enough.
Algorithm
- Initialize
k = 0as the write pointer. - For each element
numinnums:- If
num != val: writenumtonums[k], then incrementk.
- If
- Return
k.
flowchart LR
S(["nums=[3,2,2,3] val=3 k=0"])
S --> i0["i=0 → 3 = val → skip"]
i0 --> i1["i=1 → 2 ≠ val → nums[0]=2 k=1"]
i1 --> i2["i=2 → 2 ≠ val → nums[1]=2 k=2"]
i2 --> i3["i=3 → 3 = val → skip"]
i3 --> R(["return k=2 nums[:2]=[2,2]"])
Solution
def removeElement(nums, val):
k = 0
for num in nums:
if num != val:
nums[k] = num
k += 1
return k
nums = [3, 2, 2, 3]
k = removeElement(nums, 3)
print(k, nums[:k]) # 2 [2, 2]
nums = [0, 1, 2, 2, 3, 0, 4, 2]
k = removeElement(nums, 2)
print(k, nums[:k]) # 5 [0, 1, 3, 0, 4]
Complexity
- Time:
O(n) - Space:
O(1)
3. Two Pointers — II
Intuition
When there are few elements to remove, the previous approach does unnecessary copying — it rewrites every non-val element even if it is already in the right place. Instead, swap unwanted elements with the element at the end of the valid range and shrink that range by one. This minimises write operations when removals are rare.
Algorithm
- Initialize
i = 0as the current position andn = len(nums)as the effective length. - While
i < n:- If
nums[i] == val: overwrite it withnums[n - 1]and decrementn. Do not incrementi— the swapped-in element still needs to be checked. - Otherwise: increment
i.
- If
- Return
n.
flowchart LR
subgraph T0["Initial i=0 n=4"]
A0["3"] --- A1["2"] --- A2["2"] --- A3["3"]
end
subgraph T1["i=0 3=val → nums[0]=nums[3] n=3"]
B0["3"] --- B1["2"] --- B2["2"] --- B3["·"]
end
subgraph T2["i=0 3=val → nums[0]=nums[2] n=2"]
C0["2"] --- C1["2"] --- C2["·"] --- C3["·"]
end
subgraph T3["i=0,1 advance → i=2 ≥ n=2 → stop"]
D0["2"] --- D1["2"]
end
T0 --> T1 --> T2 --> T3
Solution
def removeElement(nums, val):
i = 0
n = len(nums)
while i < n:
if nums[i] == val:
n -= 1
nums[i] = nums[n]
else:
i += 1
return n
nums = [3, 2, 2, 3]
k = removeElement(nums, 3)
print(k, nums[:k]) # 2 [2, 2]
nums = [0, 1, 2, 2, 3, 0, 4, 2]
k = removeElement(nums, 2)
print(k, nums[:k]) # 5 [0, 1, 3, 0, 4]
Complexity
- Time:
O(n) - Space:
O(1)
When to prefer this over Two Pointers — I? When
valis rare, this approach writes far fewer elements. If the array has 10 000 elements and only 2 matchval, Two Pointers — I still copies 9 998 elements; this approach writes only 2.
Common Pitfalls
Indexing into nums after returning k.
The problem only guarantees that the first k elements are correct. Elements at index k and beyond are considered garbage — do not read or compare them.
Modifying nums length directly.
Python lists support del and remove(), but the problem requires in-place modification that works within the array’s original memory. Avoid creating a new list; overwrite in place and track k yourself.
Off-by-one when using k as a slice bound.
nums[:k] is correct because k is the count of valid elements, which equals the exclusive upper bound of the slice.
Concatenation of Array
Difficulty: Easy Source: NeetCode
Problem
Given an integer array
numsof lengthn, return an arrayansof length2nwhereans[i] = nums[i]andans[i + n] = nums[i]for0 <= i < n.In other words,
ansis the concatenation of two copies ofnums.Example 1: Input:
nums = [1, 2, 1]Output:[1, 2, 1, 1, 2, 1]Example 2: Input:
nums = [1, 3, 2, 1]Output:[1, 3, 2, 1, 1, 3, 2, 1]Constraints:
1 <= n <= 10001 <= nums[i] <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — understanding index-based access and iteration
- Array concatenation — combining arrays or building new ones by index
1. Brute Force
Intuition
Build the result array manually by iterating twice. On the first pass, place each element of nums at index i. On the second pass, place the same elements at index i + n. This makes the logic explicit and easy to follow, even if Python gives us a nicer shortcut.
Algorithm
- Create
ansas a list of zeros with length2 * n. - Loop
ifrom0ton - 1:- Set
ans[i] = nums[i] - Set
ans[i + n] = nums[i]
- Set
- Return
ans.
Solution
def getConcatenation(nums):
n = len(nums)
ans = [0] * (2 * n)
for i in range(n):
ans[i] = nums[i]
ans[i + n] = nums[i]
return ans
print(getConcatenation([1, 2, 1])) # [1, 2, 1, 1, 2, 1]
print(getConcatenation([1, 3, 2, 1])) # [1, 3, 2, 1, 1, 3, 2, 1]
print(getConcatenation([5])) # [5, 5]
Complexity
- Time:
O(n) - Space:
O(n)— the output array of length2n
2. Python Concatenation Operator
Intuition
Python’s + operator on lists creates a new list that is the concatenation of both operands. Since we want nums followed by nums, we can just return nums + nums — one line, same time and space complexity as the manual approach, just more readable.
This is not a “trick” — it’s the same algorithm underneath. The + operator iterates through both lists and copies each element into a new list, which is exactly what the brute force does.
Algorithm
- Return
nums + nums.
flowchart LR
A(["nums = [1, 2, 1]"]) --> B["nums + nums"]
B --> C(["[1, 2, 1, 1, 2, 1]"])
Solution
def getConcatenation(nums):
return nums + nums
print(getConcatenation([1, 2, 1])) # [1, 2, 1, 1, 2, 1]
print(getConcatenation([1, 3, 2, 1])) # [1, 3, 2, 1, 1, 3, 2, 1]
print(getConcatenation([5])) # [5, 5]
Complexity
- Time:
O(n) - Space:
O(n)— output array of length2n
Common Pitfalls
Using nums * 2 vs nums + nums. Both produce the same result for this problem, but understand that nums * 2 also creates a new list — it does not mutate nums in place. Either works fine here.
Mutating nums in place. If you try to append to nums directly inside a loop over nums, you will get an infinite loop since the list grows as you iterate. Always build a separate output or use nums + nums to create a new list.
Contains Duplicate
Difficulty: Easy Source: NeetCode
Problem
Given an integer array
nums, returntrueif any value appears at least twice in the array, and returnfalseif every element is distinct.Example 1: Input:
nums = [1, 2, 3, 1]Output:trueExample 2: Input:
nums = [1, 2, 3, 4]Output:falseExample 3: Input:
nums = [1, 1, 1, 3, 3, 4, 3, 2, 4, 2]Output:trueConstraints:
1 <= nums.length <= 10^5-10^9 <= nums[i] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — iteration and element access
- Hash Sets — O(1) average-case lookup and insertion
1. Brute Force
Intuition
For every element in the array, check every other element that comes after it. If any two elements are equal, return true. This guarantees correctness but checks every possible pair — which is slow for large arrays.
Algorithm
- For each index
ifrom0ton - 1:- For each index
jfromi + 1ton - 1:- If
nums[i] == nums[j], returntrue.
- If
- For each index
- Return
false(no duplicate found).
Solution
def containsDuplicate(nums):
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] == nums[j]:
return True
return False
print(containsDuplicate([1, 2, 3, 1])) # True
print(containsDuplicate([1, 2, 3, 4])) # False
print(containsDuplicate([1, 1, 1, 3, 3, 4, 3])) # True
Complexity
- Time:
O(n²)— every pair of elements is compared - Space:
O(1)
2. Sorting
Intuition
If you sort the array first, any duplicate values will end up sitting next to each other. A single pass checking adjacent elements is then all you need. The cost is the sort itself.
Algorithm
- Sort
nums. - For each index
ifrom1ton - 1:- If
nums[i] == nums[i - 1], returntrue.
- If
- Return
false.
Solution
def containsDuplicate(nums):
nums.sort()
for i in range(1, len(nums)):
if nums[i] == nums[i - 1]:
return True
return False
print(containsDuplicate([1, 2, 3, 1])) # True
print(containsDuplicate([1, 2, 3, 4])) # False
print(containsDuplicate([1, 1, 1, 3, 3, 4, 3])) # True
Complexity
- Time:
O(n log n)— dominated by the sort - Space:
O(1)— ignoring the sort’s stack space
3. Hash Set
Intuition
As you scan through the array, keep track of every number you have seen so far in a hash set. Before adding a number, check if it is already in the set. If it is, you have found a duplicate — return true immediately. Hash set lookups and insertions are O(1) on average, so the whole scan is O(n).
Algorithm
- Create an empty set
seen. - For each
numinnums:- If
numis inseen, returntrue. - Otherwise, add
numtoseen.
- If
- Return
false.
flowchart LR
S(["nums=[1,2,3,1] seen={}"])
S --> i0["num=1 → not in seen → seen={1}"]
i0 --> i1["num=2 → not in seen → seen={1,2}"]
i1 --> i2["num=3 → not in seen → seen={1,2,3}"]
i2 --> i3["num=1 → 1 in seen! → return True"]
Solution
def containsDuplicate(nums):
seen = set()
for num in nums:
if num in seen:
return True
seen.add(num)
return False
print(containsDuplicate([1, 2, 3, 1])) # True
print(containsDuplicate([1, 2, 3, 4])) # False
print(containsDuplicate([1, 1, 1, 3, 3, 4, 3])) # True
Complexity
- Time:
O(n)— one pass with O(1) set operations - Space:
O(n)— worst case: all elements are unique and all end up in the set
Common Pitfalls
Using len(set(nums)) != len(nums). This works and is Pythonic, but it builds the entire set before checking — meaning it never short-circuits early when a duplicate is found near the start. The explicit loop with early return is faster in practice for arrays with early duplicates.
Assuming integers are bounded. The hash set approach works for any hashable type. If someone asks you to use O(1) space, the sorting approach is a good trade-off (though it modifies the input).
Valid Anagram
Difficulty: Easy Source: NeetCode
Problem
Given two strings
sandt, returntrueiftis an anagram ofs, andfalseotherwise.An anagram is a word formed by rearranging the letters of a different word using all the original letters exactly once.
Example 1: Input:
s = "anagram",t = "nagaram"Output:trueExample 2: Input:
s = "rat",t = "car"Output:falseConstraints:
1 <= s.length, t.length <= 5 * 10^4sandtconsist of lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Strings — iteration and character access
- Hash Maps — storing and comparing frequency counts
- Sorting — understanding the cost of sorting a sequence
1. Brute Force (Sorting)
Intuition
Two strings are anagrams of each other if and only if they contain the exact same characters with the exact same frequencies. Sorting both strings rearranges all characters into a canonical order — if the sorted versions are equal, the strings are anagrams. Short-circuit immediately if the lengths differ.
Algorithm
- If
len(s) != len(t), returnfalse. - Sort both strings.
- Return
sorted(s) == sorted(t).
Solution
def isAnagram(s, t):
if len(s) != len(t):
return False
return sorted(s) == sorted(t)
print(isAnagram("anagram", "nagaram")) # True
print(isAnagram("rat", "car")) # False
print(isAnagram("ab", "a")) # False
Complexity
- Time:
O(n log n)— dominated by sorting, wheren = len(s) - Space:
O(n)— sorted copies of each string
2. Frequency Count (Hash Map)
Intuition
Instead of sorting, count how many times each character appears in s, then subtract counts for each character in t. If every count ends at zero, the strings are anagrams. We only need one hash map — increment for characters in s, decrement for characters in t, and check that all values are zero at the end.
Algorithm
- If
len(s) != len(t), returnfalse. - Create a hash map
count(default 0). - For each character
cins, incrementcount[c]. - For each character
cint, decrementcount[c]. - Return
trueif all values incountare0.
flowchart LR
A(["s='rat' t='car'"])
A --> B["count r=1 a=1 t=1 after s"]
B --> C["subtract t: c=-1 a=0 r=0 t=1"]
C --> D{"all zero?"}
D -- No --> E(["return False"])
Solution
def isAnagram(s, t):
if len(s) != len(t):
return False
count = {}
for c in s:
count[c] = count.get(c, 0) + 1
for c in t:
count[c] = count.get(c, 0) - 1
return all(v == 0 for v in count.values())
print(isAnagram("anagram", "nagaram")) # True
print(isAnagram("rat", "car")) # False
print(isAnagram("ab", "a")) # False
Complexity
- Time:
O(n)— two passes over strings of lengthn - Space:
O(1)— at most 26 distinct lowercase letters in the map
3. Fixed-Size Count Array
Intuition
Since the problem guarantees lowercase English letters only, we can use a list of 26 integers instead of a hash map. Map each character to an index via ord(c) - ord('a'). This avoids hash overhead and is cache-friendly.
Algorithm
- If
len(s) != len(t), returnfalse. - Create
count = [0] * 26. - For each character
cins, incrementcount[ord(c) - ord('a')]. - For each character
cint, decrementcount[ord(c) - ord('a')]. - Return
trueif all entries incountare0.
Solution
def isAnagram(s, t):
if len(s) != len(t):
return False
count = [0] * 26
for c in s:
count[ord(c) - ord('a')] += 1
for c in t:
count[ord(c) - ord('a')] -= 1
return all(x == 0 for x in count)
print(isAnagram("anagram", "nagaram")) # True
print(isAnagram("rat", "car")) # False
print(isAnagram("ab", "a")) # False
Complexity
- Time:
O(n) - Space:
O(1)— fixed array of 26 integers regardless of input size
Common Pitfalls
Forgetting the length check. If s and t have different lengths, they cannot be anagrams. Checking length first lets you return early and avoids subtle bugs in the count comparison.
Handling Unicode. The problem restricts input to lowercase ASCII letters, so the 26-element array works. If the problem allowed Unicode, you would need a general hash map — the fixed array approach would fail silently on characters outside a-z.
Using Counter directly. from collections import Counter; return Counter(s) == Counter(t) is valid and clean, but understanding the underlying frequency-count approach is more important for interviews.
Two Sum
Difficulty: Easy Source: NeetCode
Problem
Given an array of integers
numsand an integertarget, return the indices of the two numbers such that they add up totarget.You may assume that each input has exactly one solution, and you may not use the same element twice. You can return the answer in any order.
Example 1: Input:
nums = [2, 7, 11, 15],target = 9Output:[0, 1]Example 2: Input:
nums = [3, 2, 4],target = 6Output:[1, 2]Example 3: Input:
nums = [3, 3],target = 6Output:[0, 1]Constraints:
2 <= nums.length <= 10^4-10^9 <= nums[i] <= 10^9-10^9 <= target <= 10^9- Only one valid answer exists.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — index-based access and iteration
- Hash Maps — O(1) average-case lookup for key-value pairs
- Complement — the idea that if
a + b = target, thenb = target - a
1. Brute Force
Intuition
Try every possible pair of indices (i, j) where i < j. Check if the two elements at those positions sum to target. The first matching pair is returned. This is correct but slow because it examines all n*(n-1)/2 pairs.
Algorithm
- For each index
ifrom0ton - 1:- For each index
jfromi + 1ton - 1:- If
nums[i] + nums[j] == target, return[i, j].
- If
- For each index
- Return
[](problem guarantees a solution exists, so this is unreachable).
Solution
def twoSum(nums, target):
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] + nums[j] == target:
return [i, j]
return []
print(twoSum([2, 7, 11, 15], 9)) # [0, 1]
print(twoSum([3, 2, 4], 6)) # [1, 2]
print(twoSum([3, 3], 6)) # [0, 1]
Complexity
- Time:
O(n²) - Space:
O(1)
2. Hash Map (One Pass)
Intuition
For each number nums[i], we need to know if its complement — target - nums[i] — has already been seen. A hash map lets us check this in O(1). As we scan left to right, we store each number and its index in the map. Before storing, we check whether the complement is already there. If it is, we are done.
The key insight: we only ever need to look backwards (at elements already processed), so a single left-to-right pass is enough.
Algorithm
- Create an empty hash map
seenmapping value → index. - For each index
iand valuenuminnums:- Compute
complement = target - num. - If
complementis inseen, return[seen[complement], i]. - Otherwise, store
seen[num] = i.
- Compute
- Return
[].
flowchart LR
S(["nums=[2,7,11,15] target=9 seen={}"])
S --> i0["i=0 num=2 comp=7 → 7 not in seen → seen={2:0}"]
i0 --> i1["i=1 num=7 comp=2 → 2 in seen! → return [0,1]"]
Solution
def twoSum(nums, target):
seen = {} # value -> index
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
print(twoSum([2, 7, 11, 15], 9)) # [0, 1]
print(twoSum([3, 2, 4], 6)) # [1, 2]
print(twoSum([3, 3], 6)) # [0, 1]
Complexity
- Time:
O(n)— one pass, each hash map operation is O(1) average - Space:
O(n)— the hash map stores at mostnentries
Common Pitfalls
Using the same element twice. If target = 6 and nums[0] = 3, you might incorrectly find the complement 3 at index 0 itself. The one-pass hash map approach avoids this naturally — we only look at elements already seen, and nums[0] has not been added to the map yet when we check its complement.
Returning values instead of indices. The problem asks for indices, not the numbers themselves. Make sure your hash map stores value → index, not the reverse.
Assuming the array is sorted. The two-pointer approach for sorted arrays would give O(n) time and O(1) space, but it only works if the array is sorted. Two Sum does not guarantee sorted input, so you would need to sort first (losing the original indices) or use the hash map approach.
Longest Common Prefix
Difficulty: Easy Source: NeetCode
Problem
Write a function to find the longest common prefix string amongst an array of strings.
If there is no common prefix, return an empty string
"".Example 1: Input:
strs = ["flower", "flow", "flight"]Output:"fl"Example 2: Input:
strs = ["dog", "racecar", "car"]Output:""Constraints:
1 <= strs.length <= 2000 <= strs[i].length <= 200strs[i]consists of only lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Strings — character-by-character comparison and slicing
- Iteration — looping over multiple collections simultaneously
1. Brute Force (Character-by-Character)
Intuition
Take the first string as the initial candidate prefix. Then for each subsequent string, shorten the candidate until it matches the beginning of that string. After processing all strings, whatever remains is the longest common prefix.
Alternatively: pick any column index c. If the character at column c is the same in all strings, it is part of the prefix. The moment we find a mismatch — or run off the end of any string — we stop.
Algorithm
- If
strsis empty, return"". - Set
prefix = strs[0]. - For each string
sinstrs[1:]:- While
sdoes not start withprefix, remove the last character fromprefix. - If
prefixbecomes empty, return"".
- While
- Return
prefix.
Solution
def longestCommonPrefix(strs):
if not strs:
return ""
prefix = strs[0]
for s in strs[1:]:
while not s.startswith(prefix):
prefix = prefix[:-1]
if not prefix:
return ""
return prefix
print(longestCommonPrefix(["flower", "flow", "flight"])) # "fl"
print(longestCommonPrefix(["dog", "racecar", "car"])) # ""
print(longestCommonPrefix(["interview", "inter", "internal"])) # "inter"
Complexity
- Time:
O(S)whereSis the total number of characters across all strings (worst case: all strings identical) - Space:
O(1)extra (we reuse the prefix string in-place)
2. Sort and Compare Endpoints
Intuition
If you sort the array of strings lexicographically, the first and last strings will be the most different from each other. Any character that matches between the first and last string also matches in every string in between. So comparing just those two strings tells you the longest common prefix for the whole array.
This is a clever observation: you do not need to compare all pairs — just the extreme ends after sorting.
Algorithm
- Sort
strslexicographically. - Compare the first string
lo = strs[0]and the last stringhi = strs[-1]character by character. - Find the length of their common prefix.
- Return the prefix.
flowchart LR
A(["strs = ['flower','flow','flight']"])
A --> B["sort → ['flight','flow','flower']"]
B --> C["compare 'flight' vs 'flower'"]
C --> D["f=f ✓ l=l ✓ i≠o ✗ stop at index 2"]
D --> E(["return 'fl'"])
Solution
def longestCommonPrefix(strs):
if not strs:
return ""
strs.sort()
lo, hi = strs[0], strs[-1]
i = 0
while i < len(lo) and i < len(hi) and lo[i] == hi[i]:
i += 1
return lo[:i]
print(longestCommonPrefix(["flower", "flow", "flight"])) # "fl"
print(longestCommonPrefix(["dog", "racecar", "car"])) # ""
print(longestCommonPrefix(["interview", "inter", "internal"])) # "inter"
Complexity
- Time:
O(n log n)for the sort, plusO(m)for the comparison wheremis the length of the shortest string - Space:
O(1)extra
Common Pitfalls
Empty array or single string. If strs has one element, its entire value is the common prefix. Make sure your code handles this edge case — the sort-and-compare approach naturally handles it because lo and hi are the same string.
Prefix becoming empty. When no common character exists at position 0, the answer is "". The trimming approach handles this cleanly via the while not prefix: return "" check.
Off-by-one on string length. When comparing two strings, stop when i reaches the length of the shorter string. Accessing lo[i] beyond its length would raise an IndexError.
Group Anagrams
Difficulty: Medium Source: NeetCode
Problem
Given an array of strings
strs, group the anagrams together. You can return the answer in any order.An anagram is a word formed by rearranging the letters of a different word using all the original letters exactly once.
Example 1: Input:
strs = ["eat","tea","tan","ate","nat","bat"]Output:[["bat"],["nat","tan"],["ate","eat","tea"]]Example 2: Input:
strs = [""]Output:[[""]]Example 3: Input:
strs = ["a"]Output:[["a"]]Constraints:
1 <= strs.length <= 10^40 <= strs[i].length <= 100strs[i]consists of lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — grouping items by a shared key
- Sorting — using sorted strings as canonical keys
- Tuples as dictionary keys — immutable sequences that can be hashed
1. Brute Force (Sort Each String as Key)
Intuition
Two strings are anagrams if and only if their sorted versions are identical. For example, "eat", "tea", and "ate" all sort to "aet". We can use the sorted string as a dictionary key and group all strings that share the same key together.
This is actually quite efficient in practice — sorting each individual word is cheap, and the overall grouping work is linear in total characters.
Algorithm
- Create a hash map
groupswherekey → list of strings. - For each string
sinstrs:- Compute
key = "".join(sorted(s)). - Append
stogroups[key].
- Compute
- Return the values of
groups.
Solution
from collections import defaultdict
def groupAnagrams(strs):
groups = defaultdict(list)
for s in strs:
key = "".join(sorted(s))
groups[key].append(s)
return list(groups.values())
print(groupAnagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
# [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
print(groupAnagrams([""])) # [['']]
print(groupAnagrams(["a"])) # [['a']]
Complexity
- Time:
O(n * k log k)wherenis the number of strings andkis the maximum string length (sorting each string) - Space:
O(n * k)— storing all strings in the map
2. Character Count Tuple as Key
Intuition
Instead of sorting (which costs O(k log k) per string), we can count the frequency of each of the 26 letters in the string. Two strings are anagrams if and only if they have the same character frequencies. Represent these 26 counts as a tuple and use that as the dictionary key. Tuple creation is O(k), which is better than sorting.
Algorithm
- Create a hash map
groupswherekey → list of strings. - For each string
sinstrs:- Create a count array of 26 zeros.
- For each character
cins, incrementcount[ord(c) - ord('a')]. - Convert
countto a tuple (tuples are hashable; lists are not). - Append
stogroups[tuple(count)].
- Return the values of
groups.
flowchart LR
A(["'eat' → count[e]=1, a=1, t=1 → tuple (1,0,0,...,1,...,1,...)"])
B(["'tea' → same counts → same tuple"])
C(["'tan' → count[t]=1, a=1, n=1 → different tuple"])
A --> G["same group"]
B --> G
C --> H["different group"]
Solution
from collections import defaultdict
def groupAnagrams(strs):
groups = defaultdict(list)
for s in strs:
count = [0] * 26
for c in s:
count[ord(c) - ord('a')] += 1
groups[tuple(count)].append(s)
return list(groups.values())
print(groupAnagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
# [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
print(groupAnagrams([""])) # [['']]
print(groupAnagrams(["a"])) # [['a']]
Complexity
- Time:
O(n * k)— for each ofnstrings, we do O(k) work to build the count - Space:
O(n * k)— storing all strings and keys
Common Pitfalls
Using a list as a dictionary key. Lists are mutable and therefore not hashable in Python. You must convert the count array to a tuple before using it as a key — this is a very common mistake.
Not using defaultdict. With a regular dict, you need to check if key not in groups: groups[key] = [] before appending. Using defaultdict(list) handles this automatically and makes the code cleaner.
Assuming the output order matters. The problem says the answer can be in any order — so do not worry about sorting the groups or the strings within each group unless explicitly asked.
Majority Element
Difficulty: Easy Source: NeetCode
Problem
Given an array
numsof sizen, return the majority element.The majority element is the element that appears more than
⌊n / 2⌋times. You may assume that the majority element always exists in the array.Example 1: Input:
nums = [3, 2, 3]Output:3Example 2: Input:
nums = [2, 2, 1, 1, 1, 2, 2]Output:2Constraints:
n == nums.length1 <= n <= 5 * 10^4-10^9 <= nums[i] <= 10^9- The majority element always exists.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — counting element frequencies
- Voting / Cancellation intuition — understanding why majority elements survive pairwise elimination
1. Brute Force (Hash Map Count)
Intuition
Count the occurrences of each element using a hash map, then find the element whose count exceeds n / 2. This is straightforward and easy to reason about — scan once to count, then scan the map to find the winner.
Algorithm
- Build a frequency map
countovernums. - For each element
numand itsfreqincount:- If
freq > n // 2, returnnum.
- If
- (Unreachable — the problem guarantees a majority element exists.)
Solution
def majorityElement(nums):
n = len(nums)
count = {}
for num in nums:
count[num] = count.get(num, 0) + 1
for num, freq in count.items():
if freq > n // 2:
return num
return -1 # unreachable
print(majorityElement([3, 2, 3])) # 3
print(majorityElement([2, 2, 1, 1, 1, 2, 2])) # 2
print(majorityElement([1])) # 1
Complexity
- Time:
O(n) - Space:
O(n)— the hash map can hold up tondistinct elements
2. Sorting
Intuition
If the majority element appears more than n/2 times, after sorting it must occupy the middle index n // 2. No matter how the other elements are arranged, the majority element has enough copies to span past the midpoint.
Algorithm
- Sort
nums. - Return
nums[n // 2].
Solution
def majorityElement(nums):
nums.sort()
return nums[len(nums) // 2]
print(majorityElement([3, 2, 3])) # 3
print(majorityElement([2, 2, 1, 1, 1, 2, 2])) # 2
print(majorityElement([1])) # 1
Complexity
- Time:
O(n log n)— dominated by sorting - Space:
O(1)— ignoring sort stack space
3. Boyer-Moore Voting Algorithm
Intuition
Imagine pairing up different elements and cancelling each pair. Since the majority element appears more than half the time, it always survives this cancellation process — there are simply too many copies of it for all of them to be cancelled out by other elements.
Maintain a candidate and a count. When count hits zero, pick the current element as the new candidate. If the current element matches the candidate, increment count; otherwise, decrement it. By the end, the candidate is guaranteed to be the majority element.
Algorithm
- Set
candidate = None,count = 0. - For each
numinnums:- If
count == 0, setcandidate = num. - If
num == candidate, incrementcount. - Otherwise, decrement
count.
- If
- Return
candidate.
flowchart LR
S(["nums=[2,2,1,1,1,2,2] candidate=None count=0"])
S --> a["num=2 count=0 → candidate=2 count=1"]
a --> b["num=2 match → count=2"]
b --> c["num=1 no match → count=1"]
c --> d["num=1 no match → count=0"]
d --> e["num=1 count=0 → candidate=1 count=1"]
e --> f["num=2 no match → count=0"]
f --> g["num=2 count=0 → candidate=2 count=1"]
g --> R(["return candidate=2"])
Solution
def majorityElement(nums):
candidate = None
count = 0
for num in nums:
if count == 0:
candidate = num
count += 1 if num == candidate else -1
return candidate
print(majorityElement([3, 2, 3])) # 3
print(majorityElement([2, 2, 1, 1, 1, 2, 2])) # 2
print(majorityElement([1])) # 1
Complexity
- Time:
O(n)— single pass - Space:
O(1)— only two variables
Common Pitfalls
Boyer-Moore requires a guaranteed majority. The algorithm returns the candidate but does not verify it. If the problem did not guarantee a majority element exists, you would need a second pass to count and confirm. For this problem, verification is not needed.
Confusing n // 2 with n / 2. In Python 3, / gives float division. Use // for integer floor division when checking freq > n // 2.
Design HashSet
Difficulty: Easy Source: NeetCode
Problem
Design a HashSet without using any built-in hash table libraries.
Implement the
MyHashSetclass:
add(key)— Inserts the valuekeyinto the HashSet.remove(key)— Removes the valuekeyfrom the HashSet. Ifkeydoes not exist, do nothing.contains(key)— Returnstrueifkeyexists,falseotherwise.Example 1: Input:
["MyHashSet","add","add","contains","contains","add","contains","remove","contains"][[], [1], [2], [1], [3], [2], [2], [2], [2]]Output:[null, null, null, true, false, null, true, null, false]Constraints:
0 <= key <= 10^6- At most
10^4calls will be made toadd,remove, andcontains.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — direct-address tables using index-as-key
- Hash Functions — mapping keys to array indices
- Linked Lists — chaining to handle collisions
1. Boolean Array (Direct Address Table)
Intuition
Since keys are bounded between 0 and 10^6, we can allocate a boolean array of size 10^6 + 1. Each index represents a key — True means the key is present, False means it is absent. No hashing or collision handling needed because the key directly serves as the index.
This is a direct-address table — extremely fast (O(1) per operation) but only practical when the key range is small and known in advance.
Algorithm
- add(key): Set
data[key] = True. - remove(key): Set
data[key] = False. - contains(key): Return
data[key].
Solution
class MyHashSet:
def __init__(self):
self.data = [False] * (10**6 + 1)
def add(self, key: int) -> None:
self.data[key] = True
def remove(self, key: int) -> None:
self.data[key] = False
def contains(self, key: int) -> bool:
return self.data[key]
hs = MyHashSet()
hs.add(1)
hs.add(2)
print(hs.contains(1)) # True
print(hs.contains(3)) # False
hs.add(2)
print(hs.contains(2)) # True
hs.remove(2)
print(hs.contains(2)) # False
Complexity
- Time:
O(1)per operation - Space:
O(N)whereN = 10^6— fixed overhead regardless of how many keys are inserted
2. Chaining with Array of Lists
Intuition
A more realistic hash set uses a hash function to map keys into buckets, and each bucket is a list (chain) that can hold multiple keys. Collisions — when two keys hash to the same bucket — are resolved by storing both in the same list and searching linearly within it.
This approach generalises to arbitrary key types and does not require knowing the key range upfront. The key design choices are: the number of buckets (affects collision rate) and the hash function.
Algorithm
__init__: CreateNUM_BUCKETSempty lists._hash(key): Returnkey % NUM_BUCKETSto pick the bucket.- add(key): If
keynot already in the bucket, append it. - remove(key): Filter
keyout of the bucket list. - contains(key): Return
Trueifkeyis in the bucket.
flowchart LR
K["key=1007"] --> H["hash = 1007 % 1000 = 7"]
H --> B["bucket[7] → [7, 1007]"]
B --> C["search list for 1007 → found!"]
Solution
class MyHashSet:
NUM_BUCKETS = 1000
def __init__(self):
self.buckets = [[] for _ in range(self.NUM_BUCKETS)]
def _hash(self, key: int) -> int:
return key % self.NUM_BUCKETS
def add(self, key: int) -> None:
h = self._hash(key)
if key not in self.buckets[h]:
self.buckets[h].append(key)
def remove(self, key: int) -> None:
h = self._hash(key)
self.buckets[h] = [k for k in self.buckets[h] if k != key]
def contains(self, key: int) -> bool:
h = self._hash(key)
return key in self.buckets[h]
hs = MyHashSet()
hs.add(1)
hs.add(2)
print(hs.contains(1)) # True
print(hs.contains(3)) # False
hs.add(2)
print(hs.contains(2)) # True
hs.remove(2)
print(hs.contains(2)) # False
Complexity
- Time:
O(n / k)per operation on average, wherenis the number of keys andkis the number of buckets. With a good hash and reasonable load, this approachesO(1). - Space:
O(n + k)—kbucket lists plus space fornstored keys
Common Pitfalls
Not checking for duplicates before adding. add should be idempotent — adding a key that already exists should not create a duplicate entry in the bucket. Always check if key not in bucket before appending.
Modifying the bucket list while iterating it. When removing, create a new filtered list rather than deleting elements while iterating. In Python, a list comprehension is the cleanest way to do this.
Choosing too few buckets. With only 10 buckets and 10^4 operations, each bucket could hold 1000 keys on average, making each operation O(1000). Choose a bucket count that gives a low load factor (roughly number-of-keys / number-of-buckets ≈ 1).
Design HashMap
Difficulty: Easy Source: NeetCode
Problem
Design a HashMap without using any built-in hash table libraries.
Implement the
MyHashMapclass:
put(key, value)— Inserts or updates the mappingkey → value. Ifkeyalready exists, update its value.get(key)— Returns the value mapped tokey, or-1if it does not exist.remove(key)— Removeskeyand its associated value. Ifkeydoes not exist, do nothing.Example 1: Input:
["MyHashMap","put","put","get","get","put","get","remove","get"][[], [1,1], [2,2], [1], [3], [2,1], [2], [2], [2]]Output:[null, null, null, 1, -1, null, 1, null, -1]Constraints:
0 <= key, value <= 10^6- At most
10^4calls will be made toput,get, andremove.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — fixed-size storage for direct address tables
- Hash Functions — mapping keys to indices
- Linked Lists or Arrays — chaining to handle collisions
1. Fixed-Size Array (Direct Address Table)
Intuition
Since keys are bounded between 0 and 10^6, allocate an array of size 10^6 + 1 where each slot stores the associated value (or -1 to indicate absence). The key directly serves as the array index — no hash function needed. This is fast but memory-heavy.
Algorithm
__init__: Createdata = [-1] * (10^6 + 1).- put(key, value): Set
data[key] = value. - get(key): Return
data[key]. - remove(key): Set
data[key] = -1.
Solution
class MyHashMap:
def __init__(self):
self.data = [-1] * (10**6 + 1)
def put(self, key: int, value: int) -> None:
self.data[key] = value
def get(self, key: int) -> int:
return self.data[key]
def remove(self, key: int) -> None:
self.data[key] = -1
hm = MyHashMap()
hm.put(1, 1)
hm.put(2, 2)
print(hm.get(1)) # 1
print(hm.get(3)) # -1
hm.put(2, 1) # update key 2
print(hm.get(2)) # 1
hm.remove(2)
print(hm.get(2)) # -1
Complexity
- Time:
O(1)per operation - Space:
O(N)whereN = 10^6— allocated upfront regardless of usage
2. Chaining with Buckets
Intuition
A more realistic implementation uses a hash function to assign keys to buckets, with each bucket holding a list of (key, value) pairs. On collision, pairs are stored in the same bucket and we search linearly for the matching key. This scales better in memory when the key space is large.
Algorithm
__init__: CreateNUM_BUCKETSempty lists._hash(key): Returnkey % NUM_BUCKETS.- put(key, value): Search the bucket for
key. If found, update value. Otherwise, append(key, value). - get(key): Search the bucket for
key. Return its value or-1. - remove(key): Filter out the pair with
keyfrom the bucket.
flowchart LR
P1["put(1, 1)"] --> B1["hash=1 bucket[1] = [(1,1)]"]
P2["put(1001, 5)"] --> B2["hash=1 bucket[1] = [(1,1),(1001,5)]"]
G["get(1001)"] --> S["search bucket[1] for key=1001 → found → return 5"]
Solution
class MyHashMap:
NUM_BUCKETS = 1000
def __init__(self):
self.buckets = [[] for _ in range(self.NUM_BUCKETS)]
def _hash(self, key: int) -> int:
return key % self.NUM_BUCKETS
def put(self, key: int, value: int) -> None:
h = self._hash(key)
for i, (k, v) in enumerate(self.buckets[h]):
if k == key:
self.buckets[h][i] = (key, value)
return
self.buckets[h].append((key, value))
def get(self, key: int) -> int:
h = self._hash(key)
for k, v in self.buckets[h]:
if k == key:
return v
return -1
def remove(self, key: int) -> None:
h = self._hash(key)
self.buckets[h] = [(k, v) for k, v in self.buckets[h] if k != key]
hm = MyHashMap()
hm.put(1, 1)
hm.put(2, 2)
print(hm.get(1)) # 1
print(hm.get(3)) # -1
hm.put(2, 1) # update key 2
print(hm.get(2)) # 1
hm.remove(2)
print(hm.get(2)) # -1
Complexity
- Time:
O(n / k)per operation on average, wherenis the number of stored pairs andkis the number of buckets - Space:
O(n + k)
Common Pitfalls
Updating instead of appending on duplicate keys. put must update the value for an existing key, not append a second (key, value) pair. Iterate through the bucket first; if the key is found, update in place and return early.
Returning -1 for absent keys vs. None. The problem specifies -1 as the sentinel for “not found.” If you use a different sentinel, you risk confusing a legitimately stored value of -1 with “not found.” For this problem, the constraints say 0 <= value <= 10^6, so -1 is safe. In a general-purpose map, use None instead.
Thread safety. Real hash map implementations need locking or lock-free data structures for concurrent access. For this problem, assume single-threaded access.
Sort an Array
Difficulty: Medium Source: NeetCode
Problem
Given an array of integers
nums, sort the array in ascending order and return it.You must solve the problem without using any built-in functions in
O(n log n)time complexity and with the smallest space complexity possible.Example 1: Input:
nums = [5, 2, 3, 1]Output:[1, 2, 3, 5]Example 2: Input:
nums = [5, 1, 1, 2, 0, 0]Output:[0, 0, 1, 1, 2, 5]Constraints:
1 <= nums.length <= 5 * 10^4-5 * 10^4 <= nums[i] <= 5 * 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion — divide-and-conquer thinking
- Merging sorted arrays — combining two sorted halves into one
1. Brute Force (Bubble Sort)
Intuition
Repeatedly scan through the array and swap adjacent elements that are out of order. Each full pass “bubbles” the largest unsorted element to its final position at the end. After n - 1 passes, the array is sorted. This is correct but extremely slow for large inputs.
Algorithm
- For each pass
ifrom0ton - 1:- For each index
jfrom0ton - i - 2:- If
nums[j] > nums[j + 1], swap them.
- If
- For each index
- Return
nums.
Solution
def sortArray(nums):
n = len(nums)
for i in range(n):
for j in range(n - i - 1):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
return nums
print(sortArray([5, 2, 3, 1])) # [1, 2, 3, 5]
print(sortArray([5, 1, 1, 2, 0, 0])) # [0, 0, 1, 1, 2, 5]
Complexity
- Time:
O(n²) - Space:
O(1)
2. Merge Sort
Intuition
Merge sort is a classic divide-and-conquer algorithm. Split the array in half, recursively sort each half, then merge the two sorted halves back together. The merge step is O(n) and the recursion depth is O(log n), giving an overall time of O(n log n).
The key operation is the merge: two sorted arrays can be combined in O(n) by always picking the smaller front element from either array.
Algorithm
- Base case: If the array has 0 or 1 elements, it is already sorted — return it.
- Split: Find the midpoint
mid = len(nums) // 2. Recursively sortleft = nums[:mid]andright = nums[mid:]. - Merge: Compare elements from the front of
leftandright, appending the smaller one to the result. - Drain any remaining elements from either half.
- Return the merged result.
flowchart TD
A["[5, 2, 3, 1]"]
A --> B["[5, 2]"]
A --> C["[3, 1]"]
B --> D["[5]"]
B --> E["[2]"]
C --> F["[3]"]
C --> G["[1]"]
D & E --> H["merge → [2, 5]"]
F & G --> I["merge → [1, 3]"]
H & I --> J["merge → [1, 2, 3, 5]"]
Solution
def sortArray(nums):
if len(nums) <= 1:
return nums
mid = len(nums) // 2
left = sortArray(nums[:mid])
right = sortArray(nums[mid:])
# Merge step
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
print(sortArray([5, 2, 3, 1])) # [1, 2, 3, 5]
print(sortArray([5, 1, 1, 2, 0, 0])) # [0, 0, 1, 1, 2, 5]
print(sortArray([-4, 0, 7, 4, 9, -5, 1, 10, -1, -1])) # [-5,-4,-1,-1,0,1,4,7,9,10]
Complexity
- Time:
O(n log n)—log nlevels of recursion, each doingO(n)work in the merge step - Space:
O(n)— temporary arrays allocated during the merge steps;O(log n)for the call stack
Common Pitfalls
Mutating nums in-place during merge sort. The version above creates new lists during merging, which is simpler but uses more memory. An in-place merge sort is significantly more complex — for interviews, the slicing approach shown here is preferred.
Choosing the wrong pivot for quicksort. If you use quicksort instead of merge sort, always-worst-case pivot selection (e.g., always picking the first element on a sorted array) degrades to O(n²). Merge sort avoids this problem entirely — its worst case is always O(n log n).
Stack overflow on very large inputs. Python’s default recursion limit is 1000. For n = 50000, the recursion depth of merge sort is about log₂(50000) ≈ 16 — well within limits. No issue here, but keep this in mind for other recursive algorithms.
Sort Colors
Difficulty: Medium Source: NeetCode
Problem
Given an array
numswithnobjects colored red, white, or blue, sort them in-place so that objects of the same color are adjacent, with the colors in the order red, white, and blue.We will use the integers
0,1, and2to represent the colors red, white, and blue, respectively.You must solve this problem without using the library’s sort function.
Example 1: Input:
nums = [2, 0, 2, 1, 1, 0]Output:[0, 0, 1, 1, 2, 2]Example 2: Input:
nums = [2, 0, 1]Output:[0, 1, 2]Constraints:
n == nums.length1 <= n <= 300nums[i]is either0,1, or2.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Two Pointers — maintaining multiple indices into an array
- In-place swapping — rearranging elements without extra storage
1. Brute Force (Count and Overwrite)
Intuition
Count how many 0s, 1s, and 2s appear in the array. Then overwrite the array — first fill in all the 0s, then all the 1s, then all the 2s. This takes two passes and constant extra space (just three counters). Simple and easy to verify.
Algorithm
- Count occurrences of
0,1,2. - Overwrite
nums: firstcount[0]positions get0, nextcount[1]positions get1, rest get2.
Solution
def sortColors(nums):
count = [0, 0, 0]
for num in nums:
count[num] += 1
i = 0
for color in range(3):
for _ in range(count[color]):
nums[i] = color
i += 1
nums = [2, 0, 2, 1, 1, 0]
sortColors(nums)
print(nums) # [0, 0, 1, 1, 2, 2]
nums = [2, 0, 1]
sortColors(nums)
print(nums) # [0, 1, 2]
Complexity
- Time:
O(n)— two passes - Space:
O(1)— three counters
2. Dutch National Flag Algorithm
Intuition
This is the classic Dutch National Flag problem (named after the three-color Dutch flag) attributed to Edsger Dijkstra. The idea is to use three pointers:
low— everything left oflowis0mid— the current element being examinedhigh— everything right ofhighis2- Elements between
lowandhigh(exclusive) are either1or unprocessed
We scan mid from left to right. Depending on the value at mid:
- If it is
0, swap it to thelowzone and advance bothlowandmid. - If it is
1, it is already in the middle — just advancemid. - If it is
2, swap it to thehighzone and retreathigh. Do not advancemidyet because the swapped-in element has not been examined.
This sorts the array in a single pass with no extra memory.
Algorithm
- Initialize
low = 0,mid = 0,high = n - 1. - While
mid <= high:- If
nums[mid] == 0: swapnums[mid]andnums[low], incrementlowandmid. - If
nums[mid] == 1: incrementmid. - If
nums[mid] == 2: swapnums[mid]andnums[high], decrementhigh.
- If
flowchart LR
S(["[2,0,2,1,1,0] low=0 mid=0 high=5"])
S --> a["nums[0]=2 → swap with high → [0,0,2,1,1,2] high=4"]
a --> b["nums[0]=0 → swap with low → already placed low=1 mid=1"]
b --> c["nums[1]=0 → swap with low → low=2 mid=2"]
c --> d["nums[2]=2 → swap with high → [0,0,1,1,2,2] high=3"]
d --> e["nums[2]=1 → mid=3"]
e --> f["nums[3]=1 → mid=4 → mid>high → done"]
f --> R(["[0,0,1,1,2,2]"])
Solution
def sortColors(nums):
low, mid, high = 0, 0, len(nums) - 1
while mid <= high:
if nums[mid] == 0:
nums[low], nums[mid] = nums[mid], nums[low]
low += 1
mid += 1
elif nums[mid] == 1:
mid += 1
else: # nums[mid] == 2
nums[mid], nums[high] = nums[high], nums[mid]
high -= 1
nums = [2, 0, 2, 1, 1, 0]
sortColors(nums)
print(nums) # [0, 0, 1, 1, 2, 2]
nums = [2, 0, 1]
sortColors(nums)
print(nums) # [0, 1, 2]
nums = [1]
sortColors(nums)
print(nums) # [1]
Complexity
- Time:
O(n)— single pass,midadvances orhighretreats every iteration - Space:
O(1)— three pointer variables only
Common Pitfalls
Not advancing mid when swapping with high. After swapping nums[mid] with nums[high], you decrement high but do NOT increment mid. The element that just arrived at mid from high has not been examined yet — it could be 0, 1, or 2. Advancing mid prematurely would skip it.
Confusing low and mid. After swapping nums[mid] with nums[low], it is safe to advance both because the element that moved to low’s position is guaranteed to be 0 (by invariant — everything before low is already 0), and the element that moved to mid from low was previously known to be 0 or 1 (never 2), so advancing mid is safe.
Top K Frequent Elements
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
numsand an integerk, return thekmost frequent elements. You may return the answer in any order.Example 1: Input:
nums = [1, 1, 1, 2, 2, 3],k = 2Output:[1, 2]Example 2: Input:
nums = [1],k = 1Output:[1]Constraints:
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4kis in the range[1, the number of unique elements in nums]- The answer is unique (no ties for the k-th position).
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — counting element frequencies
- Sorting — ordering by a custom key
- Bucket Sort — mapping values to indices representing frequency
1. Hash Map + Sort
Intuition
Count the frequency of every element with a hash map, then sort the unique elements by their frequency in descending order and return the first k. Straightforward and easy to implement — the bottleneck is the sort.
Algorithm
- Build a frequency map
count. - Sort the unique keys by frequency descending.
- Return the first
kkeys.
Solution
def topKFrequent(nums, k):
count = {}
for num in nums:
count[num] = count.get(num, 0) + 1
# Sort by frequency descending
sorted_keys = sorted(count.keys(), key=lambda x: count[x], reverse=True)
return sorted_keys[:k]
print(topKFrequent([1, 1, 1, 2, 2, 3], 2)) # [1, 2]
print(topKFrequent([1], 1)) # [1]
print(topKFrequent([4, 1, -1, 2, -1, 2, 3], 2)) # [-1, 2]
Complexity
- Time:
O(n log n)— dominated by the sort of up tonunique elements - Space:
O(n)— the frequency map
2. Bucket Sort
Intuition
Here is the key observation: the maximum possible frequency of any element is n (if all elements are the same). So we can create a list of n + 1 buckets where bucket[f] holds all elements that appear exactly f times.
After filling the buckets, scan from the highest frequency down to 1, collecting elements until we have k. This avoids sorting entirely — the “sort” is implicit in the bucket indices.
Algorithm
- Build frequency map
count. - Create
bucketsof sizen + 1, wherebuckets[f]is a list. - For each
(num, freq)incount, appendnumtobuckets[freq]. - Scan
bucketsfrom indexndown to1, collecting elements into the result until we havek.
flowchart LR
A(["nums=[1,1,1,2,2,3]"])
A --> B["count: {1:3, 2:2, 3:1}"]
B --> C["buckets[1]=[3] buckets[2]=[2] buckets[3]=[1]"]
C --> D["scan from high: take from [3] → [1] → done when k=2"]
D --> E(["result=[1,2]"])
Solution
def topKFrequent(nums, k):
count = {}
for num in nums:
count[num] = count.get(num, 0) + 1
# buckets[i] holds all numbers with frequency i
buckets = [[] for _ in range(len(nums) + 1)]
for num, freq in count.items():
buckets[freq].append(num)
result = []
for freq in range(len(buckets) - 1, 0, -1):
for num in buckets[freq]:
result.append(num)
if len(result) == k:
return result
return result
print(topKFrequent([1, 1, 1, 2, 2, 3], 2)) # [1, 2]
print(topKFrequent([1], 1)) # [1]
print(topKFrequent([4, 1, -1, 2, -1, 2, 3], 2)) # [-1, 2]
Complexity
- Time:
O(n)— building the count map is O(n), filling the buckets is O(n), scanning the buckets is O(n) - Space:
O(n)— the count map and bucket list each hold at mostnentries
Common Pitfalls
Confusing frequency with value. The bucket index represents frequency, not the element value. buckets[3] holds elements that appear 3 times, not the number 3.
Allocating n + 1 buckets. The maximum possible frequency is n (all elements identical), so you need indices 0 through n. Allocate n + 1 slots. Index 0 is never used but that is fine.
Returning early. Once you have collected k elements, you can return immediately. There is no need to scan the remaining buckets.
Encode and Decode Strings
Difficulty: Medium Source: NeetCode
Problem
Design an algorithm to encode a list of strings to a single string. The encoded string is then sent over the network and is decoded back to the original list of strings.
Implement the
encodeanddecodefunctions:
encode(strs)— encodes a list of strings into a single string.decode(s)— decodes the encoded string back into the original list of strings.The encoded string should be decodable even if the individual strings contain any possible character, including the delimiter character itself.
Example 1: Input:
["lint", "code", "love", "you"]Output:["lint", "code", "love", "you"](after encode then decode)Example 2: Input:
["we", "say", ":", "yes"]Output:["we", "say", ":", "yes"]Constraints:
0 <= strs.length <= 2000 <= strs[i].length <= 200strs[i]contains any possible characters out of 256 valid ASCII characters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Strings — slicing, concatenation, and
find/indexoperations - Framing / length-prefix encoding — a technique from network protocols for delimiting variable-length messages
1. Naive Delimiter (Broken Approach)
Intuition
A first instinct is to join strings with a special delimiter like # or | and split on it during decoding. This fails when strings themselves contain the delimiter — there is no way to distinguish a delimiter from a literal character in the string.
This approach is shown here only to illustrate why it breaks, not as a solution.
# BROKEN — fails if any string contains the delimiter
def encode_broken(strs):
return "#".join(strs)
def decode_broken(s):
return s.split("#")
# Works fine here...
print(decode_broken(encode_broken(["lint", "code"]))) # ['lint', 'code']
# But breaks when a string contains '#'
print(decode_broken(encode_broken(["a#b", "c"]))) # ['a', 'b', 'c'] ← WRONG
2. Length-Prefix Encoding
Intuition
The trick is to encode the length of each string along with the string itself, separated by a special character. This way, during decoding, you always know exactly how many characters to read — you do not need to search for a delimiter at all.
The format is: "<length>#<string>" repeated for each string. For example:
["lint", "code", "love", "you"]→"4#lint4#code4#love3#you"["we", "say", ":", "yes"]→"2#we3#say1#:3#yes"
The # here is just used to separate the length digits from the string content. Even if the string itself contains #, we are safe because we read exactly length characters after the #, never searching for another #.
Algorithm
Encode:
- For each string
s, appendf"{len(s)}#{s}"to the result. - Return the concatenated result.
Decode:
- Start with pointer
i = 0. - While
i < len(s):- Find the next
#starting fromi. Its index isj. - Read the length:
length = int(s[i:j]). - Read the string:
s[j+1 : j+1+length]. - Advance
itoj + 1 + length.
- Find the next
- Return collected strings.
flowchart LR
E(["encode(['lint','code'])"]) --> S["'4#lint4#code'"]
S --> D["i=0 → j=1 → len=4 → read 'lint' → i=6"]
D --> D2["i=6 → j=7 → len=4 → read 'code' → i=12"]
D2 --> R(["['lint','code']"])
Solution
def encode(strs):
result = ""
for s in strs:
result += f"{len(s)}#{s}"
return result
def decode(s):
result = []
i = 0
while i < len(s):
j = s.index('#', i) # find the next '#' from position i
length = int(s[i:j]) # parse the length
result.append(s[j + 1: j + 1 + length]) # read exactly 'length' chars
i = j + 1 + length # advance past the string
return result
# Test 1: basic
strs = ["lint", "code", "love", "you"]
print(decode(encode(strs))) # ['lint', 'code', 'love', 'you']
# Test 2: strings containing '#'
strs = ["we", "say", ":", "yes"]
print(decode(encode(strs))) # ['we', 'say', ':', 'yes']
# Test 3: empty string in the list
strs = ["", "hello", ""]
print(decode(encode(strs))) # ['', 'hello', '']
Complexity
- Time:
O(n)for both encode and decode, wherenis the total number of characters across all strings - Space:
O(n)— the encoded string and the output list
Common Pitfalls
Searching for # naively. During decoding, do not scan for the # from position 0 on every iteration — start from the current pointer i. Also, s.index('#', i) is safe because the # is always at position i + (number of digits), which is known to be present.
Forgetting multi-digit lengths. If a string has length 200, the prefix is "200#" — three digits, not one. The s.index('#', i) approach handles this correctly regardless of how many digits the length has.
Empty list edge case. If strs = [], encode([]) returns "". decode("") starts with i = 0 and the while i < len(s) condition is immediately false, returning []. This is correct behaviour.
Range Sum Query 2D - Immutable
Difficulty: Medium Source: NeetCode
Problem
Given a 2D matrix
matrix, handle multiple queries of the following type:
sumRegion(row1, col1, row2, col2)— Calculate the sum of the elements ofmatrixinside the rectangle defined by its upper left corner(row1, col1)and lower right corner(row2, col2).Implement the
NumMatrixclass with an efficientsumRegionmethod.Example 1: Input:
matrix = [[3,0,1,4,2],[5,6,3,2,1],[1,2,0,1,5],[4,1,0,1,7],[1,0,3,0,5]]sumRegion(2,1,4,3)→8sumRegion(1,1,2,2)→11sumRegion(1,2,2,4)→12Constraints:
m == matrix.length,n == matrix[i].length1 <= m, n <= 200-10^4 <= matrix[i][j] <= 10^40 <= row1 <= row2 < m0 <= col1 <= col2 < n- At most
10^4calls tosumRegion.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Prefix sums (1D) — computing cumulative sums for range queries
- 2D arrays — indexing and iteration over rows and columns
1. Brute Force (Iterate Rectangle)
Intuition
For each query, loop over every cell in the specified rectangle and add up the values. Simple and correct, but redoes work for every query — with 10^4 queries over a 200 × 200 matrix, this performs up to 4 × 10^8 operations.
Algorithm
__init__: Store the matrix.sumRegion: Iteraterowfromrow1torow2,colfromcol1tocol2, summingmatrix[row][col].
Solution
class NumMatrix:
def __init__(self, matrix):
self.matrix = matrix
def sumRegion(self, row1, col1, row2, col2):
total = 0
for r in range(row1, row2 + 1):
for c in range(col1, col2 + 1):
total += self.matrix[r][c]
return total
matrix = [
[3, 0, 1, 4, 2],
[5, 6, 3, 2, 1],
[1, 2, 0, 1, 5],
[4, 1, 0, 1, 7],
[1, 0, 3, 0, 5],
]
nm = NumMatrix(matrix)
print(nm.sumRegion(2, 1, 4, 3)) # 8
print(nm.sumRegion(1, 1, 2, 2)) # 11
print(nm.sumRegion(1, 2, 2, 4)) # 12
Complexity
- Time:
O(m * n)per query - Space:
O(1)extra
2. 2D Prefix Sum
Intuition
Precompute a 2D prefix sum table prefix where prefix[r][c] = sum of all elements in the rectangle from (0, 0) to (r-1, c-1) (using 1-indexed prefix to simplify boundary handling).
Then any rectangle sum can be computed with four lookups using the inclusion-exclusion principle:
sumRegion(r1, c1, r2, c2)
= prefix[r2+1][c2+1]
- prefix[r1][c2+1]
- prefix[r2+1][c1]
+ prefix[r1][c1]
Think of it like adding and subtracting overlapping areas:
+------------------+
| A | B |
|-------+---------|
| C | query |
+------------------+
sum(query) = total - A - C + corner(A∩C was subtracted twice)
Algorithm
__init__:
- Create
prefixof size(m+1) × (n+1)filled with zeros. - For each
rfrom1tom, for eachcfrom1ton:prefix[r][c] = matrix[r-1][c-1] + prefix[r-1][c] + prefix[r][c-1] - prefix[r-1][c-1]
sumRegion(r1, c1, r2, c2):
- Return
prefix[r2+1][c2+1] - prefix[r1][c2+1] - prefix[r2+1][c1] + prefix[r1][c1]
flowchart LR
A(["Precompute prefix[r][c] O(m*n)"])
A --> B(["Each sumRegion query O(1)"])
B --> C["prefix[r2+1][c2+1] - prefix[r1][c2+1] - prefix[r2+1][c1] + prefix[r1][c1]"]
Solution
class NumMatrix:
def __init__(self, matrix):
m, n = len(matrix), len(matrix[0])
# prefix[r][c] = sum of matrix[0..r-1][0..c-1]
self.prefix = [[0] * (n + 1) for _ in range(m + 1)]
for r in range(1, m + 1):
for c in range(1, n + 1):
self.prefix[r][c] = (
matrix[r - 1][c - 1]
+ self.prefix[r - 1][c]
+ self.prefix[r][c - 1]
- self.prefix[r - 1][c - 1]
)
def sumRegion(self, row1, col1, row2, col2):
p = self.prefix
return (
p[row2 + 1][col2 + 1]
- p[row1][col2 + 1]
- p[row2 + 1][col1]
+ p[row1][col1]
)
matrix = [
[3, 0, 1, 4, 2],
[5, 6, 3, 2, 1],
[1, 2, 0, 1, 5],
[4, 1, 0, 1, 7],
[1, 0, 3, 0, 5],
]
nm = NumMatrix(matrix)
print(nm.sumRegion(2, 1, 4, 3)) # 8
print(nm.sumRegion(1, 1, 2, 2)) # 11
print(nm.sumRegion(1, 2, 2, 4)) # 12
Complexity
- Time:
O(m * n)for preprocessing;O(1)persumRegionquery - Space:
O(m * n)— the prefix table
Common Pitfalls
Off-by-one indexing. Using a (m+1) × (n+1) prefix table with 1-based indexing means prefix[0][...] and prefix[...][0] are always 0 — which acts as a safe boundary, eliminating the need for explicit bounds checks.
Getting the inclusion-exclusion formula backwards. Draw it out: to get the sum of a rectangle, start with the large prefix sum at the bottom-right, subtract the strips above and to the left, then add back the corner that was subtracted twice.
Negative values. The matrix allows negative values — but the prefix sum approach handles this correctly with no modifications. Do not short-circuit on negative values.
Product of Array Except Self
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, return an arrayanswersuch thatanswer[i]is equal to the product of all the elements ofnumsexceptnums[i].The product of any prefix or suffix of
numsis guaranteed to fit in a 32-bit integer.You must write an algorithm that runs in
O(n)time and without using the division operation.Example 1: Input:
nums = [1, 2, 3, 4]Output:[24, 12, 8, 6]Example 2: Input:
nums = [-1, 1, 0, -3, 3]Output:[0, 0, 9, 0, 0]Constraints:
2 <= nums.length <= 10^5-30 <= nums[i] <= 30- The product of any prefix or suffix fits in a 32-bit integer.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Prefix products — cumulative products from the left
- Suffix products — cumulative products from the right
- Two-pass array traversal — scanning left-to-right then right-to-left
1. Brute Force
Intuition
For each position i, multiply together all elements except nums[i]. Two nested loops: the outer loop picks the position to exclude, the inner loop multiplies everything else.
Algorithm
- For each index
i:- Set
product = 1. - For each index
j, ifj != i, multiplyproduct *= nums[j]. - Set
answer[i] = product.
- Set
- Return
answer.
Solution
def productExceptSelf(nums):
n = len(nums)
answer = [1] * n
for i in range(n):
for j in range(n):
if j != i:
answer[i] *= nums[j]
return answer
print(productExceptSelf([1, 2, 3, 4])) # [24, 12, 8, 6]
print(productExceptSelf([-1, 1, 0, -3, 3])) # [0, 0, 9, 0, 0]
Complexity
- Time:
O(n²) - Space:
O(1)extra (not counting the output array)
2. Prefix and Suffix Products
Intuition
For any index i, the product of all elements except nums[i] equals:
- (product of all elements to the left of
i) × (product of all elements to the right ofi)
We can compute these two separately:
- Left pass: Build a
prefixarray whereprefix[i]= product ofnums[0..i-1]. - Right pass: Build a
suffixarray wheresuffix[i]= product ofnums[i+1..n-1]. answer[i] = prefix[i] * suffix[i].
We can do this in O(1) extra space (beyond the output array) by computing prefix on the first pass, then multiplying in suffix on the second pass directly into the answer array.
Algorithm
- Initialise
answer = [1] * n. - Left pass: Maintain a running
prefix = 1. For eachi, setanswer[i] = prefix, then updateprefix *= nums[i]. - Right pass: Maintain a running
suffix = 1. For eachifrom right to left, multiplyanswer[i] *= suffix, then updatesuffix *= nums[i]. - Return
answer.
flowchart LR
A(["nums = [1, 2, 3, 4]"])
A --> B["Left pass: answer = [1, 1, 2, 6]"]
B --> C["Right pass (suffix): answer = [24, 12, 8, 6]"]
C --> R(["return [24, 12, 8, 6]"])
Solution
def productExceptSelf(nums):
n = len(nums)
answer = [1] * n
# Left pass: answer[i] = product of nums[0..i-1]
prefix = 1
for i in range(n):
answer[i] = prefix
prefix *= nums[i]
# Right pass: multiply in product of nums[i+1..n-1]
suffix = 1
for i in range(n - 1, -1, -1):
answer[i] *= suffix
suffix *= nums[i]
return answer
print(productExceptSelf([1, 2, 3, 4])) # [24, 12, 8, 6]
print(productExceptSelf([-1, 1, 0, -3, 3])) # [0, 0, 9, 0, 0]
print(productExceptSelf([2, 3])) # [3, 2]
Complexity
- Time:
O(n)— two passes - Space:
O(1)extra (the output array does not count toward space complexity per the problem statement)
Common Pitfalls
Using division. The naive O(n) approach — compute total product, divide by nums[i] — is explicitly prohibited and also breaks when nums[i] == 0.
The zero case. The prefix/suffix approach handles zeros automatically. If nums[i] == 0, both its left prefix and right suffix are unaffected. The zero propagates through multiplication correctly — no special casing needed.
Order of operations in the right pass. In the right pass, multiply the current answer[i] by suffix BEFORE updating suffix. If you update suffix *= nums[i] first, you include nums[i] in the suffix for position i, which is wrong — we want the product of elements strictly to the right.
Valid Sudoku
Difficulty: Medium Source: NeetCode
Problem
Determine if a
9 × 9Sudoku board is valid. Only the filled cells need to be validated according to the following rules:
- Each row must contain the digits
1–9with no repetition.- Each column must contain the digits
1–9with no repetition.- Each of the nine
3 × 3sub-boxes must contain the digits1–9with no repetition.Note: A Sudoku board does not need to be solvable to be valid. Only already-filled cells are validated. Empty cells are represented by
'.'.Example 1: Input: a standard partially-filled Sudoku board Output:
trueExample 2: Input: a board with
8appearing twice in the top row Output:falseConstraints:
board.length == 9board[i].length == 9board[i][j]is a digit1–9or'.'.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Sets — detecting duplicates efficiently
- 2D array indexing — mapping
(row, col)to box index - String vs integer handling — board cells are strings
'1'–'9'and'.'
1. Three Separate Passes
Intuition
Validate each constraint independently: check all rows for duplicates, then check all columns, then check all 3×3 boxes. Each check uses a set to detect repeated digits. Although this is three passes, it is still O(81) = O(1) since the board is a fixed 9×9 grid.
Algorithm
- For each of 9 rows: collect non-
'.'cells into a list; returnfalseif any duplicates. - For each of 9 columns: same check.
- For each of 9 boxes (indexed by
box_row0–2,box_col0–2): collect the 9 cells in each 3×3 block; returnfalseif any duplicates. - Return
true.
Solution
def isValidSudoku(board):
# Check rows
for row in board:
seen = set()
for c in row:
if c == '.':
continue
if c in seen:
return False
seen.add(c)
# Check columns
for col in range(9):
seen = set()
for row in range(9):
c = board[row][col]
if c == '.':
continue
if c in seen:
return False
seen.add(c)
# Check 3x3 boxes
for box_row in range(3):
for box_col in range(3):
seen = set()
for r in range(3):
for c in range(3):
val = board[box_row * 3 + r][box_col * 3 + c]
if val == '.':
continue
if val in seen:
return False
seen.add(val)
return True
board = [
["5","3",".",".","7",".",".",".","."],
["6",".",".","1","9","5",".",".","."],
[".","9","8",".",".",".",".","6","."],
["8",".",".",".","6",".",".",".","3"],
["4",".",".","8",".","3",".",".","1"],
["7",".",".",".","2",".",".",".","6"],
[".","6",".",".",".",".","2","8","."],
[".",".",".","4","1","9",".",".","5"],
[".",".",".",".","8",".",".","7","9"],
]
print(isValidSudoku(board)) # True
invalid_board = [row[:] for row in board]
invalid_board[0][1] = "5" # duplicate '5' in first row
print(isValidSudoku(invalid_board)) # False
Complexity
- Time:
O(81)=O(1)— fixed board size - Space:
O(9)=O(1)— at most 9 elements in any set at once
2. Single Pass with Keyed Sets
Intuition
Instead of three separate loops, visit each cell once and simultaneously check all three constraints using one set per constraint type. The key insight is encoding the constraint identity into the set entry itself:
- Row check: store
(row, digit)in arowsset - Column check: store
(col, digit)in acolsset - Box check: store
(row // 3, col // 3, digit)in aboxesset
If any of these tuples is already in its respective set, return false immediately.
Algorithm
- Create three empty sets:
rows,cols,boxes. - For each cell
(r, c)with valueval:- Skip if
val == '.'. - If
(r, val)inrowsor(c, val)incolsor(r//3, c//3, val)inboxes: returnfalse. - Add the tuples to each set.
- Skip if
- Return
true.
flowchart LR
A(["visit cell (0,0) val='5'"])
A --> B["check rows: (0,'5') — not seen"]
B --> C["check cols: (0,'5') — not seen"]
C --> D["check boxes: (0,0,'5') — not seen"]
D --> E["add all three tuples, continue"]
Solution
def isValidSudoku(board):
rows = set()
cols = set()
boxes = set()
for r in range(9):
for c in range(9):
val = board[r][c]
if val == '.':
continue
if (r, val) in rows:
return False
rows.add((r, val))
if (c, val) in cols:
return False
cols.add((c, val))
box_key = (r // 3, c // 3, val)
if box_key in boxes:
return False
boxes.add(box_key)
return True
board = [
["5","3",".",".","7",".",".",".","."],
["6",".",".","1","9","5",".",".","."],
[".","9","8",".",".",".",".","6","."],
["8",".",".",".","6",".",".",".","3"],
["4",".",".","8",".","3",".",".","1"],
["7",".",".",".","2",".",".",".","6"],
[".","6",".",".",".",".","2","8","."],
[".",".",".","4","1","9",".",".","5"],
[".",".",".",".","8",".",".","7","9"],
]
print(isValidSudoku(board)) # True
board[0][1] = "5" # duplicate in row 0
print(isValidSudoku(board)) # False
Complexity
- Time:
O(1)— 81 cells, fixed - Space:
O(1)— at most 81 * 3 = 243 entries across all sets, which is constant
Common Pitfalls
Forgetting to skip '.' cells. Empty cells should not be validated — they represent unfilled positions. Always check if val == '.' before processing.
Box index calculation. The box for cell (r, c) is identified by (r // 3, c // 3). Box (0,0) covers rows 0–2 and columns 0–2; box (1,2) covers rows 3–5 and columns 6–8.
Thinking the board must be solvable. Validity only requires that the existing digits do not violate the three rules. A board with all cells empty is valid. A board with the digit 5 in every cell of the first row is invalid — regardless of whether the rest of the board could be filled in.
Longest Consecutive Sequence
Difficulty: Medium Source: NeetCode
Problem
Given an unsorted array of integers
nums, return the length of the longest consecutive elements sequence.You must write an algorithm that runs in
O(n)time.Example 1: Input:
nums = [100, 4, 200, 1, 3, 2]Output:4(The longest consecutive sequence is[1, 2, 3, 4])Example 2: Input:
nums = [0, 3, 7, 2, 5, 8, 4, 6, 0, 1]Output:9Constraints:
0 <= nums.length <= 10^5-10^9 <= nums[i] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Sets — O(1) average-case lookup
- Sequence start detection — identifying where a consecutive run begins
1. Brute Force (Sort and Scan)
Intuition
Sort the array first. Then scan through it: if the current number is exactly 1 more than the previous, extend the current streak; if it is the same as the previous (duplicate), skip it; otherwise, start a new streak. Track the maximum streak seen.
Algorithm
- Handle edge case: if
numsis empty, return0. - Sort
nums. - Track
current_streak = 1andbest = 1. - For each index
ifrom1ton - 1:- If
nums[i] == nums[i - 1]: skip (duplicate). - If
nums[i] == nums[i - 1] + 1: incrementcurrent_streak, updatebest. - Else: reset
current_streak = 1.
- If
- Return
best.
Solution
def longestConsecutive(nums):
if not nums:
return 0
nums.sort()
best = 1
current = 1
for i in range(1, len(nums)):
if nums[i] == nums[i - 1]:
continue # skip duplicates
elif nums[i] == nums[i - 1] + 1:
current += 1
best = max(best, current)
else:
current = 1
return best
print(longestConsecutive([100, 4, 200, 1, 3, 2])) # 4
print(longestConsecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1])) # 9
print(longestConsecutive([])) # 0
Complexity
- Time:
O(n log n)— dominated by the sort - Space:
O(1)extra
2. Hash Set (O(n))
Intuition
The key insight: a consecutive sequence [a, a+1, a+2, ..., a+k] has exactly one starting point — the number a where a - 1 is NOT in the array. If we only start counting from sequence beginnings, each number is counted at most once across all sequences.
Build a hash set of all numbers for O(1) lookup. For each number n, check if n - 1 is in the set. If not, n is the start of a sequence — count upwards from there. Skip numbers where n - 1 exists because they belong to a sequence already counted.
Algorithm
- Build
num_set = set(nums). - Initialize
best = 0. - For each
numinnum_set:- If
num - 1is NOT innum_set(i.e.,numis a sequence start):- Count streak: while
num + streakis innum_set, incrementstreak. - Update
best = max(best, streak).
- Count streak: while
- If
- Return
best.
flowchart LR
S(["num_set = {100,4,200,1,3,2}"])
S --> a["num=100: 99 not in set → count: 100,101? no → streak=1"]
a --> b["num=4: 3 in set → skip (not a start)"]
b --> c["num=200: 199 not in set → count: 200,201? no → streak=1"]
c --> d["num=1: 0 not in set → count: 1,2,3,4 → streak=4"]
d --> e["num=3: 2 in set → skip"]
e --> f["num=2: 1 in set → skip"]
f --> R(["best=4"])
Solution
def longestConsecutive(nums):
num_set = set(nums)
best = 0
for num in num_set:
# Only start counting from the beginning of a sequence
if num - 1 not in num_set:
streak = 1
while num + streak in num_set:
streak += 1
best = max(best, streak)
return best
print(longestConsecutive([100, 4, 200, 1, 3, 2])) # 4
print(longestConsecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1])) # 9
print(longestConsecutive([])) # 0
Complexity
- Time:
O(n)— building the set is O(n). Each number is visited at most twice: once in the outer loop (to check if it is a start), and at most once in the innerwhileloop (only when it is part of a sequence being counted). Total inner loop iterations across all starting points is bounded byn. - Space:
O(n)— the hash set
Common Pitfalls
Iterating over the original list vs. the set. Iterating over nums (with duplicates) instead of num_set is safe but wastes time on duplicates. Iterating over num_set ensures we consider each unique value once.
Restarting counts for non-starts. Without the if num - 1 not in num_set guard, you would count sequences starting at every element — including elements in the middle of a sequence. This makes the inner while loop run redundantly and inflates runtime to O(n²) in the worst case.
Empty input. If nums is empty, the for loop does not execute and best stays at 0 — correct, no special case needed.
Best Time to Buy and Sell Stock II
Difficulty: Medium Source: NeetCode
Problem
You are given an integer array
priceswhereprices[i]is the price of a given stock on thei-th day.On each day, you may decide to buy and/or sell the stock. You can only hold at most one share of the stock at any time. However, you can buy it then immediately sell it on the same day.
Return the maximum profit you can achieve.
Example 1: Input:
prices = [7, 1, 5, 3, 6, 4]Output:7Explanation: Buy on day 2 (price=1), sell on day 3 (price=5), profit=4. Buy on day 4 (price=3), sell on day 5 (price=6), profit=3. Total=7.Example 2: Input:
prices = [1, 2, 3, 4, 5]Output:4Explanation: Buy on day 1, sell on day 5. Profit=4.Example 3: Input:
prices = [7, 6, 4, 3, 1]Output:0Explanation: No profitable transaction possible.Constraints:
1 <= prices.length <= 3 * 10^40 <= prices[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — making locally optimal choices that lead to a global optimum
- Peaks and valleys — identifying upward and downward trends in a sequence
1. Peaks and Valleys
Intuition
To maximise profit, we want to buy at every valley (local minimum) and sell at every peak (local maximum). Between any valley and peak, we capture the full upward movement of the stock price.
Scan through the prices and whenever the current price is lower than the next price, buy (or keep holding). When the current price is higher than the next, sell. Accumulate all upward movements.
Algorithm
- Initialize
profit = 0,i = 0. - While
i < n - 1:- Find valley: while
prices[i] >= prices[i + 1], incrementi. - Find peak: while
prices[i] <= prices[i + 1], incrementi. - Add
prices[peak] - prices[valley]toprofit.
- Find valley: while
- Return
profit.
Solution
def maxProfit(prices):
profit = 0
i = 0
n = len(prices)
while i < n - 1:
# Find valley
while i < n - 1 and prices[i] >= prices[i + 1]:
i += 1
valley = prices[i]
# Find peak
while i < n - 1 and prices[i] <= prices[i + 1]:
i += 1
peak = prices[i]
profit += peak - valley
return profit
print(maxProfit([7, 1, 5, 3, 6, 4])) # 7
print(maxProfit([1, 2, 3, 4, 5])) # 4
print(maxProfit([7, 6, 4, 3, 1])) # 0
Complexity
- Time:
O(n) - Space:
O(1)
2. Greedy (Sum Positive Differences)
Intuition
Here is the key insight that simplifies everything: since we can buy and sell on consecutive days, we can decompose any profit into the sum of single-day gains.
For example, buying at price 1 on day 1 and selling at price 5 on day 4 gives profit 4. But this is the same as buying and selling each day: (2-1) + (3-2) + (4-3) + (5-4) = 1 + 1 + 1 + 1 = 4. The intermediate buy-sell steps cancel out.
So: just sum up all positive consecutive differences. If prices[i+1] > prices[i], add the difference to profit. Ignore negative differences (do not trade on down days).
Algorithm
- Initialize
profit = 0. - For each index
ifrom0ton - 2:- If
prices[i + 1] > prices[i]: addprices[i + 1] - prices[i]toprofit.
- If
- Return
profit.
flowchart LR
A(["prices = [7,1,5,3,6,4]"])
A --> a["day 0→1: 1-7=-6 → skip"]
a --> b["day 1→2: 5-1=+4 → profit+=4"]
b --> c["day 2→3: 3-5=-2 → skip"]
c --> d["day 3→4: 6-3=+3 → profit+=3"]
d --> e["day 4→5: 4-6=-2 → skip"]
e --> R(["profit=7"])
Solution
def maxProfit(prices):
profit = 0
for i in range(len(prices) - 1):
if prices[i + 1] > prices[i]:
profit += prices[i + 1] - prices[i]
return profit
print(maxProfit([7, 1, 5, 3, 6, 4])) # 7
print(maxProfit([1, 2, 3, 4, 5])) # 4
print(maxProfit([7, 6, 4, 3, 1])) # 0
Complexity
- Time:
O(n)— one pass - Space:
O(1)
Common Pitfalls
Confusing this with Best Time to Buy and Sell Stock I. In problem I, you can only make one transaction. Here, unlimited transactions are allowed. The greedy approach only works because you can trade as many times as you want.
Trying to track buy/sell days. You do not need to know exactly which days to buy and sell to compute the maximum profit. The sum-of-positive-differences trick gives the correct answer without tracking individual transactions.
Selling before buying. You must buy before you can sell. The consecutive-day trick handles this automatically — we only add gains from one day to the next, never going backwards in time.
Majority Element II
Difficulty: Medium Source: NeetCode
Problem
Given an integer array of size
n, find all elements that appear more than⌊n / 3⌋times.Example 1: Input:
nums = [3, 2, 3]Output:[3]Example 2: Input:
nums = [1, 2]Output:[1, 2]Example 3: Input:
nums = [3, 2, 3, 2, 1, 2]Output:[3, 2]Constraints:
1 <= nums.length <= 5 * 10^4-10^9 <= nums[i] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — frequency counting
- Boyer-Moore Voting Algorithm — from Majority Element I
- Mathematical observation — at most 2 elements can appear more than n/3 times
1. Hash Map Count
Intuition
Count every element’s frequency with a hash map, then collect all elements whose count exceeds n // 3. This is direct and easy to verify.
Algorithm
- Build a frequency map
count. - Threshold =
n // 3. - Return all elements with
count[num] > threshold.
Solution
def majorityElement(nums):
n = len(nums)
count = {}
for num in nums:
count[num] = count.get(num, 0) + 1
threshold = n // 3
return [num for num, freq in count.items() if freq > threshold]
print(majorityElement([3, 2, 3])) # [3]
print(majorityElement([1, 2])) # [1, 2]
print(majorityElement([3, 2, 3, 2, 1, 2])) # [3, 2]
Complexity
- Time:
O(n) - Space:
O(n)— the hash map
2. Boyer-Moore Voting with Two Candidates
Intuition
Key observation: At most 2 elements can appear more than n/3 times. Why? If three elements each appeared more than n/3 times, they would collectively account for more than n elements — impossible.
Extending Boyer-Moore to find at most 2 candidates:
Maintain two candidates (c1, c2) and their respective counts (cnt1, cnt2). For each number:
- If it matches
c1, incrementcnt1. - Else if it matches
c2, incrementcnt2. - Else if
cnt1 == 0, replacec1with the current number. - Else if
cnt2 == 0, replacec2with the current number. - Else both candidates differ — decrement both counts (cancel one of each).
After the first pass, c1 and c2 are candidates. Do a second pass to verify both actually exceed n // 3 (since the voting pass finds candidates, not guaranteed winners).
Algorithm
- Phase 1: Find two candidates using the extended voting algorithm.
- Phase 2: Count actual occurrences of
c1andc2; include those exceedingn // 3.
flowchart LR
S(["nums=[3,2,3,2,1,2] c1=None cnt1=0 c2=None cnt2=0"])
S --> a["3: cnt1=0 → c1=3 cnt1=1"]
a --> b["2: cnt2=0 → c2=2 cnt2=1"]
b --> c["3: matches c1 → cnt1=2"]
c --> d["2: matches c2 → cnt2=2"]
d --> e["1: neither, cnt1>0 cnt2>0 → cnt1=1 cnt2=1"]
e --> f["2: matches c2 → cnt2=2"]
f --> G["Phase 2: count(3)=2 > 6//3=2? No! count(2)=3>2? Yes"]
G --> R(["result=[2] ... wait: count(3)=2 not > 2"])
Note:
>not>=— so for[3,2,3,2,1,2](n=6), threshold is2, count(3)=2 is NOT> 2, count(2)=3 IS> 2. Let’s verify:[1,2](n=2), threshold=0, both count 1 which is> 0. Correct.
Solution
def majorityElement(nums):
# Phase 1: find candidates
c1, c2 = None, None
cnt1, cnt2 = 0, 0
for num in nums:
if num == c1:
cnt1 += 1
elif num == c2:
cnt2 += 1
elif cnt1 == 0:
c1, cnt1 = num, 1
elif cnt2 == 0:
c2, cnt2 = num, 1
else:
cnt1 -= 1
cnt2 -= 1
# Phase 2: verify candidates
n = len(nums)
threshold = n // 3
result = []
for candidate in [c1, c2]:
if candidate is not None and nums.count(candidate) > threshold:
result.append(candidate)
return result
print(majorityElement([3, 2, 3])) # [3]
print(majorityElement([1, 2])) # [1, 2]
print(majorityElement([3, 2, 3, 2, 1, 2])) # [2]
print(majorityElement([2, 2])) # [2]
Complexity
- Time:
O(n)— two passes (thenums.count()calls are each O(n), but constants) - Space:
O(1)— four variables only
Common Pitfalls
Skipping the verification pass. The voting algorithm finds candidates but does not guarantee they exceed the threshold. A second pass to verify their actual counts is mandatory. For example, in [1, 2, 3], every element is a candidate but none appears more than once (threshold = 3 // 3 = 1), so the answer is [].
Using >= instead of > for the threshold. The problem asks for elements appearing more than n/3 times, not at least n/3 times.
Not handling duplicate candidates. If the same value becomes both c1 and c2 due to implementation bugs, the verification phase handles it — each candidate is checked independently.
Subarray Sum Equals K
Difficulty: Medium Source: NeetCode
Problem
Given an array of integers
numsand an integerk, return the total number of subarrays whose sum equalsk.A subarray is a contiguous non-empty sequence of elements within an array.
Example 1: Input:
nums = [1, 1, 1],k = 2Output:2Example 2: Input:
nums = [1, 2, 3],k = 3Output:2Constraints:
1 <= nums.length <= 2 * 10^4-1000 <= nums[i] <= 1000-10^7 <= k <= 10^7
Prerequisites
Before attempting this problem, you should be comfortable with:
- Prefix sums — cumulative sums from the start of the array
- Hash Maps — counting how many times a value has been seen
- Complement trick — if
prefix[j] - prefix[i] == k, then subarrayi+1..jsums tok
1. Brute Force (All Subarrays)
Intuition
Try every possible subarray. For each starting index i, extend the subarray rightward and keep a running sum. When the running sum equals k, increment the count.
Algorithm
- Initialize
count = 0. - For each
ifrom0ton - 1:- Set
total = 0. - For each
jfromiton - 1:- Add
nums[j]tototal. - If
total == k, incrementcount.
- Add
- Set
- Return
count.
Solution
def subarraySum(nums, k):
count = 0
n = len(nums)
for i in range(n):
total = 0
for j in range(i, n):
total += nums[j]
if total == k:
count += 1
return count
print(subarraySum([1, 1, 1], 2)) # 2
print(subarraySum([1, 2, 3], 3)) # 2
print(subarraySum([1], 0)) # 0
Complexity
- Time:
O(n²) - Space:
O(1)
2. Prefix Sum with Hash Map
Intuition
Let prefix[i] = sum of nums[0..i]. A subarray from index i+1 to j sums to k when:
prefix[j] - prefix[i] == k
→ prefix[i] == prefix[j] - k
So for each position j, the number of valid subarrays ending at j equals the number of times prefix[j] - k has appeared as a prefix sum before position j.
We track prefix sum occurrences in a hash map as we scan. The map starts with {0: 1} to handle the case where the prefix sum itself equals k (the entire prefix from index 0 to j is a valid subarray).
Algorithm
- Initialize
prefix_counts = {0: 1},prefix = 0,count = 0. - For each
numinnums:- Update
prefix += num. - Add
prefix_counts.get(prefix - k, 0)tocount. - Increment
prefix_counts[prefix]by 1.
- Update
- Return
count.
flowchart LR
S(["nums=[1,1,1] k=2 prefix_counts={0:1} prefix=0"])
S --> a["num=1: prefix=1 check(1-2=-1)→0 count=0 map={0:1,1:1}"]
a --> b["num=1: prefix=2 check(2-2=0)→1 count=1 map={0:1,1:1,2:1}"]
b --> c["num=1: prefix=3 check(3-2=1)→1 count=2 map={0:1,1:1,2:1,3:1}"]
c --> R(["return 2"])
Solution
def subarraySum(nums, k):
prefix_counts = {0: 1} # prefix sum 0 has been seen once (before any element)
prefix = 0
count = 0
for num in nums:
prefix += num
# How many earlier prefixes satisfy: prefix - earlier_prefix == k
count += prefix_counts.get(prefix - k, 0)
# Record this prefix sum
prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
return count
print(subarraySum([1, 1, 1], 2)) # 2
print(subarraySum([1, 2, 3], 3)) # 2
print(subarraySum([1], 0)) # 0
Complexity
- Time:
O(n)— one pass with O(1) hash map operations - Space:
O(n)— hash map holds at mostn + 1distinct prefix sums
Common Pitfalls
Not initialising prefix_counts = {0: 1}. Without this, you miss subarrays that start from index 0 (i.e., where the prefix sum from the very beginning equals k). For example, nums = [3], k = 3 should return 1, but without the initial {0: 1} it returns 0.
Checking before updating the map. You must look up prefix - k in the map BEFORE adding the current prefix to the map. Adding first would allow a subarray to “match itself” — counting a zero-length subarray.
Negative numbers and zeros. Unlike the sliding window technique (which only works for positive numbers), prefix sum with a hash map handles negative numbers and zeros correctly. No special casing required.
First Missing Positive
Difficulty: Hard Source: NeetCode
Problem
Given an unsorted integer array
nums, return the smallest missing positive integer.You must implement an algorithm that runs in
O(n)time and usesO(1)auxiliary space.Example 1: Input:
nums = [1, 2, 0]Output:3Example 2: Input:
nums = [3, 4, -1, 1]Output:2Example 3: Input:
nums = [7, 8, 9, 11, 12]Output:1Constraints:
1 <= nums.length <= 5 * 10^5-2^31 <= nums[i] <= 2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays as hash sets — using indices to record the presence of values
- Index marking — using sign changes to encode boolean information within an array
- Pigeonhole principle — the answer must be in the range
[1, n+1]
1. Hash Set
Intuition
Put all elements into a hash set, then scan positive integers starting from 1 until you find one that is missing. This is O(n) time and O(n) space — clean and correct, but uses extra memory.
Algorithm
- Convert
numsto a setseen. - For
i = 1, 2, 3, ...:- If
iis not inseen, returni.
- If
Solution
def firstMissingPositive(nums):
seen = set(nums)
i = 1
while i in seen:
i += 1
return i
print(firstMissingPositive([1, 2, 0])) # 3
print(firstMissingPositive([3, 4, -1, 1])) # 2
print(firstMissingPositive([7, 8, 9, 11, 12])) # 1
Complexity
- Time:
O(n) - Space:
O(n)— the hash set
2. Index Marking (O(1) Space)
Intuition
Key observation (pigeonhole): Given an array of n elements, the first missing positive must be in the range [1, n+1]. Why? If all of 1, 2, ..., n are present, the answer is n+1. If any of them is missing, the answer is at most n.
This means we only care about values in [1, n] — everything else is irrelevant.
The trick: Use the array itself as a hash set. To mark that value v is present, negate the element at index v - 1. After marking, the first index i with a positive value means i + 1 is missing.
Three-phase algorithm:
- Clean up: Replace any value that is not in
[1, n]with a sentinel (e.g.,n+1) so it does not interfere with marking. - Mark: For each value
v = abs(nums[i])in[1, n], negatenums[v - 1]if it is positive (to avoid double-negation). - Scan: The first index
iwithnums[i] > 0meansi + 1is missing. If all are negative, returnn + 1.
Algorithm
- Replace all values
<= 0or> nwithn + 1. - For each
iin rangen:- Let
v = abs(nums[i]). - If
1 <= v <= nandnums[v - 1] > 0: negatenums[v - 1].
- Let
- For each
iin rangen:- If
nums[i] > 0: returni + 1.
- If
- Return
n + 1.
flowchart LR
A(["nums=[3,4,-1,1] n=4"])
A --> B["Phase 1: replace -1→5 → [3,4,5,1]"]
B --> C["Phase 2: mark\n v=3→nums[2]=-5\n v=4→nums[3]=-1\n v=5→out of range skip\n v=1→nums[0]=-3\n result: [-3,4,-5,-1]"]
C --> D["Phase 3: scan\n i=0 negative skip\n i=1 positive! → return 2"]
D --> R(["return 2"])
Solution
def firstMissingPositive(nums):
n = len(nums)
# Phase 1: replace out-of-range values with n+1 (a safe sentinel)
for i in range(n):
if nums[i] <= 0 or nums[i] > n:
nums[i] = n + 1
# Phase 2: mark presence of values 1..n by negating the element at that index
for i in range(n):
v = abs(nums[i])
if 1 <= v <= n and nums[v - 1] > 0:
nums[v - 1] = -nums[v - 1]
# Phase 3: first index with a positive value → that index+1 is missing
for i in range(n):
if nums[i] > 0:
return i + 1
return n + 1
print(firstMissingPositive([1, 2, 0])) # 3
print(firstMissingPositive([3, 4, -1, 1])) # 2
print(firstMissingPositive([7, 8, 9, 11, 12])) # 1
Complexity
- Time:
O(n)— three independent linear passes - Space:
O(1)— we reuse the input array for marking (no extra data structures)
Why the Marking Works
After phase 1, every value is either in [1, n] or the sentinel n+1. In phase 2, we use index v - 1 as a “presence flag” for value v. Negating nums[v-1] records “value v exists” without losing the original value v (we recover it via abs()).
After phase 2:
nums[i] < 0means valuei + 1WAS present in the original array.nums[i] > 0means valuei + 1was NOT present — it is a candidate for the first missing positive.
Common Pitfalls
Double-negation. When marking nums[v-1], check that it is still positive before negating. If it is already negative (marked by a previous duplicate), negating it again would undo the mark. Always check if nums[v - 1] > 0 before negating.
Using abs() in the marking phase. After some elements have been negated, nums[i] may be negative. When reading it as an index target in phase 2, always use abs(nums[i]) to recover the original value.
Off-by-one. Value v maps to index v - 1. Value 1 is at index 0, value n is at index n - 1. Be careful: if v = n + 1, there is no valid index for it — skip it (it was already cleaned up as out-of-range).
Mutating the input. This algorithm modifies nums in place. If the caller needs the original array preserved, make a copy before calling.
Best Time to Buy and Sell Stock
Difficulty: Easy Source: NeetCode
Problem
You are given an array
priceswhereprices[i]is the price of a given stock on theith day. You want to maximize your profit by choosing a single day to buy one stock and choosing a different day in the future to sell that stock. Return the maximum profit you can achieve from this transaction. If you cannot achieve any profit, return0.Example 1: Input: prices = [7,1,5,3,6,4] Output: 5
Example 2: Input: prices = [7,6,4,3,1] Output: 0
Constraints:
- 1 <= prices.length <= 10^5
- 0 <= prices[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sliding Window (Two Pointers) — maintaining a left and right pointer to track a range of interest
- Greedy thinking — making locally optimal choices (always buy at the lowest price seen so far)
1. Brute Force
Intuition
Try every possible pair of buy and sell days. For each buy day i, check every sell day j > i and track the maximum profit. This is conceptually simple but very slow for large inputs.
Algorithm
- For each index
i(buy day), iterate over everyj > i(sell day). - Compute profit as
prices[j] - prices[i]. - Track and return the maximum profit seen. If it never exceeds 0, return 0.
Solution
def maxProfit_brute(prices):
max_profit = 0
n = len(prices)
for i in range(n):
for j in range(i + 1, n):
profit = prices[j] - prices[i]
max_profit = max(max_profit, profit)
return max_profit
# Test cases
print(maxProfit_brute([7, 1, 5, 3, 6, 4])) # 5
print(maxProfit_brute([7, 6, 4, 3, 1])) # 0
print(maxProfit_brute([2, 4, 1])) # 2
Complexity
- Time:
O(n²)— nested loops over all pairs - Space:
O(1)— only tracking a single max value
2. Sliding Window (One Pass)
Intuition
Think of left as the buy pointer and right as the sell pointer. Move right forward one step at a time. Whenever the price at right drops below the price at left, there’s no point holding onto that buy day — just move left to right because we found a cheaper buy price. At each step, calculate the profit and update the maximum. This is sliding window in disguise: we’re maintaining a window [left, right] where prices[left] is the minimum seen so far.
Algorithm
- Initialize
left = 0,right = 1,max_profit = 0. - While
right < len(prices):- If
prices[right] < prices[left], moveleft = right(found cheaper buy). - Otherwise, compute
profit = prices[right] - prices[left]and updatemax_profit. - Advance
right.
- If
- Return
max_profit.
graph LR
A["prices = [7,1,5,3,6,4]"] --> B["L=7,R=1: 1<7, move L→1"]
B --> C["L=1,R=5: profit=4"]
C --> D["L=1,R=3: profit=2"]
D --> E["L=1,R=6: profit=5 ✓ max"]
E --> F["L=1,R=4: profit=3"]
F --> G["return 5"]
Solution
def maxProfit(prices):
left = 0 # buy pointer
right = 1 # sell pointer
max_profit = 0
while right < len(prices):
if prices[right] < prices[left]:
# Found a cheaper day to buy — reset the buy pointer
left = right
else:
profit = prices[right] - prices[left]
max_profit = max(max_profit, profit)
right += 1
return max_profit
# Test cases
print(maxProfit([7, 1, 5, 3, 6, 4])) # 5
print(maxProfit([7, 6, 4, 3, 1])) # 0
print(maxProfit([2, 4, 1])) # 2
Complexity
- Time:
O(n)— single pass through the array - Space:
O(1)— only a handful of variables
Common Pitfalls
Returning negative profit. If prices only decrease, every prices[right] - prices[left] is negative. Initialize max_profit = 0 (not -infinity) so you naturally return 0 when no profitable trade exists.
Selling before buying. The constraint is that you must buy before you sell — left always trails right, so the two-pointer approach naturally enforces this. Never swap or set left > right.
Single element array. With only one price, there’s no sell day, so profit is 0. The while loop condition right < len(prices) handles this without any special casing.
Maximum Subarray
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, find the subarray with the largest sum and return its sum.Example 1: Input:
nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]Output:6Explanation: The subarray[4, -1, 2, 1]has sum6.Example 2: Input:
nums = [1]Output:1Example 3: Input:
nums = [5, 4, -1, 7, 8]Output:23Constraints:
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy thinking — knowing when to abandon a running sum
- Dynamic programming basics —
dp[i]in terms ofdp[i-1]
1. Brute Force
Intuition
Try every possible subarray. For each starting index i, compute the sum of all subarrays starting at i and track the maximum. This is O(n²) — correct but slow.
Algorithm
- Initialise
max_sum = -infinity. - For each
ifrom0ton-1:running = 0- For each
jfromiton-1:running += nums[j]max_sum = max(max_sum, running)
- Return
max_sum.
Solution
def maxSubArray(nums):
max_sum = float('-inf')
for i in range(len(nums)):
running = 0
for j in range(i, len(nums)):
running += nums[j]
max_sum = max(max_sum, running)
return max_sum
print(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4])) # 6
print(maxSubArray([1])) # 1
print(maxSubArray([5, 4, -1, 7, 8])) # 23
print(maxSubArray([-1, -2, -3])) # -1
Complexity
- Time:
O(n²) - Space:
O(1)
2. Kadane’s Algorithm (Greedy / DP)
Intuition
At each index, decide: should we extend the current subarray or start a new one here? If the current running sum is negative, it can only drag down any future subarray — so we drop it and restart from the current element. Otherwise, we extend. This greedy decision is optimal: a negative prefix never helps.
More formally, dp[i] = max(nums[i], dp[i-1] + nums[i]). We only need the previous value, so we use a single variable.
Algorithm
- Initialise
current = nums[0],max_sum = nums[0]. - For each
numinnums[1:]:current = max(num, current + num)— start fresh or extend.max_sum = max(max_sum, current).
- Return
max_sum.
Solution
def maxSubArray(nums):
current = nums[0]
max_sum = nums[0]
for num in nums[1:]:
current = max(num, current + num)
max_sum = max(max_sum, current)
return max_sum
print(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4])) # 6
print(maxSubArray([1])) # 1
print(maxSubArray([5, 4, -1, 7, 8])) # 23
print(maxSubArray([-1, -2, -3])) # -1
print(maxSubArray([-2, -1])) # -1
# Equivalent formulation: drop the prefix when it goes negative
def maxSubArray_v2(nums):
max_sum = nums[0]
current = 0
for num in nums:
current += num
max_sum = max(max_sum, current)
if current < 0:
current = 0 # reset: negative prefix helps nobody
return max_sum
print(maxSubArray_v2([-2, 1, -3, 4, -1, 2, 1, -5, 4])) # 6
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Initialising max_sum = 0. If all elements are negative, the best subarray is the single largest element. Starting at 0 would incorrectly return 0 for an all-negative array. Always initialise with nums[0] or -infinity.
Resetting current to 0 but forgetting to update max_sum first. In the “reset on negative” variant, update max_sum = max(max_sum, current) before resetting current. Otherwise you’ll miss the max that occurred just before the reset.
Confusing this with Maximum Subarray Sum with No Adjacent Elements. That’s a different problem (house robber variant). This problem allows any contiguous subarray, including one of length 1.
Maximum Sum Circular Subarray
Difficulty: Medium Source: NeetCode
Problem
Given a circular integer array
nums(last element wraps to first), return the maximum possible sum of a non-empty subarray ofnums.Example 1: Input:
nums = [1, -2, 3, -2]Output:3Example 2: Input:
nums = [5, -3, 5]Output:10Example 3: Input:
nums = [-3, -2, -3]Output:-2Constraints:
1 <= nums.length <= 3 * 10^4-3 * 10^4 <= nums[i] <= 3 * 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Maximum Subarray (Kadane’s) — the non-circular version is a building block
- Complement thinking — the circular max relates to the total sum and the minimum subarray
1. Brute Force
Intuition
For each starting position i, compute the sum of every subarray that wraps around or doesn’t. To simulate circularity, we can double the array and run Kadane’s on it. But even simpler: for each starting index, sum in a sliding window of length up to n. This is O(n²).
Algorithm
- Double the array:
doubled = nums + nums. - For each start
ifrom0ton-1:- Sum all subarrays
doubled[i:i+k]forkfrom1ton. - Track the maximum.
- Sum all subarrays
- Return the maximum.
Solution
def maxSubarraySumCircular(nums):
n = len(nums)
doubled = nums + nums
max_sum = float('-inf')
for i in range(n):
running = 0
for j in range(i, i + n):
running += doubled[j]
max_sum = max(max_sum, running)
return max_sum
print(maxSubarraySumCircular([1, -2, 3, -2])) # 3
print(maxSubarraySumCircular([5, -3, 5])) # 10
print(maxSubarraySumCircular([-3, -2, -3])) # -2
Complexity
- Time:
O(n²) - Space:
O(n)— doubled array
2. Greedy — Total Sum Minus Minimum Subarray
Intuition
A subarray in a circular array either:
- Does not wrap around — this is just the regular max subarray (Kadane’s).
- Wraps around — the subarray covers the start and end of the array.
For case 2, the elements not in the subarray form a contiguous middle section. So the circular max = total_sum - min_subarray_sum. We want to maximise the kept portion, which means minimising what we throw away.
Run Kadane’s twice:
- Once for max subarray (case 1).
- Once for min subarray (case 2).
Answer = max(max_sub, total - min_sub).
Edge case: if all elements are negative, min_sub == total, meaning we’d be returning 0 for case 2. But the answer must be non-empty, so we take max_sub in that case (the least negative single element).
Algorithm
- Compute
total = sum(nums). - Run Kadane’s for
max_sub. - Run Kadane’s (minimising) for
min_sub. - If
max_sub < 0, returnmax_sub(all negatives edge case). - Return
max(max_sub, total - min_sub).
Solution
def maxSubarraySumCircular(nums):
def kadane_max(arr):
cur = best = arr[0]
for num in arr[1:]:
cur = max(num, cur + num)
best = max(best, cur)
return best
def kadane_min(arr):
cur = best = arr[0]
for num in arr[1:]:
cur = min(num, cur + num)
best = min(best, cur)
return best
total = sum(nums)
max_sub = kadane_max(nums)
min_sub = kadane_min(nums)
# Edge case: all negative, circular answer would be 0 (empty), but must be non-empty
if max_sub < 0:
return max_sub
return max(max_sub, total - min_sub)
print(maxSubarraySumCircular([1, -2, 3, -2])) # 3
print(maxSubarraySumCircular([5, -3, 5])) # 10
print(maxSubarraySumCircular([-3, -2, -3])) # -2
print(maxSubarraySumCircular([3, -1, 2, -1])) # 4
print(maxSubarraySumCircular([3, -2, 2, -3])) # 3
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Returning total - min_sub when all elements are negative. If all elements are negative, min_sub == total, so total - min_sub = 0. But a subarray must be non-empty — the correct answer is the maximum single element (which Kadane’s max gives us). Always check if max_sub < 0.
Using Kadane’s on a doubled array without capping length. If you run Kadane’s on nums + nums without restricting to n elements, you might pick a subarray longer than n — which isn’t a valid circular subarray.
Forgetting that case 2 can’t be the entire array. total - min_sub requires that min_sub be a proper non-empty subarray. If min_sub is the entire array, the “kept” portion is empty — which is invalid. The all-negative edge case catches this.
Longest Turbulent Subarray
Difficulty: Medium Source: NeetCode
Problem
A subarray is turbulent if the comparison sign alternates between each adjacent pair of elements. That is, for
arr[i], arr[i+1], arr[i+2], we need eitherarr[i] > arr[i+1] < arr[i+2]orarr[i] < arr[i+1] > arr[i+2]. Return the length of the longest turbulent subarray.Example 1: Input:
nums = [9, 4, 2, 10, 7, 8, 8, 1, 9]Output:5Explanation:[4, 2, 10, 7, 8]is turbulent.Example 2: Input:
nums = [4, 8, 12, 16]Output:2Example 3: Input:
nums = [100]Output:1Constraints:
1 <= nums.length <= 4 * 10^40 <= nums[i] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sliding Window — expanding and contracting a window based on conditions
- Maximum Subarray / Kadane’s — similar “extend or restart” pattern
1. Brute Force
Intuition
For each starting index, extend the subarray as long as it remains turbulent. Check each new element — if the alternating comparison property breaks, stop and record the length. Try all starting positions and track the maximum.
Algorithm
- For each start
i:- Extend
j = i + 1, i + 2, ...while the subarray is turbulent. - Turbulence breaks when adjacent comparisons don’t alternate.
- Record
j - ias the length.
- Extend
- Return the maximum.
Solution
def maxTurbulenceSize(nums):
n = len(nums)
if n == 1:
return 1
def is_turbulent(arr):
for i in range(1, len(arr) - 1):
if arr[i - 1] < arr[i] and arr[i] < arr[i + 1]:
return False
if arr[i - 1] > arr[i] and arr[i] > arr[i + 1]:
return False
if arr[i - 1] == arr[i] or arr[i] == arr[i + 1]:
return False
return True
max_len = 1
for i in range(n):
for j in range(i + 2, n + 1):
if is_turbulent(nums[i:j]):
max_len = max(max_len, j - i)
return max_len
print(maxTurbulenceSize([9, 4, 2, 10, 7, 8, 8, 1, 9])) # 5
print(maxTurbulenceSize([4, 8, 12, 16])) # 2
print(maxTurbulenceSize([100])) # 1
Complexity
- Time:
O(n³)— outer loops areO(n²), inner check isO(n) - Space:
O(n)
2. Greedy — Sliding Window / Linear Scan
Intuition
Scan left to right. Keep track of the previous comparison direction (prev). If the current pair forms the opposite comparison from the previous (alternating), extend the current turbulent subarray. If they’re equal or continue in the same direction, the turbulence breaks — restart from the current position. This is exactly like Kadane’s “extend or restart” pattern.
Algorithm
- Initialise
max_len = 1,cur_len = 1. - For
ifrom1ton-1:- Compute
cmp:-1ifnums[i] < nums[i-1],1if greater,0if equal. - If
cmp == 0: resetcur_len = 1. - Elif
i == 1orcmp == prev:cur_len = 2(pair is valid but not alternating with before). - Else:
cur_len += 1(successfully alternated). - Update
max_len = max(max_len, cur_len). - Set
prev = cmp.
- Compute
- Return
max_len.
Solution
def maxTurbulenceSize(nums):
n = len(nums)
if n == 1:
return 1
max_len = 1
cur_len = 1
prev = 0 # -1, 0, or 1
for i in range(1, n):
if nums[i] > nums[i - 1]:
cmp = 1
elif nums[i] < nums[i - 1]:
cmp = -1
else:
cmp = 0
if cmp == 0:
cur_len = 1
elif cmp == -prev:
cur_len += 1 # alternated correctly
else:
cur_len = 2 # valid pair but didn't extend turbulence
prev = cmp
max_len = max(max_len, cur_len)
return max_len
print(maxTurbulenceSize([9, 4, 2, 10, 7, 8, 8, 1, 9])) # 5
print(maxTurbulenceSize([4, 8, 12, 16])) # 2
print(maxTurbulenceSize([100])) # 1
print(maxTurbulenceSize([9, 9])) # 1
print(maxTurbulenceSize([2, 0, 4, 3, 4])) # 5
# Cleaner version using sign comparison
def maxTurbulenceSize_v2(nums):
n = len(nums)
max_len = cur_len = 1
for i in range(1, n):
if nums[i] == nums[i - 1]:
cur_len = 1
elif i == 1:
cur_len = 2
elif (nums[i] > nums[i - 1]) != (nums[i - 1] > nums[i - 2]):
cur_len += 1
else:
cur_len = 2
max_len = max(max_len, cur_len)
return max_len
print(maxTurbulenceSize_v2([9, 4, 2, 10, 7, 8, 8, 1, 9])) # 5
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Not resetting to 2 (not 1) when turbulence breaks. When the current pair (nums[i-1], nums[i]) forms a valid comparison but doesn’t continue the alternation, the subarray resets but includes the current pair. The length is 2, not 1.
Treating equal elements as valid turbulence. Two equal adjacent elements immediately break turbulence. Reset cur_len = 1 (not 2) when nums[i] == nums[i-1].
Comparing only adjacent pairs. Turbulence requires alternating comparisons — you need to check each pair against the previous pair’s direction, not just whether the current pair goes up or down.
Find The Duplicate Number
Difficulty: Medium Source: NeetCode
Problem
Given an array of integers
numscontainingn + 1integers where each integer is in the range[1, n]inclusive. There is only one repeated number innums, return this repeated number. You must solve the problem without modifying the arraynumsand using only constant extra space.Example 1: Input: nums = [1,3,4,2,2] Output: 2
Example 2: Input: nums = [3,1,3,4,2] Output: 3
Constraints:
- 1 <= n <= 10^5
- nums.length == n + 1
- 1 <= nums[i] <= n
- There is only one repeated number in nums
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Sets — O(1) average-case lookup for tracking seen elements
- Floyd’s Cycle Detection — slow/fast pointers for detecting cycles (see problem 3)
- Index-as-Pointer Thinking — treating array values as “next pointers” to model a linked list
1. Brute Force — Hash Set
Intuition
Walk through the array. Keep a set of numbers we’ve seen so far. The first number we see twice is the duplicate. Simple, but uses O(n) extra space which violates the optimal constraint.
Algorithm
- Initialize an empty set
seen. - For each number
ninnums:- If
nis already inseen, returnn. - Otherwise, add
ntoseen.
- If
Solution
def findDuplicate_brute(nums):
seen = set()
for n in nums:
if n in seen:
return n
seen.add(n)
return -1 # guaranteed to find one
# Test cases
print(findDuplicate_brute([1, 3, 4, 2, 2])) # 2
print(findDuplicate_brute([3, 1, 3, 4, 2])) # 3
print(findDuplicate_brute([1, 1])) # 1
print(findDuplicate_brute([2, 2, 2, 2, 2])) # 2
Complexity
- Time:
O(n)— single pass - Space:
O(n)— hash set stores up to n elements
2. Floyd’s Cycle Detection — O(1) Space (Optimal)
Intuition
This is the clever one. The key insight: model the array as a linked list where index i points to index nums[i]. Since every value is in [1, n] and there are n+1 elements, there must be a cycle — and the entry point of that cycle is the duplicate number.
Let’s trace [1, 3, 4, 2, 2] (indices 0–4):
Index: 0 1 2 3 4
Value: 1 3 4 2 2
Treat as: node at index i has next pointer to index nums[i]
Start at index 0:
0 -> nums[0]=1 -> nums[1]=3 -> nums[3]=2 -> nums[2]=4 -> nums[4]=2 -> nums[2]=4 -> ...
Cycle: 2 -> 4 -> 2 -> 4 -> ...
Entry point of cycle: index 2 (value 2 is the duplicate!)
The algorithm is exactly Floyd’s cycle detection:
- Find where slow and fast pointers meet (inside the cycle).
- Reset one pointer to the start.
- Move both one step at a time until they meet — that meeting point is the cycle entry = the duplicate.
Why does resetting to start and walking at the same speed find the entry? It’s a mathematical property of Floyd’s algorithm: if the distance from start to cycle entry is F, and the cycle length is C, then after slow and fast meet inside the cycle, moving one pointer from start and one from the meeting point at the same speed, they converge at the cycle entry after F more steps.
Algorithm
- Phase 1 — Find meeting point inside cycle:
slow = nums[0],fast = nums[0]- Move
slow = nums[slow]andfast = nums[nums[fast]] - Repeat until
slow == fast
- Phase 2 — Find cycle entry:
- Reset
slow2 = nums[0](start of the “linked list”) - Move
slow = nums[slow]andslow2 = nums[slow2](both one step) - Repeat until
slow == slow2
- Reset
- Return
slow(the cycle entry = duplicate).
Solution
def findDuplicate(nums):
# Phase 1: Find the intersection point inside the cycle
slow = nums[0]
fast = nums[0]
while True:
slow = nums[slow] # one step
fast = nums[nums[fast]] # two steps
if slow == fast:
break
# Phase 2: Find the entry point of the cycle
slow2 = nums[0]
while slow != slow2:
slow = nums[slow]
slow2 = nums[slow2]
return slow # cycle entry = duplicate
# Test cases
print(findDuplicate([1, 3, 4, 2, 2])) # 2
print(findDuplicate([3, 1, 3, 4, 2])) # 3
print(findDuplicate([1, 1])) # 1
# Trace for [1,3,4,2,2]:
# Index: 0 -> 1 -> 3 -> 2 -> 4 -> 2 (cycle!)
# Phase 1:
# slow: 1, 3, 2, 4, 2, 4 ...
# fast: 3, 4, 4, 4, ... (fast loops in 2->4->2 cycle)
# They meet at 4 (or wherever in the cycle)
# Phase 2: slow2 starts at nums[0]=1, advances until meeting slow
# Larger test
import random
n = 10
nums = list(range(1, n+1)) + [random.randint(1, n)] # add one duplicate
random.shuffle(nums)
print(f"nums={nums}, duplicate={findDuplicate(nums)}")
Complexity
- Time:
O(n)— Floyd’s algorithm runs in linear time - Space:
O(1)— only two pointer variables
Common Pitfalls
Starting slow and fast at index 0 vs value nums[0]. Since index 0 is special (it’s our “entry node” to the linked list), we start both pointers at nums[0] — the first node we actually jump to. Don’t start at index 0 itself or you’ll treat 0 as part of the cycle incorrectly.
Modifying the array to mark visited nodes. A common approach uses negative marking or sorting, but the problem explicitly says you cannot modify the array. Floyd’s approach is the clean O(1) space solution that obeys this constraint.
Using the do-while pattern correctly. Phase 1 uses while True: ... if slow == fast: break because slow and fast start at the same place — we must move them at least once before checking equality.
Confusing index and value. At step i, we’re AT index i and we jump TO index nums[i]. The duplicate number is the index value that two different predecessors both point to — that’s the cycle entry.
Linked Lists
What if instead of buying reserved seats in a theatre (like an array), everyone just holds a ticket that says “the next person is in seat 42”? You don’t need a block of seats together — each person simply points to the next. That’s a linked list.
The Problem with Arrays
Arrays store data in a contiguous block of memory. That’s great for random access (arr[5] is instant), but terrible when you need to insert or delete in the middle — everything has to shuffle along.
flowchart LR
subgraph Array["Array (contiguous memory)"]
direction LR
A0["[0] 10"] --- A1["[1] 20"] --- A2["[2] 30"] --- A3["[3] 40"]
end
Insert 15 after index 0? Every element from index 1 onward must move one position to the right. With a million items, that’s a million moves — O(n).
The Linked List Solution
A linked list breaks free from contiguous memory. Each piece of data lives inside a node, and every node holds two things:
- The value (the actual data)
- A pointer (the memory address of the next node)
flowchart LR
N1["Node\nvalue: 10\nnext: →"] --> N2["Node\nvalue: 20\nnext: →"] --> N3["Node\nvalue: 30\nnext: →"] --> N4["Node\nvalue: 40\nnext: null"]
Nodes can live anywhere in memory — they don’t need to be neighbours. Inserting a new node is just a matter of updating two pointers, regardless of list size.
Array vs Linked List at a Glance
flowchart LR
subgraph LL["Linked List"]
direction TB
LL1["Prepend: O(1)"]
LL2["Append (with tail): O(1)"]
LL3["Access by index: O(n)"]
LL4["Insert / Delete (with ref): O(1)"]
end
subgraph AR["Array"]
direction TB
AR1["Prepend: O(n)"]
AR2["Append (amortised): O(1)"]
AR3["Access by index: O(1)"]
AR4["Insert / Delete (middle): O(n)"]
end
Neither structure is universally better — the right choice depends on your access pattern.
Real-World Linked Lists
Linked lists show up everywhere you need cheap insertions and deletions at the edges:
- Music playlist — each song points to the next; skipping to the next track is just following one pointer
- Browser history — each page visited is a node; the back button follows
prevpointers - Blockchain — each block holds the hash (pointer) of the previous block, chaining them together
- Undo / redo in editors — each change is a node; undo walks backwards through the chain
What’s in This Section
| Page | What You’ll Learn |
|---|---|
| Singly Linked Lists | Nodes that point forward only; append, prepend, delete, search |
| Doubly Linked Lists | Nodes that know both neighbours; O(1) delete, bidirectional traversal |
| Queues | FIFO processing built on a doubly linked list; Python’s deque |
Singly Linked Lists
Twitter’s original feed didn’t load all tweets at once. It kept a pointer to the latest tweet, and each tweet pointed to the one before it. That’s a singly linked list — a chain where every node knows only the next step forward, never back.
The Node: The Fundamental Building Block
Every singly linked list is made of nodes. A node is just a small object that holds two things: its value, and a pointer to the next node.
flowchart LR
N["Node\n──────\nvalue: 42\nnext: ●"] --> M["Node\n──────\nvalue: 99\nnext: null"]
In Python:
class Node:
def __init__(self, value):
self.value = value
self.next = None # points to the next Node, or None if last
# Create two nodes and link them manually
a = Node(42)
b = Node(99)
a.next = b # a now points to b
print(a.value) # 42
print(a.next.value) # 99 — reached b through a's pointer
print(b.next) # None — b is the last node
Chaining Nodes into a List
Once you have nodes, a list just needs to remember where the chain starts (the head) and where it ends (the tail).
flowchart LR
HEAD(["head"]) --> N1["10"] --> N2["20"] --> N3["30"] --> N4["40"]
TAIL(["tail"]) --> N4
Keeping a tail pointer is a small trick with a big payoff — it lets us append in O(1) instead of walking the entire chain every time.
Building a Full Singly Linked List
Let’s implement append, prepend, delete, search, and traverse step by step.
class Node:
def __init__(self, value):
self.value = value
self.next = None
class SinglyLinkedList:
def __init__(self):
self.head = None
self.tail = None
self.length = 0
# O(1) — tail pointer means no walking needed
def append(self, value):
node = Node(value)
if self.head is None:
self.head = self.tail = node
else:
self.tail.next = node
self.tail = node
self.length += 1
# O(1) — just rewire the head pointer
def prepend(self, value):
node = Node(value)
node.next = self.head
self.head = node
if self.tail is None:
self.tail = node
self.length += 1
# O(n) — must walk to find the node before the target
def delete(self, value):
if self.head is None:
return False
if self.head.value == value:
self.head = self.head.next
if self.head is None:
self.tail = None
self.length -= 1
return True
cur = self.head
while cur.next:
if cur.next.value == value:
if cur.next == self.tail:
self.tail = cur
cur.next = cur.next.next
self.length -= 1
return True
cur = cur.next
return False
# O(n) — scan from head until found or end
def search(self, value):
cur = self.head
index = 0
while cur:
if cur.value == value:
return index
cur = cur.next
index += 1
return -1
# O(n) — visit every node once
def traverse(self):
result = []
cur = self.head
while cur:
result.append(cur.value)
cur = cur.next
return result
ll = SinglyLinkedList()
ll.append(20)
ll.append(30)
ll.append(40)
ll.prepend(10)
print("List: ", ll.traverse())
print("Length: ", ll.length)
print("Search 30: index", ll.search(30))
print("Search 99: index", ll.search(99))
ll.delete(30)
print("After delete 30:", ll.traverse())
ll.delete(10) # deleting head
print("After delete 10:", ll.traverse())
What Happens During Prepend (O(1))
Prepending is instant — no matter how long the list is, it’s always just two pointer updates.
Before prepending 5:
flowchart LR
HEAD(["head"]) --> N1["10"] --> N2["20"] --> N3["30"]
TAIL(["tail"]) --> N3
After prepending 5:
flowchart LR
HEAD(["head"]) --> N0["5"] --> N1["10"] --> N2["20"] --> N3["30"]
TAIL(["tail"]) --> N3
Only the head pointer and the new node’s next change. Zero existing nodes are touched.
What Happens During Delete (O(n))
Deletion requires finding the node before the target — that’s the O(n) walk. Once found, it’s a single pointer update.
Before deleting 20:
flowchart LR
HEAD(["head"]) --> N1["10"] --> N2["20"] --> N3["30"] --> N4["40"]
TAIL(["tail"]) --> N4
N1 -. "cur" .-> N2
After deleting 20:
flowchart LR
HEAD(["head"]) --> N1["10"] --> N3["30"] --> N4["40"]
TAIL(["tail"]) --> N4
Node 10’s next pointer skips over 20 and points directly to 30. The deleted node becomes unreachable and is garbage collected.
Time Complexity Summary
| Operation | Time | Why |
|---|---|---|
prepend | O(1) | Rewire head only |
append | O(1) | Tail pointer available |
delete | O(n) | Must find predecessor |
search | O(n) | Sequential scan |
traverse | O(n) | Visit every node |
| Access by index | O(n) | No direct addressing |
Without a tail pointer,
appendwould require walking the entire list each time — makingnappends cost O(n²) total. Always keep atail.
Real-World: Undo History in Text Editors
When you type in a text editor, each keystroke or action is pushed onto a singly linked list. The “undo” operation pops from the head — O(1), instant, regardless of how many changes you’ve made. That’s why undo never slows down no matter how deep your history is.
class Node:
def __init__(self, value):
self.value = value
self.next = None
class UndoHistory:
"""A minimal undo stack built on a singly linked list."""
def __init__(self):
self.head = None
def push(self, action):
node = Node(action)
node.next = self.head
self.head = node
print(f" Did: {action}")
def undo(self):
if self.head is None:
print(" Nothing to undo.")
return
action = self.head.value
self.head = self.head.next
print(f" Undid: {action}")
def current_state(self):
actions = []
cur = self.head
while cur:
actions.append(cur.value)
cur = cur.next
return actions
history = UndoHistory()
history.push("type 'Hello'")
history.push("bold text")
history.push("change font size to 14")
print("History (most recent first):", history.current_state())
history.undo()
history.undo()
print("History after 2 undos:", history.current_state())
Doubly Linked Lists
A singly linked list is a one-way street. You can drive forward, but if you miss a turn you have to go all the way back to the start. A doubly linked list adds a second lane: every node knows both its next neighbour and its previous neighbour. Now you can travel in either direction.
The Doubly-Linked Node
Each node now carries three pieces of information:
flowchart LR
P(["prev: ●"]) --> N["Node\n──────\nvalue: 42\nprev: ←\nnext: →"] --> Q(["next: ●"])
In Python:
class Node:
def __init__(self, value):
self.value = value
self.prev = None # points to the previous node
self.next = None # points to the next node
a = Node(10)
b = Node(20)
c = Node(30)
# Link forward
a.next = b
b.next = c
# Link backward
b.prev = a
c.prev = b
# Traverse forward
cur = a
while cur:
print("forward:", cur.value)
cur = cur.next
# Traverse backward from tail
cur = c
while cur:
print("backward:", cur.value)
cur = cur.prev
The Full Structure
With both head and tail pointers, we can enter the list from either end in O(1).
flowchart LR
HEAD(["head"]) --> N1
N1["10\n← null | 10 | →"] <--> N2["20\n← | 20 | →"] <--> N3["30\n← | 30 | →"] <--> N4["40\n← | 40 | null →"]
TAIL(["tail"]) --> N4
Full Implementation
class Node:
def __init__(self, value):
self.value = value
self.prev = None
self.next = None
class DoublyLinkedList:
def __init__(self):
self.head = None
self.tail = None
self.length = 0
# O(1)
def append(self, value):
node = Node(value)
if self.tail is None:
self.head = self.tail = node
else:
node.prev = self.tail
self.tail.next = node
self.tail = node
self.length += 1
# O(1)
def prepend(self, value):
node = Node(value)
if self.head is None:
self.head = self.tail = node
else:
node.next = self.head
self.head.prev = node
self.head = node
self.length += 1
# O(1) when you already have the node reference
def delete_node(self, node):
if node.prev:
node.prev.next = node.next
else:
self.head = node.next # deleting head
if node.next:
node.next.prev = node.prev
else:
self.tail = node.prev # deleting tail
self.length -= 1
return node.value
# O(1) — tail pointer + prev pointer makes this instant
def delete_tail(self):
if self.tail is None:
return None
return self.delete_node(self.tail)
# O(1)
def delete_head(self):
if self.head is None:
return None
return self.delete_node(self.head)
def traverse_forward(self):
result = []
cur = self.head
while cur:
result.append(cur.value)
cur = cur.next
return result
def traverse_backward(self):
result = []
cur = self.tail
while cur:
result.append(cur.value)
cur = cur.prev
return result
dll = DoublyLinkedList()
for v in [10, 20, 30, 40]:
dll.append(v)
print("Forward: ", dll.traverse_forward())
print("Backward:", dll.traverse_backward())
dll.delete_tail()
print("After delete_tail:", dll.traverse_forward())
dll.delete_head()
print("After delete_head:", dll.traverse_forward())
Why O(1) Delete Is a Big Deal
In a singly linked list, deleting a node requires finding its predecessor — you have to walk from the head. With a doubly linked list, every node already knows its predecessor via prev. If you have a reference to the node, deletion is just four pointer updates, no searching.
Before deleting node 20:
flowchart LR
HEAD(["head"]) --> N1["10"] <--> N2["20"] <--> N3["30"] <--> N4["40"]
TAIL(["tail"]) --> N4
After deleting node 20:
flowchart LR
HEAD(["head"]) --> N1["10"] <--> N3["30"] <--> N4["40"]
TAIL(["tail"]) --> N4
Node 10’s next skips to 30, and node 30’s prev points back to 10. Done in O(1).
Forward and Backward Traversal in Action
class Node:
def __init__(self, value):
self.value = value
self.prev = None
self.next = None
class DoublyLinkedList:
def __init__(self):
self.head = None
self.tail = None
def append(self, value):
node = Node(value)
if self.tail is None:
self.head = self.tail = node
else:
node.prev = self.tail
self.tail.next = node
self.tail = node
def traverse_forward(self):
result, cur = [], self.head
while cur:
result.append(cur.value)
cur = cur.next
return result
def traverse_backward(self):
result, cur = [], self.tail
while cur:
result.append(cur.value)
cur = cur.prev
return result
pages = DoublyLinkedList()
for page in ["google.com", "wikipedia.org", "python.org", "docs.python.org"]:
pages.append(page)
print("Forward (browser history):", pages.traverse_forward())
print("Backward (hitting Back): ", pages.traverse_backward())
Time Complexity Summary
| Operation | Time | Why |
|---|---|---|
prepend | O(1) | Rewire head + new node’s next |
append | O(1) | Tail pointer available |
delete_head | O(1) | head.next becomes new head |
delete_tail | O(1) | tail.prev becomes new tail |
delete_node(node) | O(1) | Node already knows its neighbours |
search | O(n) | Still a sequential scan |
| Traverse forward | O(n) | Visit every node |
| Traverse backward | O(n) | Visit every node via prev |
The extra
prevpointer costs one more memory reference per node. The payoff is O(1) deletion from any position (given the node) and bidirectional traversal.
Real-World Uses
Browser forward/back navigation is the textbook example. Each visited page is a node. The Back button follows prev pointers; the Forward button follows next pointers.
Python’s collections.deque is implemented as a doubly linked list under the hood. That’s why both appendleft/popleft and append/pop are O(1).
LRU Cache (Least Recently Used) is one of the most common interview problems. It combines a doubly linked list with a hash map: the list orders items by recency, and the hash map gives O(1) access to any node. When you access an item, you can unlink it from its current position and relink it at the front — all O(1) thanks to the prev pointer.
# Python's deque IS a doubly linked list
from collections import deque
browser_history = deque()
# Visit pages (append to right = go forward)
for page in ["home", "search", "article", "comments"]:
browser_history.append(page)
print(f"Visited: {page}")
print("\nHistory:", list(browser_history))
# Hit Back button (pop from right)
current = browser_history.pop()
print(f"\nBack from: {current}")
print("History:", list(browser_history))
# Go forward again
browser_history.append(current)
print(f"Forward to: {current}")
print("History:", list(browser_history))
Queues
Picture the checkout line at a supermarket. The first person to join the line is the first person to pay and leave. Nobody jumps the queue. Nobody gets served out of order. That’s a queue: First In, First Out — FIFO.
The FIFO Principle
A queue has exactly two operations:
- Enqueue — add an item to the back of the line
- Dequeue — remove an item from the front of the line
flowchart LR
IN(["enqueue\n(add to back)"]) --> T["TAIL\n────\nTask C"]
T <--> M["Task B"]
M <--> H["HEAD\nTask A"]
H --> OUT(["dequeue\n(remove from front)"])
The item that has waited the longest always gets served next. Items can never skip ahead.
Why a Doubly Linked List Is the Right Tool
You might wonder: why not just use a Python list? The problem is that removing from the front of a list (list.pop(0)) is O(n) — every remaining element must shift one position to the left. With a million items, that’s a million moves per dequeue.
A doubly linked list with a head pointer makes dequeue O(1): just redirect the head pointer to the second node. A tail pointer makes enqueue O(1) too.
| Structure | Enqueue | Dequeue |
|---|---|---|
| Python list | O(1) amortised | O(n) — items shift |
| Singly linked list | O(1) | O(1) |
| Doubly linked list | O(1) | O(1) |
collections.deque | O(1) | O(1) |
Building a Queue from a Doubly Linked List
class Node:
def __init__(self, value):
self.value = value
self.prev = None
self.next = None
class Queue:
def __init__(self):
self.head = None # front — dequeue from here
self.tail = None # back — enqueue here
self.length = 0
def enqueue(self, value):
"""Add to the back. O(1)."""
node = Node(value)
if self.tail is None:
self.head = self.tail = node
else:
node.prev = self.tail
self.tail.next = node
self.tail = node
self.length += 1
def dequeue(self):
"""Remove from the front. O(1)."""
if self.head is None:
return None
value = self.head.value
self.head = self.head.next
if self.head is not None:
self.head.prev = None
else:
self.tail = None # queue is now empty
self.length -= 1
return value
def peek(self):
"""Look at the front item without removing it. O(1)."""
return self.head.value if self.head else None
def is_empty(self):
return self.length == 0
def __repr__(self):
items = []
cur = self.head
while cur:
items.append(str(cur.value))
cur = cur.next
return "front -> [" + ", ".join(items) + "] <- back"
q = Queue()
print("Enqueuing tasks...")
for task in ["print report", "send email", "run backup"]:
q.enqueue(task)
print(f" enqueued: {task!r:20s} queue: {q}")
print("\nProcessing tasks...")
while not q.is_empty():
task = q.dequeue()
print(f" processed: {task!r:20s} queue: {q}")
Enqueue Step by Step
Starting with an empty queue, enqueue A, then B, then C:
After enqueue A:
flowchart LR
HEAD(["head"]) --> NA["A"]
TAIL(["tail"]) --> NA
After enqueue B:
flowchart LR
HEAD(["head"]) --> NA["A"] <--> NB["B"]
TAIL(["tail"]) --> NB
After enqueue C:
flowchart LR
HEAD(["head"]) --> NA["A"] <--> NB["B"] <--> NC["C"]
TAIL(["tail"]) --> NC
Dequeue Step by Step
Dequeue from the above queue (removes A):
flowchart LR
HEAD(["head"]) --> NB["B"] <--> NC["C"]
TAIL(["tail"]) --> NC
GONE["A"] -. "removed" .-> NB
head now points to B. A is unreachable and gets garbage collected.
The Pythonic Way: collections.deque
Python’s standard library includes collections.deque — a battle-hardened doubly linked list (implemented in C). For production code, always prefer it over a hand-rolled queue.
from collections import deque
# Simulate a print spooler
print_spooler = deque()
# Users submit print jobs
print_spooler.append("Alice: budget.pdf")
print_spooler.append("Bob: slides.pptx")
print_spooler.append("Carol: report.docx")
print_spooler.append("Dave: logo.png")
print(f"Jobs in queue: {len(print_spooler)}")
print(f"Next to print: {print_spooler[0]}\n")
# Printer processes jobs one by one
while print_spooler:
job = print_spooler.popleft() # O(1) dequeue
print(f"Printing: {job}")
print("\nAll jobs done. Spooler empty.")
deque also supports appendleft and pop for use as a stack or deque (double-ended queue), but when you only use append + popleft, it behaves as a pure FIFO queue.
Breadth-First Search: Queues in Algorithms
One of the most important uses of a queue is Breadth-First Search (BFS) — the algorithm that finds shortest paths in unweighted graphs. BFS explores all neighbours of the current node before moving deeper, and a queue is what enforces that “process closest nodes first” ordering.
from collections import deque
def bfs(graph, start):
"""
Find the shortest number of hops from 'start' to every reachable node.
Returns a dict: {node: distance_from_start}
"""
visited = {start: 0}
queue = deque([start])
while queue:
node = queue.popleft() # process the oldest (closest) node
for neighbour in graph[node]:
if neighbour not in visited:
visited[neighbour] = visited[node] + 1
queue.append(neighbour) # explore it later
return visited
# A simple social network: who follows whom
network = {
"Alice": ["Bob", "Carol"],
"Bob": ["Alice", "Dave", "Eve"],
"Carol": ["Alice", "Frank"],
"Dave": ["Bob"],
"Eve": ["Bob"],
"Frank": ["Carol"],
}
distances = bfs(network, "Alice")
print("Degrees of separation from Alice:")
for person, hops in sorted(distances.items(), key=lambda x: x[1]):
print(f" {person}: {hops} hop(s)")
Real-World Queues
| Use Case | What Gets Queued | Why FIFO Matters |
|---|---|---|
| Print spooler | Print jobs | Documents print in submission order |
| CPU task scheduling | OS processes | Fair allocation of CPU time |
| Message queues (Kafka, RabbitMQ) | Events / messages | Consumers see events in order produced |
| BFS graph traversal | Nodes to visit | Shortest path guarantee requires level-order processing |
| Web server request handling | HTTP requests | Requests handled in arrival order |
| Keyboard input buffer | Keystrokes | Characters appear in the order they were typed |
Time Complexity Summary
| Operation | Time | Notes |
|---|---|---|
enqueue | O(1) | Append to tail |
dequeue | O(1) | Remove from head |
peek | O(1) | Read head value |
is_empty | O(1) | Check length |
| Access by index | O(n) | Queues aren’t for random access |
Fast and Slow Pointers
Imagine two runners on a circular track — one jogs at a steady pace while the other sprints at double the speed. If the track loops, the sprinter will eventually lap the jogger and they will meet. If the track is straight with a finish line, the sprinter just reaches the end first. This elegant insight is the heart of Floyd’s Cycle Detection Algorithm, and it works just as beautifully on linked lists.
The fast/slow pointer pattern (also called the “tortoise and hare” technique) is one of the most satisfying tricks in computer science: two pointers, one constraint, and a surprising number of problems solved.
How It Works
Two pointers start at the head of a linked list:
- Slow pointer — advances 1 node per step
- Fast pointer — advances 2 nodes per step
Their relative behaviour tells us everything:
| Situation | What happens |
|---|---|
| List has a cycle | Fast laps slow — they meet inside the cycle |
| List has no cycle | Fast reaches None — exits cleanly |
| Finding the middle | When fast hits the end, slow is exactly in the middle |
graph LR
A[Head] --> B[1] --> C[2] --> D[3] --> E[4] --> F[5]
F --> C
style A fill:#f5f5f5,stroke:#999
style C fill:#ffd700,stroke:#e6b800
style F fill:#ff6b6b,stroke:#cc0000
In the diagram above, node 5 points back to node 2, creating a cycle. The fast pointer enters the cycle first, keeps circling, and eventually collides with the slow pointer.
Problem 1: Detect a Cycle (Floyd’s Algorithm)
Real-world analogue: Memory leak detection — a garbage collector needs to know if a reference chain loops back on itself rather than terminating. Operating systems also use this to detect infinite loops in state machines.
class Node:
def __init__(self, val):
self.val = val
self.next = None
def has_cycle(head):
slow = head
fast = head
while fast is not None and fast.next is not None:
slow = slow.next # 1 step
fast = fast.next.next # 2 steps
if slow is fast: # same object in memory
return True
return False # fast reached the end — no cycle
# --- Build a list WITHOUT a cycle: 1 -> 2 -> 3 -> 4 -> 5 ---
nodes = [Node(i) for i in range(1, 6)]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
print("No cycle:", has_cycle(nodes[0])) # False
# --- Build a list WITH a cycle: 1 -> 2 -> 3 -> 4 -> 5 -> (back to 2) ---
nodes[4].next = nodes[1] # 5 points back to 2
print("With cycle:", has_cycle(nodes[0])) # True
Why does this work? Once the fast pointer enters a cycle of length L, it is “trapped”. Each step the gap between fast and slow changes by 1 (fast gains 2, slow gains 1, so fast gains 1 on slow relative to the cycle). After at most L steps inside the cycle they are guaranteed to occupy the same node.
Complexity:
- Time: O(n) — fast pointer visits each node at most twice before either exiting or meeting slow
- Space: O(1) — just two pointer variables, no extra data structures
Problem 2: Find the Start of the Cycle
Knowing a cycle exists is useful. Knowing where it starts is even more useful — for example, finding the exact point of a circular reference in a dependency graph.
The mathematical trick: When slow and fast first meet, reset one pointer to the head and keep the other at the meeting point. Now advance both one step at a time. They will meet at the cycle’s entry node.
class Node:
def __init__(self, val):
self.val = val
self.next = None
def find_cycle_start(head):
slow = head
fast = head
meeting_point = None
# Phase 1: detect the cycle
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast:
meeting_point = slow
break
if meeting_point is None:
return None # no cycle
# Phase 2: find cycle entry
pointer1 = head
pointer2 = meeting_point
while pointer1 is not pointer2:
pointer1 = pointer1.next
pointer2 = pointer2.next
return pointer1 # both arrive at cycle start simultaneously
# Build: 0 -> 1 -> 2 -> 3 -> 4 -> 5 -> (back to 2)
nodes = [Node(i) for i in range(6)]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
nodes[5].next = nodes[2] # cycle entry is node with value 2
start = find_cycle_start(nodes[0])
print("Cycle starts at node with value:", start.val) # 2
# Build: 0 -> 1 -> 2 -> 3 -> (back to 0, full cycle)
nodes2 = [Node(i) for i in range(4)]
for i in range(len(nodes2) - 1):
nodes2[i].next = nodes2[i + 1]
nodes2[3].next = nodes2[0] # cycle entry is node with value 0
start2 = find_cycle_start(nodes2[0])
print("Cycle starts at node with value:", start2.val) # 0
Why does Phase 2 work? Let F = distance from head to cycle start, C = cycle length, k = distance from cycle start to meeting point. It can be proven that F = C - k, which means a pointer from the head and a pointer from the meeting point, both moving one step at a time, will travel the same distance before reaching the cycle entry node.
Complexity:
- Time: O(n)
- Space: O(1)
Problem 3: Find the Middle of a Linked List
When fast reaches the end (None or the last node), slow is exactly at the middle. This is useful for merge sort on linked lists — split the list at the middle and sort each half recursively.
class Node:
def __init__(self, val):
self.val = val
self.next = None
def find_middle(head):
slow = head
fast = head
# Fast moves 2 steps; when it can't, slow is at middle
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
return slow
def build_list(values):
if not values:
return None
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
return nodes[0]
def list_to_str(head):
result = []
while head:
result.append(str(head.val))
head = head.next
return " -> ".join(result)
# Odd length: 1 -> 2 -> 3 -> 4 -> 5
head_odd = build_list([1, 2, 3, 4, 5])
mid = find_middle(head_odd)
print(f"List: {list_to_str(head_odd)}")
print(f"Middle node: {mid.val}") # 3
print()
# Even length: 1 -> 2 -> 3 -> 4
head_even = build_list([1, 2, 3, 4])
mid = find_middle(head_even)
print(f"List: {list_to_str(head_even)}")
print(f"Middle node: {mid.val}") # 3 (second of the two middle nodes)
Complexity:
- Time: O(n)
- Space: O(1)
Problem 4: Check if a Linked List Length is Even or Odd
A neat side effect of the two-pointer dance: when fast exits the loop, its final position reveals the parity of the list’s length.
- If
fast is Noneafter the loop → fast took a full last step and “fell off” → even number of nodes - If
fast.next is None→ fast landed on the last node exactly → odd number of nodes
class Node:
def __init__(self, val):
self.val = val
self.next = None
def is_length_even(head):
fast = head
while fast is not None and fast.next is not None:
fast = fast.next.next
# If fast is None, we exhausted the list evenly
return fast is None
def build_list(values):
if not values:
return None
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
return nodes[0]
for length in [1, 2, 3, 4, 5, 6]:
head = build_list(list(range(length)))
parity = "even" if is_length_even(head) else "odd"
print(f"Length {length}: {parity}")
Complexity:
- Time: O(n)
- Space: O(1)
Putting It All Together: Merge Sort on a Linked List
Here is a complete runnable merge sort that uses find_middle to split the list — the canonical real-world use of finding the middle node.
class Node:
def __init__(self, val):
self.val = val
self.next = None
def find_middle(head):
slow = head
fast = head
prev = None
while fast is not None and fast.next is not None:
prev = slow
slow = slow.next
fast = fast.next.next
if prev:
prev.next = None # split the list at the middle
return slow
def merge(left, right):
dummy = Node(0)
tail = dummy
while left and right:
if left.val <= right.val:
tail.next = left
left = left.next
else:
tail.next = right
right = right.next
tail = tail.next
tail.next = left if left else right
return dummy.next
def merge_sort(head):
if head is None or head.next is None:
return head
mid = find_middle(head)
left = merge_sort(head) # head's list is now cut at mid
right = merge_sort(mid)
return merge(left, right)
def build_list(values):
if not values:
return None
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
return nodes[0]
def list_to_python(head):
result = []
while head:
result.append(head.val)
head = head.next
return result
import random
random.seed(42)
values = random.sample(range(100), 10)
print("Before:", values)
head = build_list(values)
sorted_head = merge_sort(head)
print("After: ", list_to_python(sorted_head))
Complexity Summary
| Problem | Time | Space | Key insight |
|---|---|---|---|
| Detect cycle | O(n) | O(1) | Fast always catches slow inside a cycle |
| Find cycle start | O(n) | O(1) | F = C - k mathematical proof |
| Find middle | O(n) | O(1) | Fast ends when slow is at centre |
| Even/odd length | O(n) | O(1) | Final position of fast |
Real-World Applications
- Memory leak detection — garbage collectors and tools like Valgrind track reference chains; a cycle means an object can never be freed.
- Infinite loop detection in state machines — a finite state machine that cycles indefinitely without reaching an exit state is broken; fast/slow can detect this in O(states) time.
- Merge sort on linked lists — finding the midpoint without knowing the length in advance is exactly what
find_middledoes; used in real standard library implementations. - Network packet routing — detecting routing loops where a packet bounces between routers infinitely.
- Functional programming — lazy infinite lists and stream processing use cycle detection to identify when a generator has looped back.
Linked List Problems
Practice problems for the Linked Lists section, covering pointer manipulation, cycle detection, and cache-style designs.
Reverse Linked List
Difficulty: Easy Source: NeetCode
Problem
Given the head of a singly linked list, reverse the list, and return the reversed list.
Example 1: Input: head = [1,2,3,4,5] Output: [5,4,3,2,1]
Example 2: Input: head = [1,2] Output: [2,1]
Constraints:
- The number of nodes in the list is in the range [0, 5000]
- -5000 <= Node.val <= 5000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Linked List Traversal — walking a linked list node by node using
.next - Pointer Manipulation — re-assigning
.nextreferences to change list structure
1. Brute Force / Naive Approach
Intuition
The simplest idea is to not think about pointers at all. Just walk the list, collect all the values into a Python list, reverse that list, then rebuild a brand new linked list. It wastes memory but it’s easy to reason about and a great starting point.
Algorithm
- Walk the original list and collect all node values into a Python list.
- Reverse the Python list.
- Build a new linked list from the reversed values and return its head.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
# Brute force: collect values, rebuild reversed
def reverseList_brute(head):
values = []
cur = head
while cur:
values.append(cur.val)
cur = cur.next
values.reverse()
dummy = ListNode(0)
cur = dummy
for v in values:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Input: ", end=""); print_list(head)
result = reverseList_brute(head)
print("Output: ", end=""); print_list(result) # 5 -> 4 -> 3 -> 2 -> 1
head2 = build_list([1, 2])
result2 = reverseList_brute(head2)
print("Output: ", end=""); print_list(result2) # 2 -> 1
head3 = build_list([])
result3 = reverseList_brute(head3)
print("Output: ", end=""); print_list(result3) # None
Complexity
- Time:
O(n)— two passes over the list - Space:
O(n)— storing all values in a Python list
2. Iterative In-Place (Optimal)
Intuition
Instead of collecting values, we can flip the .next pointers directly as we walk the list. We keep track of two pointers: prev (starts as None) and curr (starts at head). At each step, we save curr.next, point curr.next back to prev, then advance both pointers forward. When curr falls off the end, prev is the new head.
Initial: None <- 1 -> 2 -> 3 -> 4 -> 5
After 1: None <- 1 <- 2 3 -> 4 -> 5
After 2: None <- 1 <- 2 <- 3 4 -> 5
...
Final: None <- 1 <- 2 <- 3 <- 4 <- 5
Algorithm
- Initialize
prev = Noneandcurr = head. - While
curris notNone: a. Savenext_node = curr.next. b. Pointcurr.next = prev(reverse the arrow). c. Moveprev = curr. d. Movecurr = next_node. - Return
prevas the new head.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def reverseList(head):
prev = None
curr = head
while curr:
next_node = curr.next # save next before we overwrite it
curr.next = prev # flip the pointer
prev = curr # advance prev
curr = next_node # advance curr
return prev # prev is now the new head
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Input: ", end=""); print_list(head)
result = reverseList(head)
print("Output: ", end=""); print_list(result) # 5 -> 4 -> 3 -> 2 -> 1
head2 = build_list([1, 2])
print("Output: ", end=""); print_list(reverseList(head2)) # 2 -> 1
print("Output: ", end=""); print_list(reverseList(None)) # None
Complexity
- Time:
O(n)— single pass - Space:
O(1)— only two pointer variables
3. Recursive Approach
Intuition
Recursion is elegant here: to reverse a list, reverse everything after the head, then make the second node point back to the head and detach the head’s .next. The base case is an empty list or single node — that’s already reversed.
Think of it this way: if we call reverseList([2,3,4,5]) and it gives us back 5->4->3->2, then 2.next still points to 3. We want 3 to point back to 1… actually we want head.next.next = head and head.next = None.
Algorithm
- Base case: if
headisNoneorhead.nextisNone, returnhead. - Recurse on
head.next— this reverses the rest and returns the new head. - Set
head.next.next = head(make the node after head point back to head). - Set
head.next = None(detach head from the old chain). - Return the new head from step 2.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def reverseList_recursive(head):
# Base case: empty list or single node
if not head or not head.next:
return head
# Reverse the rest of the list
new_head = reverseList_recursive(head.next)
# head.next is now the tail of the reversed sublist
# Make it point back to head
head.next.next = head
head.next = None # head becomes the new tail
return new_head
# Test cases
head = build_list([1, 2, 3, 4, 5])
result = reverseList_recursive(head)
print("Output: ", end=""); print_list(result) # 5 -> 4 -> 3 -> 2 -> 1
head2 = build_list([1, 2])
print("Output: ", end=""); print_list(reverseList_recursive(head2)) # 2 -> 1
print("Output: ", end=""); print_list(reverseList_recursive(None)) # None
Complexity
- Time:
O(n)— visits every node once - Space:
O(n)— call stack depth equals list length
Common Pitfalls
Forgetting to save curr.next before overwriting it. Once you do curr.next = prev, you’ve lost your reference to the rest of the list. Always save next_node = curr.next first.
Returning curr instead of prev. When the loop ends, curr is None and prev is the last node you processed — which is the new head. Returning curr gives back None.
Forgetting head.next = None in the recursive approach. Without this, the original head still points forward, creating a cycle in your result.
Merge Two Sorted Lists
Difficulty: Easy Source: NeetCode
Problem
You are given the heads of two sorted linked lists
list1andlist2. Merge the two lists into one sorted list. The list should be made by splicing together the nodes of the first two lists. Return the head of the merged linked list.Example 1: Input: list1 = [1,2,4], list2 = [1,3,4] Output: [1,1,2,3,4,4]
Example 2: Input: list1 = [], list2 = [] Output: []
Constraints:
- The number of nodes in both lists is in the range [0, 50]
- -100 <= Node.val <= 100
- Both list1 and list2 are sorted in non-decreasing order
Prerequisites
Before attempting this problem, you should be comfortable with:
- Linked List Traversal — walking a list node by node using
.next - Dummy Head Trick — using a sentinel node to simplify edge cases in list construction
- Two-Pointer Technique — advancing two pointers independently based on a comparison
1. Brute Force / Naive Approach
Intuition
Forget about the linked list structure for a moment. Collect all values from both lists into a Python list, sort it, then rebuild a linked list. It’s straightforward but wastes memory and ignores the fact that both inputs are already sorted.
Algorithm
- Walk
list1and collect all values. - Walk
list2and collect all values. - Sort the combined values.
- Build a new linked list from the sorted values and return its head.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def mergeTwoLists_brute(list1, list2):
values = []
cur = list1
while cur:
values.append(cur.val)
cur = cur.next
cur = list2
while cur:
values.append(cur.val)
cur = cur.next
values.sort()
dummy = ListNode(0)
cur = dummy
for v in values:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
# Test cases
l1 = build_list([1, 2, 4])
l2 = build_list([1, 3, 4])
print("Output: ", end=""); print_list(mergeTwoLists_brute(l1, l2)) # 1->1->2->3->4->4
print("Output: ", end=""); print_list(mergeTwoLists_brute(None, None)) # None
l3 = build_list([])
l4 = build_list([0])
print("Output: ", end=""); print_list(mergeTwoLists_brute(l3, l4)) # 0
Complexity
- Time:
O((m+n) log(m+n))— sorting all values - Space:
O(m+n)— storing all values
2. Iterative with Dummy Head (Optimal)
Intuition
Since both lists are already sorted, we can merge them in one pass using the classic merge step from merge sort. We compare the heads of both lists, pick the smaller one, attach it to our result, and advance that list’s pointer. A dummy head node makes it easy to handle the “empty result” edge case — we just return dummy.next at the end.
list1: 1 -> 2 -> 4
list2: 1 -> 3 -> 4
Step 1: compare 1 vs 1 → pick list1's 1, result: 1
Step 2: compare 2 vs 1 → pick list2's 1, result: 1 -> 1
Step 3: compare 2 vs 3 → pick list1's 2, result: 1 -> 1 -> 2
Step 4: compare 4 vs 3 → pick list2's 3, result: 1 -> 1 -> 2 -> 3
Step 5: compare 4 vs 4 → pick list1's 4, result: 1 -> 1 -> 2 -> 3 -> 4
Step 6: list1 exhausted → append rest of list2 (just 4)
Final: 1 -> 1 -> 2 -> 3 -> 4 -> 4
Algorithm
- Create a
dummysentinel node and setcur = dummy. - While both
list1andlist2are notNone:- If
list1.val <= list2.val, attachlist1and advancelist1. - Otherwise, attach
list2and advancelist2. - Advance
cur.
- If
- Attach whichever list still has remaining nodes.
- Return
dummy.next.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def mergeTwoLists(list1, list2):
dummy = ListNode(0) # sentinel node
cur = dummy
while list1 and list2:
if list1.val <= list2.val:
cur.next = list1
list1 = list1.next
else:
cur.next = list2
list2 = list2.next
cur = cur.next
# Attach any remaining nodes (at most one list has leftover)
cur.next = list1 if list1 else list2
return dummy.next
# Test cases
l1 = build_list([1, 2, 4])
l2 = build_list([1, 3, 4])
print("Output: ", end=""); print_list(mergeTwoLists(l1, l2)) # 1->1->2->3->4->4
print("Output: ", end=""); print_list(mergeTwoLists(None, None)) # None
l3 = build_list([])
l4 = build_list([0])
print("Output: ", end=""); print_list(mergeTwoLists(l3, l4)) # 0
l5 = build_list([1, 3, 5])
l6 = build_list([2, 4, 6])
print("Output: ", end=""); print_list(mergeTwoLists(l5, l6)) # 1->2->3->4->5->6
Complexity
- Time:
O(m+n)— single pass through both lists - Space:
O(1)— we reuse existing nodes, only allocate the dummy node
Common Pitfalls
Not handling the case when one list is exhausted early. The while loop exits as soon as either list is empty. You need cur.next = list1 if list1 else list2 to attach what’s left — don’t try to loop again.
Forgetting the dummy node. Without a dummy, you’d need special-case logic for setting the initial head. The dummy node makes the code uniform — every node is added via cur.next.
Using <= vs < in the comparison. Using <= means when values are equal, we prefer list1. This is fine for correctness but matters if you’re asked to be stable — either way, both lists get fully consumed.
Linked List Cycle
Difficulty: Easy Source: NeetCode
Problem
Given
head, the head of a linked list, determine if the linked list has a cycle in it. There is a cycle if some node in the list can be reached again by continuously following thenextpointer. Internally,posis used to denote the index of the node that tail’snextpointer is connected to (0-indexed). Note thatposis not passed as a parameter. Returntrueif there is a cycle in the linked list, otherwise returnfalse.Example 1: Input: head = [3,2,0,-4], pos = 1 Output: true
Example 2: Input: head = [1,2], pos = 0 Output: false
Constraints:
- The number of nodes in the list is in the range [0, 10^4]
- -10^5 <= Node.val <= 10^5
posis -1 or a valid index in the linked list
Prerequisites
Before attempting this problem, you should be comfortable with:
- Linked List Traversal — following
.nextpointers through a list - Hash Sets — O(1) lookup for tracking visited elements
- Two-Pointer Technique — using slow and fast pointers moving at different speeds
1. Brute Force / Naive Approach
Intuition
Keep a hash set of every node we’ve visited. As we walk the list, if we ever encounter a node we’ve already seen, there must be a cycle. If we reach None, the list is finite and has no cycle. The catch: we’re storing node references (not values, since values can repeat) in the set.
Algorithm
- Initialize an empty set
visited. - Walk the list with pointer
cur. - If
curis already invisited, returnTrue. - Add
curtovisitedand advance tocur.next. - If the loop ends (we hit
None), returnFalse.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list_with_cycle(values, pos):
"""Build a linked list with an optional cycle.
pos = -1 means no cycle; otherwise tail.next points to node at index pos."""
if not values:
return None
nodes = [ListNode(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
if pos != -1:
nodes[-1].next = nodes[pos] # create cycle
return nodes[0]
def hasCycle_brute(head):
visited = set()
cur = head
while cur:
if cur in visited:
return True
visited.add(cur)
cur = cur.next
return False
# Test cases
head1 = build_list_with_cycle([3, 2, 0, -4], pos=1)
print(hasCycle_brute(head1)) # True
head2 = build_list_with_cycle([1, 2], pos=0)
print(hasCycle_brute(head2)) # True
head3 = build_list_with_cycle([1], pos=-1)
print(hasCycle_brute(head3)) # False
head4 = build_list_with_cycle([3, 2, 0, -4], pos=-1)
print(hasCycle_brute(head4)) # False
Complexity
- Time:
O(n)— visit each node at most once - Space:
O(n)— storing all visited nodes in the set
2. Floyd’s Cycle Detection (Optimal)
Intuition
This is the classic “tortoise and hare” algorithm. Use two pointers: slow moves one step at a time, fast moves two steps at a time. If there’s no cycle, fast will hit None and we’re done. If there is a cycle, both pointers end up going around the loop — and since fast is lapping slow, they will eventually meet at the same node.
Think of it like a circular running track: if two runners start at the same point and one runs twice as fast, the faster runner will eventually lap the slower one and they’ll be at the same position again.
No cycle:
slow: 1 -> 2 -> 3 -> None (fast gets to None first, exit)
With cycle [3,2,0,-4], cycle at index 1:
Step 0: slow=3, fast=3
Step 1: slow=2, fast=0
Step 2: slow=0, fast=2
Step 3: slow=-4, fast=-4 <-- they meet! cycle confirmed
Algorithm
- Initialize
slow = headandfast = head. - While
fastandfast.nextare notNone:- Move
slowone step:slow = slow.next. - Move
fasttwo steps:fast = fast.next.next. - If
slow == fast, returnTrue.
- Move
- Return
False(fast reached the end, no cycle).
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list_with_cycle(values, pos):
if not values:
return None
nodes = [ListNode(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
if pos != -1:
nodes[-1].next = nodes[pos]
return nodes[0]
def hasCycle(head):
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
# Test cases
head1 = build_list_with_cycle([3, 2, 0, -4], pos=1)
print(hasCycle(head1)) # True
head2 = build_list_with_cycle([1, 2], pos=0)
print(hasCycle(head2)) # True
head3 = build_list_with_cycle([1], pos=-1)
print(hasCycle(head3)) # False
head4 = build_list_with_cycle([3, 2, 0, -4], pos=-1)
print(hasCycle(head4)) # False
head5 = build_list_with_cycle([], pos=-1)
print(hasCycle(head5)) # False
Complexity
- Time:
O(n)— both pointers traverse at most O(n) steps before meeting or exiting - Space:
O(1)— only two pointer variables, no extra data structures
Common Pitfalls
Checking slow == fast before moving them. Both start at head, so they’re equal initially — don’t check before moving or you’ll always return True. The check should be inside the loop after advancing.
Checking fast.next in the while condition. You need fast and fast.next because you’re jumping two steps: fast.next.next. If you only check fast, you’ll get an AttributeError when fast is the last node and you try to access fast.next.next.
Storing node values instead of node references in the brute force. Values can repeat! [1, 1, 1] with no cycle would incorrectly return True. Always add the node object itself to the set, not its .val.
Reorder List
Difficulty: Medium Source: NeetCode
Problem
You are given the head of a singly linked-list: L0 → L1 → … → Ln-1 → Ln. Reorder it to: L0 → Ln → L1 → Ln-1 → L2 → Ln-2 → … You may not modify the values in the list’s nodes. Only nodes themselves may be changed.
Example 1: Input: head = [1,2,3,4] Output: [1,4,2,3]
Example 2: Input: head = [1,2,3,4,5] Output: [1,5,2,4,3]
Constraints:
- The number of nodes in the list is in the range [1, 5 * 10^4]
- 1 <= Node.val <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Slow/Fast Pointer (Middle of List) — finding the midpoint of a linked list
- Reversing a Linked List — in-place pointer reversal (see problem 1)
- Merging Two Lists — interleaving nodes from two lists
1. Brute Force / Naive Approach
Intuition
Collect all the nodes into a Python list (by reference, not by value). Then use two pointers — one from the front, one from the back — to pick nodes alternately and re-link them. This avoids the three-step trick but uses O(n) extra space.
Algorithm
- Collect all nodes into a Python list
nodes. - Use
left = 0andright = len(nodes) - 1pointers. - While
left < right:- Link
nodes[left].next = nodes[right]. - Advance
left. - If
left == right, break (odd-length middle node). - Link
nodes[right].next = nodes[left]. - Advance
rightbackward.
- Link
- Set
nodes[left].next = None(terminate the list).
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def reorderList_brute(head):
if not head:
return
nodes = []
cur = head
while cur:
nodes.append(cur)
cur = cur.next
left, right = 0, len(nodes) - 1
while left < right:
nodes[left].next = nodes[right]
left += 1
if left == right:
break
nodes[right].next = nodes[left]
right -= 1
nodes[left].next = None # terminate
# Test cases
head = build_list([1, 2, 3, 4])
reorderList_brute(head)
print("Output: ", end=""); print_list(head) # 1 -> 4 -> 2 -> 3
head2 = build_list([1, 2, 3, 4, 5])
reorderList_brute(head2)
print("Output: ", end=""); print_list(head2) # 1 -> 5 -> 2 -> 4 -> 3
head3 = build_list([1])
reorderList_brute(head3)
print("Output: ", end=""); print_list(head3) # 1
Complexity
- Time:
O(n)— one pass to collect, one pass to re-link - Space:
O(n)— storing all node references
2. Three-Step In-Place (Optimal)
Intuition
The key insight is that the reordering pattern is: take from the front, take from the back, alternate. That means we’re interleaving the first half with the reversed second half. So the plan is:
- Find the middle using slow/fast pointers.
- Reverse the second half in-place.
- Merge the two halves by interleaving.
Let’s trace [1,2,3,4,5]:
Step 1 - Find middle:
slow/fast start at 1
slow=2, fast=3
slow=3, fast=5 (fast.next is None, stop)
Middle = node 3
First half: 1 -> 2 -> 3 -> None
Second half: 4 -> 5 -> None
Step 2 - Reverse second half:
4 -> 5 -> None becomes 5 -> 4 -> None
Step 3 - Merge:
Take 1 from first, take 5 from second
Take 2 from first, take 4 from second
Take 3 from first (middle, second is exhausted)
Result: 1 -> 5 -> 2 -> 4 -> 3
Algorithm
- Find the middle node using slow/fast pointers.
- Split the list:
mid.next = Noneterminates the first half. - Reverse the second half.
- Merge first and second halves by interleaving: take one from each, alternating, until one is exhausted.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def reorderList(head):
if not head or not head.next:
return
# Step 1: Find the middle using slow/fast pointers
slow, fast = head, head
while fast.next and fast.next.next:
slow = slow.next
fast = fast.next.next
# slow is now the middle node
# Step 2: Reverse the second half
second = slow.next
slow.next = None # cut the list in half
prev = None
while second:
tmp = second.next
second.next = prev
prev = second
second = tmp
second = prev # second now points to the reversed second half
# Step 3: Merge the two halves by interleaving
first = head
while second:
tmp1 = first.next
tmp2 = second.next
first.next = second
second.next = tmp1
first = tmp1
second = tmp2
# Test cases
head = build_list([1, 2, 3, 4])
reorderList(head)
print("Output: ", end=""); print_list(head) # 1 -> 4 -> 2 -> 3
head2 = build_list([1, 2, 3, 4, 5])
reorderList(head2)
print("Output: ", end=""); print_list(head2) # 1 -> 5 -> 2 -> 4 -> 3
head3 = build_list([1])
reorderList(head3)
print("Output: ", end=""); print_list(head3) # 1
head4 = build_list([1, 2])
reorderList(head4)
print("Output: ", end=""); print_list(head4) # 1 -> 2
Complexity
- Time:
O(n)— three linear passes (find middle, reverse, merge) - Space:
O(1)— all operations are in-place
Common Pitfalls
Wrong middle for even-length lists. For [1,2,3,4], the middle should be node 2 (so first half is [1,2] and second is [3,4]). Use fast.next and fast.next.next as the condition — this stops slow at node 2 for even lists.
Forgetting to cut the list in half. If you don’t set slow.next = None, the first half still connects into the second half. After reversing the second half you’ll have a messy loop.
Losing pointers during the merge. During interleaving you overwrite .next pointers. Always save first.next and second.next into temporaries before re-linking.
Remove Nth Node From End of List
Difficulty: Medium Source: NeetCode
Problem
Given the head of a linked list, remove the
nthnode from the end of the list and return its head.Example 1: Input: head = [1,2,3,4,5], n = 2 Output: [1,2,3,5]
Example 2: Input: head = [1], n = 1 Output: []
Example 3: Input: head = [1,2], n = 1 Output: [1]
Constraints:
- The number of nodes in the list is
sz- 1 <= sz <= 30
- 0 <= Node.val <= 100
- 1 <= n <= sz
Prerequisites
Before attempting this problem, you should be comfortable with:
- Linked List Traversal — walking a list and counting nodes
- Two-Pointer Technique — maintaining a gap between two pointers
- Dummy Head Trick — using a sentinel node to handle edge cases like removing the head
1. Brute Force / Naive Approach
Intuition
First, figure out the total length of the list. Then the nth node from the end is at position length - n from the start (0-indexed). Walk to that position and remove it. Straightforward and easy to reason about, but requires two full passes.
Algorithm
- Walk the list to get its length
L. - The target node is at index
L - n(0-indexed from head). - Walk to index
L - n - 1(the node just before the target). - Skip the target:
prev.next = prev.next.next. - Handle the special case where the target is the head itself (
L - n == 0).
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def removeNthFromEnd_brute(head, n):
# First pass: get length
length = 0
cur = head
while cur:
length += 1
cur = cur.next
# The node to remove is at index (length - n) from the start (0-indexed)
target_idx = length - n
# Special case: removing the head
if target_idx == 0:
return head.next
# Second pass: walk to node just before target
cur = head
for _ in range(target_idx - 1):
cur = cur.next
cur.next = cur.next.next # skip the target node
return head
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(removeNthFromEnd_brute(head, 2)) # 1->2->3->5
head2 = build_list([1])
print("Output: ", end=""); print_list(removeNthFromEnd_brute(head2, 1)) # None
head3 = build_list([1, 2])
print("Output: ", end=""); print_list(removeNthFromEnd_brute(head3, 1)) # 1
Complexity
- Time:
O(n)— two passes over the list - Space:
O(1)— no extra data structures
2. Two-Pointer One-Pass (Optimal)
Intuition
We can do this in a single pass by maintaining a gap of exactly n nodes between two pointers. Here’s the trick:
- Advance the
fastpointernsteps ahead. - Then move both
slowandfasttogether untilfast.nextisNone. - At that point,
slowis sitting just before the node to remove.
Using a dummy head means we never have to special-case removing the actual head node.
head = [1, 2, 3, 4, 5], n = 2
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> None
slow = dummy, fast = dummy
Advance fast 2 steps:
fast = 2 (dummy, 1, 2 -- that's 2 steps from dummy)
Move both until fast.next is None:
slow=1, fast=3
slow=2, fast=4
slow=3, fast=5 (fast.next is None, stop)
slow.next is 4 (the 2nd from end) -- remove it:
slow.next = slow.next.next → 3 -> 5
Result: 1 -> 2 -> 3 -> 5
Algorithm
- Create a
dummynode pointing tohead. Setslow = dummyandfast = dummy. - Advance
fastexactlynsteps forward. - Move both
slowandfastone step at a time untilfast.nextisNone. - Remove
slow.next:slow.next = slow.next.next. - Return
dummy.next.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def removeNthFromEnd(head, n):
dummy = ListNode(0, head) # dummy.next = head
slow = dummy
fast = dummy
# Advance fast by n steps
for _ in range(n):
fast = fast.next
# Move both until fast.next is None
while fast.next:
slow = slow.next
fast = fast.next
# slow.next is the node to remove
slow.next = slow.next.next
return dummy.next
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(removeNthFromEnd(head, 2)) # 1->2->3->5
head2 = build_list([1])
print("Output: ", end=""); print_list(removeNthFromEnd(head2, 1)) # None
head3 = build_list([1, 2])
print("Output: ", end=""); print_list(removeNthFromEnd(head3, 1)) # 1
head4 = build_list([1, 2])
print("Output: ", end=""); print_list(removeNthFromEnd(head4, 2)) # 2 (remove head)
head5 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(removeNthFromEnd(head5, 5)) # 2->3->4->5 (remove head)
Complexity
- Time:
O(sz)— single pass - Space:
O(1)— only pointer variables
Common Pitfalls
Not using a dummy node and forgetting the head-removal edge case. If the list has only one node and n=1, the result should be None. Without a dummy, you need a special if branch. The dummy node makes slow always point to the node before the one to delete, even when deleting the head.
Advancing fast by n+1 instead of n. Some formulations advance fast by n+1 from a dummy start and then stop when fast is None. Both work, but they’re different — be consistent. In our approach: advance by n, then stop when fast.next is None.
Off-by-one in the gap. The gap between slow and fast should be such that when fast reaches the last node (not past it), slow is at the node before the one to remove. Trace through a small example to verify your gap is correct.
Copy List With Random Pointer
Difficulty: Medium Source: NeetCode
Problem
A linked list of length
nis given such that each node contains an additional random pointer, which could point to any node in the list, ornull. Construct a deep copy of the list. The deep copy should consist of exactlynbrand new nodes, where each new node has its value set to the value of its corresponding original node. Both thenextandrandompointer of the new nodes should point to new nodes in the copied list such that the pointers represent the same list state. None of the pointers in the new list should point to nodes in the original list. Return the head of the copied linked list.Example 1: Input: head = [[7,null],[13,0],[11,4],[10,2],[1,0]] Output: [[7,null],[13,0],[11,4],[10,2],[1,0]]
Example 2: Input: head = [[1,1],[2,1]] Output: [[1,1],[2,1]]
Constraints:
- 0 <= n <= 1000
- -10^4 <= Node.val <= 10^4
- Node.random is null or pointing to some node in the linked list
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — mapping old nodes to their new copies for O(1) lookup
- Two-Pass Traversal — first create nodes, then wire up pointers
- Deep vs Shallow Copy — understanding that pointers must point to new nodes, not original ones
1. Hash Map Approach (Standard)
Intuition
The challenge here is the random pointer — it can point anywhere in the list, including backwards. If we try to copy in one pass, we might reference a copy that doesn’t exist yet.
The clean solution: two passes using a hash map that maps each original node to its newly created copy.
- Pass 1: Walk the original list and create a fresh copy of each node, storing the mapping
{original: copy}. - Pass 2: Walk again and wire up
.nextand.randomfor each copy using the map.
Since the map gives us copy_of[original.next] and copy_of[original.random] in O(1), this is efficient.
Algorithm
- Edge case: if
headisNone, returnNone. - Create a dictionary
old_to_new = {}. - First pass — create all copy nodes: for each node in the original list, create
new_node = Node(node.val)and storeold_to_new[node] = new_node. - Second pass — wire pointers: for each node in the original list:
old_to_new[node].next = old_to_new.get(node.next)old_to_new[node].random = old_to_new.get(node.random)
- Return
old_to_new[head].
Solution
class Node:
def __init__(self, val=0, next=None, random=None):
self.val = val
self.next = next
self.random = random
def build_list(pairs):
"""Build list from [(val, random_index_or_None), ...]"""
if not pairs:
return None
nodes = [Node(val) for val, _ in pairs]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
for i, (_, rand_idx) in enumerate(pairs):
if rand_idx is not None:
nodes[i].random = nodes[rand_idx]
return nodes[0]
def print_list(head):
# Build node->index mapping to display random pointer indices
node_index = {}
cur = head
idx = 0
while cur:
node_index[cur] = idx
cur = cur.next
idx += 1
cur = head
result = []
while cur:
rand_idx = node_index[cur.random] if cur.random else None
result.append(f"[{cur.val}, {rand_idx}]")
cur = cur.next
print(" -> ".join(result) if result else "None")
def copyRandomList(head):
if not head:
return None
old_to_new = {}
# Pass 1: create all copy nodes
cur = head
while cur:
old_to_new[cur] = Node(cur.val)
cur = cur.next
# Pass 2: wire up next and random pointers
cur = head
while cur:
if cur.next:
old_to_new[cur].next = old_to_new[cur.next]
if cur.random:
old_to_new[cur].random = old_to_new[cur.random]
cur = cur.next
return old_to_new[head]
# Test cases
head1 = build_list([(7, None), (13, 0), (11, 4), (10, 2), (1, 0)])
print("Original: ", end=""); print_list(head1)
copy1 = copyRandomList(head1)
print("Copy: ", end=""); print_list(copy1)
print("Are they different objects?", head1 is not copy1) # True
head2 = build_list([(1, 1), (2, 1)])
copy2 = copyRandomList(head2)
print("Copy: ", end=""); print_list(copy2)
head3 = build_list([])
copy3 = copyRandomList(head3)
print("Empty copy:", copy3) # None
Complexity
- Time:
O(n)— two linear passes - Space:
O(n)— hash map stores one entry per node
2. O(1) Space — Interleaving Trick
Intuition
This is a clever technique to avoid the hash map entirely. Instead of a separate map, we interleave copy nodes directly into the original list. Each copy node is inserted right after its original. This lets us find copy_of[node] as node.next — no dictionary needed.
Three phases:
- Interleave: For each original node, insert
copyright after it:A -> A' -> B -> B' -> C -> C' - Wire random:
node.next.random = node.random.next(copy’s random = copy of original’s random) - Separate: Unweave the two lists back apart.
Algorithm
- Walk the list and insert a copy of each node right after it.
- Walk again: for each original node
cur, ifcur.randomexists, setcur.next.random = cur.random.next. - Walk a third time and separate the interleaved list into original and copy lists.
Solution
class Node:
def __init__(self, val=0, next=None, random=None):
self.val = val
self.next = next
self.random = random
def build_list(pairs):
if not pairs:
return None
nodes = [Node(val) for val, _ in pairs]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
for i, (_, rand_idx) in enumerate(pairs):
if rand_idx is not None:
nodes[i].random = nodes[rand_idx]
return nodes[0]
def print_list(head):
node_index = {}
cur = head
idx = 0
while cur:
node_index[cur] = idx
cur = cur.next
idx += 1
cur = head
result = []
while cur:
rand_idx = node_index[cur.random] if cur.random else None
result.append(f"[{cur.val}, {rand_idx}]")
cur = cur.next
print(" -> ".join(result) if result else "None")
def copyRandomList_o1(head):
if not head:
return None
# Phase 1: interleave copies into original list
# A -> B -> C becomes A -> A' -> B -> B' -> C -> C'
cur = head
while cur:
copy = Node(cur.val)
copy.next = cur.next
cur.next = copy
cur = copy.next # move to next original node
# Phase 2: wire random pointers for copies
cur = head
while cur:
if cur.random:
cur.next.random = cur.random.next # copy's random = copy of original's random
cur = cur.next.next # skip to next original
# Phase 3: separate the two lists
copy_head = head.next
cur = head
while cur:
copy = cur.next
cur.next = copy.next # restore original list
copy.next = copy.next.next if copy.next else None # build copy list
cur = cur.next
return copy_head
# Test cases
head1 = build_list([(7, None), (13, 0), (11, 4), (10, 2), (1, 0)])
copy1 = copyRandomList_o1(head1)
print("O(1) Copy: ", end=""); print_list(copy1)
# Verify original is intact
print("Original: ", end=""); print_list(head1)
head2 = build_list([(1, 1), (2, 1)])
copy2 = copyRandomList_o1(head2)
print("O(1) Copy: ", end=""); print_list(copy2)
Complexity
- Time:
O(n)— three linear passes - Space:
O(1)— no extra data structures (beyond the copy nodes themselves)
Common Pitfalls
Pointing random to original nodes instead of copies. The whole point is a deep copy — every pointer in the new list must point to new nodes. Double-check that you’re using old_to_new[node.random] not node.random itself.
Using .get() vs direct key access. When a node’s .next or .random is None, old_to_new[None] will raise a KeyError. Use old_to_new.get(node.next) which safely returns None, or add an explicit if check.
Forgetting to restore the original list in the O(1) approach. The interleaving technique modifies the original list temporarily. The separation step must cleanly restore cur.next = copy.next for all original nodes.
Add Two Numbers
Difficulty: Medium Source: NeetCode
Problem
You are given two non-empty linked lists representing two non-negative integers. The digits are stored in reverse order, and each of their nodes contains a single digit. Add the two numbers and return the sum as a linked list. You may assume the two numbers do not have leading zeros, except the number 0 itself.
Example 1: Input: l1 = [2,4,3], l2 = [5,6,4] Output: [7,0,8] Explanation: 342 + 465 = 807
Example 2: Input: l1 = [0], l2 = [0] Output: [0]
Example 3: Input: l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9] Output: [8,9,9,9,0,0,0,1]
Constraints:
- The number of nodes in each linked list is in the range [1, 100]
- 0 <= Node.val <= 9
- It is guaranteed that the list represents a number that does not have leading zeros
Prerequisites
Before attempting this problem, you should be comfortable with:
- Linked List Traversal — walking two lists simultaneously
- Carry Arithmetic — how digit-by-digit addition with carry works (like doing addition by hand)
- Dummy Head Trick — building a result list cleanly
1. Simulate Addition (Optimal)
Intuition
The lists are already in reverse order — least significant digit first — which is exactly how we do long addition by hand: start from the least significant digit, add, carry the overflow. So we just simulate this process directly on the linked lists.
Walk both lists simultaneously, add corresponding digits plus any carry from the previous step. The sum at each position can be 0–19 (9+9+1 carry), so the digit is total % 10 and the new carry is total // 10. Continue until both lists are exhausted and there’s no remaining carry.
l1 = 2 -> 4 -> 3 (represents 342)
l2 = 5 -> 6 -> 4 (represents 465)
Position 0: 2 + 5 + carry(0) = 7, digit=7, carry=0
Position 1: 4 + 6 + carry(0) = 10, digit=0, carry=1
Position 2: 3 + 4 + carry(1) = 8, digit=8, carry=0
Both exhausted, carry=0, done.
Result: 7 -> 0 -> 8 (represents 807)
Algorithm
- Create a dummy head and a
curpointer. - Initialize
carry = 0. - While
l1orl2orcarryis non-zero:- Get digits:
v1 = l1.val if l1 else 0,v2 = l2.val if l2 else 0. - Compute
total = v1 + v2 + carry. - New digit:
total % 10. New carry:total // 10. - Append a new node with the digit to the result.
- Advance
l1andl2if they’re not exhausted.
- Get digits:
- Return
dummy.next.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def addTwoNumbers(l1, l2):
dummy = ListNode(0)
cur = dummy
carry = 0
while l1 or l2 or carry:
v1 = l1.val if l1 else 0
v2 = l2.val if l2 else 0
total = v1 + v2 + carry
carry = total // 10
digit = total % 10
cur.next = ListNode(digit)
cur = cur.next
if l1:
l1 = l1.next
if l2:
l2 = l2.next
return dummy.next
# Test cases
l1 = build_list([2, 4, 3]) # 342
l2 = build_list([5, 6, 4]) # 465
print("Output: ", end=""); print_list(addTwoNumbers(l1, l2)) # 7->0->8 (807)
l3 = build_list([0])
l4 = build_list([0])
print("Output: ", end=""); print_list(addTwoNumbers(l3, l4)) # 0
l5 = build_list([9, 9, 9, 9, 9, 9, 9]) # 9999999
l6 = build_list([9, 9, 9, 9]) # 9999
print("Output: ", end=""); print_list(addTwoNumbers(l5, l6)) # 8->9->9->9->0->0->0->1
# Lists of different lengths
l7 = build_list([1])
l8 = build_list([9, 9]) # 99 + 1 = 100
print("Output: ", end=""); print_list(addTwoNumbers(l7, l8)) # 0->0->1
# Final carry creates new node
l9 = build_list([5])
l10 = build_list([5]) # 5 + 5 = 10
print("Output: ", end=""); print_list(addTwoNumbers(l9, l10)) # 0->1
Complexity
- Time:
O(max(m, n))— we iterate as many times as the longer list (plus possibly one extra for a final carry) - Space:
O(max(m, n))— the result list has at mostmax(m, n) + 1nodes
2. Convert to Integer and Back (Brute Force)
Intuition
Since the lists are in reverse order, we can read each list as a number, add them as regular Python integers, then convert the result back to a linked list. Python handles arbitrarily large integers, so there’s no overflow concern. This is simple but misses the point of the problem (and wouldn’t work in languages with fixed-size integers).
Algorithm
- Walk
l1and reconstruct the integer (least significant digit at index 0 means multiply by10^i). - Same for
l2. - Compute
total = num1 + num2. - Build the result list from
totalby repeatedly takingtotal % 10and appending.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def addTwoNumbers_brute(l1, l2):
def list_to_int(node):
num, multiplier = 0, 1
while node:
num += node.val * multiplier
multiplier *= 10
node = node.next
return num
total = list_to_int(l1) + list_to_int(l2)
if total == 0:
return ListNode(0)
dummy = ListNode(0)
cur = dummy
while total > 0:
cur.next = ListNode(total % 10)
cur = cur.next
total //= 10
return dummy.next
# Test
l1 = build_list([2, 4, 3])
l2 = build_list([5, 6, 4])
print("Output: ", end=""); print_list(addTwoNumbers_brute(l1, l2)) # 7->0->8
Complexity
- Time:
O(max(m, n))— similar traversal - Space:
O(max(m, n))— result list
Common Pitfalls
Forgetting the final carry. After both lists are exhausted, there may still be a carry of 1 (e.g., 5+5=10 leaves carry=1). The while l1 or l2 or carry condition handles this — don’t change it to just while l1 or l2.
Not handling lists of different lengths. If l1 has 3 nodes and l2 has 2, you need to keep going after l2 is exhausted. Use v1 = l1.val if l1 else 0 to safely get 0 when a list runs out.
Advancing both pointers unconditionally. After the loop body, only advance a pointer if it’s not None. if l1: l1 = l1.next — don’t do l1 = l1.next unconditionally or you’ll get an AttributeError.
Reverse Linked List II
Difficulty: Medium Source: NeetCode
Problem
Given the head of a singly linked list and two integers
leftandrightwhereleft <= right, reverse the nodes of the list from positionleftto positionright, and return the reversed list. Positions are 1-indexed.Example 1: Input: head = [1,2,3,4,5], left = 2, right = 4 Output: [1,4,3,2,5]
Example 2: Input: head = [5], left = 1, right = 1 Output: [5]
Constraints:
- The number of nodes in the list is
n- 1 <= n <= 500
- -500 <= Node.val <= 500
- 1 <= left <= right <= n
Prerequisites
Before attempting this problem, you should be comfortable with:
- Reversing a Linked List — in-place pointer reversal with prev/curr (see problem 1)
- Dummy Head Trick — sentinel node to handle edge cases like reversing from position 1
- Pointer Re-attachment — connecting reversed sublist back to the surrounding nodes
1. Brute Force — Collect and Rebuild
Intuition
Extract the values in the subrange, reverse them, then put them back. Not the most elegant, but easy to reason about and good for verifying the optimal approach.
Algorithm
- Walk the list and collect all node values into a Python list.
- Reverse the slice
values[left-1:right]in-place. - Walk the original list again and overwrite each node’s
.valwith the new values.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def reverseBetween_brute(head, left, right):
values = []
cur = head
while cur:
values.append(cur.val)
cur = cur.next
# Reverse the slice [left-1, right) in-place
l, r = left - 1, right - 1
while l < r:
values[l], values[r] = values[r], values[l]
l += 1
r -= 1
# Write values back into nodes
cur = head
for v in values:
cur.val = v
cur = cur.next
return head
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseBetween_brute(head, 2, 4)) # 1->4->3->2->5
head2 = build_list([5])
print("Output: ", end=""); print_list(reverseBetween_brute(head2, 1, 1)) # 5
head3 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseBetween_brute(head3, 1, 5)) # 5->4->3->2->1
Complexity
- Time:
O(n)— two passes - Space:
O(n)— storing all values
2. In-Place with Dummy Head (Optimal)
Intuition
We want to reverse exactly the nodes from position left to right without touching anything else. The key is to identify four anchor points:
prev_left: the node just before positionleft(we’ll re-attach the reversed segment here)- The node at position
left(it becomes the tail of the reversed segment) - The node at position
right(it becomes the new head of the reversed segment) - The node just after position
right(we’ll re-attach to the tail of the reversed segment)
Using a dummy head means prev_left always exists, even when left = 1.
Example: [1, 2, 3, 4, 5], left=2, right=4
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> None
^ ^ ^ ^
prev_l left_node right after_right
(pos 1) (pos 2) (pos 4) (pos 5)
After reversing positions 2-4:
dummy -> 1 -> 4 -> 3 -> 2 -> 5 -> None
^ ^
left_node after_right
(now tail)
Algorithm
- Create a
dummynode and setdummy.next = head. - Walk
left - 1steps fromdummyto findprev_left(the node before positionleft). - Set
left_node = prev_left.next(the node at positionleft). - Reverse the sublist from position
lefttorightusing standard iterative reversal. Keep count. - Re-attach:
prev_left.next = right_node(the new head of the reversed segment) andleft_node.next = after_right. - Return
dummy.next.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def reverseBetween(head, left, right):
dummy = ListNode(0, head)
# Step 1: Walk to the node just before position 'left'
prev_left = dummy
for _ in range(left - 1):
prev_left = prev_left.next
# Step 2: 'left_node' is the node at position 'left'
# It will become the tail of the reversed segment
left_node = prev_left.next
# Step 3: Reverse from position left to right
# We reverse (right - left) times
prev = None
cur = left_node
for _ in range(right - left + 1):
next_node = cur.next
cur.next = prev
prev = cur
cur = next_node
# After loop:
# prev = node at position 'right' (new head of reversed segment)
# cur = node at position 'right+1' (first node after the segment)
# Step 4: Re-attach
prev_left.next = prev # connect node before segment to new head
left_node.next = cur # connect old left (now tail) to node after segment
return dummy.next
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseBetween(head, 2, 4)) # 1->4->3->2->5
head2 = build_list([5])
print("Output: ", end=""); print_list(reverseBetween(head2, 1, 1)) # 5
# left=1: reverse from the very start (dummy saves us here)
head3 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseBetween(head3, 1, 5)) # 5->4->3->2->1
head4 = build_list([3, 5])
print("Output: ", end=""); print_list(reverseBetween(head4, 1, 2)) # 5->3
Complexity
- Time:
O(n)— at most two passes: one to findprev_left, one to reverse - Space:
O(1)— only pointer variables
Common Pitfalls
Not using a dummy node when left = 1. If the reversal starts at the head, there’s no “node before position left”. The dummy node acts as a fake predecessor so the re-attachment code stays uniform.
Getting confused about prev and cur after the reversal loop. After reversing right - left + 1 nodes, prev points to the node that was at position right (now the start of the reversed segment) and cur points to the node that was at position right + 1 (the first node after the segment). Keep this straight for the re-attachment.
Forgetting to re-attach left_node.next. After reversal, left_node (original position-left node) has next = None (or whatever prev started as). You must set left_node.next = cur to reconnect it to the rest of the list.
Design Circular Queue
Difficulty: Medium Source: NeetCode
Problem
Design your implementation of the circular queue. The circular queue is a linear data structure in which the operations are performed based on FIFO (First In First Out) principle, and the last position is connected back to the first position to make a circle. It is also called “Ring Buffer”. Implement the
MyCircularQueueclass:
MyCircularQueue(k)— Initializes the object with the size of the queuek.enQueue(value)— Inserts an element into the circular queue. ReturnsTrueif the operation is successful.deQueue()— Deletes an element from the circular queue. ReturnsTrueif the operation is successful.Front()— Gets the front item from the queue. Returns-1if the queue is empty.Rear()— Gets the last item from the queue. Returns-1if the queue is empty.isEmpty()— Checks whether the circular queue is empty or not.isFull()— Checks whether the circular queue is full or not.Example 1: Input: [“MyCircularQueue”,“enQueue”,“enQueue”,“enQueue”,“enQueue”,“Rear”,“isFull”,“deQueue”,“enQueue”,“Rear”] [[3],[1],[2],[3],[4],[],[],[],[4],[]] Output: [null,true,true,true,false,3,true,true,true,4]
Constraints:
- 1 <= k <= 1000
- 0 <= value <= 1000
- At most 3000 calls will be made to enQueue, deQueue, Front, Rear, isEmpty, isFull
Prerequisites
Before attempting this problem, you should be comfortable with:
- Modular Arithmetic — using
%to wrap indices around a fixed-size array - Queue FIFO Semantics — elements are added at the rear and removed from the front
- Fixed-Size Array — pre-allocating storage and using pointers to track bounds
1. Array with Head/Tail Pointers (Optimal)
Intuition
A circular queue is just a fixed-size array where the front and rear pointers wrap around using modular arithmetic. Instead of shifting elements when we dequeue (which would be O(n)), we just move the head pointer forward. The “circle” just means index k-1 wraps back to index 0.
We track:
self.queue— the underlying array (pre-allocated withkslots)self.head— index of the front elementself.count— how many elements are currently in the queue (simpler than tracking both head and tail without ambiguity)
The tail index is derived as (self.head + self.count - 1) % self.k.
Initial (k=3):
Queue: [_, _, _]
head=0, count=0
enQueue(1):
Queue: [1, _, _]
head=0, count=1
enQueue(2):
Queue: [1, 2, _]
head=0, count=2
enQueue(3):
Queue: [1, 2, 3]
head=0, count=3 (full!)
deQueue(): (removes front element, value=1)
head=1, count=2
Queue: [1, 2, 3] (1 is still there but logically removed)
enQueue(4):
tail = (1 + 2) % 3 = 0
Queue: [4, 2, 3] (overwrites old slot 0)
head=1, count=3
Front() = queue[head] = queue[1] = 2
Rear() = queue[(1+3-1)%3] = queue[0] = 4
Algorithm
__init__(k): Allocate array of sizek, sethead = 0,count = 0,k = k.enQueue(value): If full, returnFalse. Write to(head + count) % k. Incrementcount. ReturnTrue.deQueue(): If empty, returnFalse. Advancehead = (head + 1) % k. Decrementcount. ReturnTrue.Front(): If empty, return-1. Returnqueue[head].Rear(): If empty, return-1. Returnqueue[(head + count - 1) % k].isEmpty(): Returncount == 0.isFull(): Returncount == self.k.
Solution
class MyCircularQueue:
def __init__(self, k: int):
self.k = k
self.queue = [0] * k
self.head = 0 # index of front element
self.count = 0 # number of elements currently stored
def enQueue(self, value: int) -> bool:
if self.isFull():
return False
tail_idx = (self.head + self.count) % self.k
self.queue[tail_idx] = value
self.count += 1
return True
def deQueue(self) -> bool:
if self.isEmpty():
return False
self.head = (self.head + 1) % self.k
self.count -= 1
return True
def Front(self) -> int:
if self.isEmpty():
return -1
return self.queue[self.head]
def Rear(self) -> int:
if self.isEmpty():
return -1
tail_idx = (self.head + self.count - 1) % self.k
return self.queue[tail_idx]
def isEmpty(self) -> bool:
return self.count == 0
def isFull(self) -> bool:
return self.count == self.k
# Test cases
q = MyCircularQueue(3)
print(q.enQueue(1)) # True
print(q.enQueue(2)) # True
print(q.enQueue(3)) # True
print(q.enQueue(4)) # False (full)
print(q.Rear()) # 3
print(q.isFull()) # True
print(q.deQueue()) # True
print(q.enQueue(4)) # True
print(q.Rear()) # 4
print(q.Front()) # 2
print("---")
# Test wrap-around
q2 = MyCircularQueue(2)
print(q2.enQueue(1)) # True
print(q2.enQueue(2)) # True
print(q2.isFull()) # True
print(q2.deQueue()) # True
print(q2.enQueue(3)) # True (wraps to slot 0)
print(q2.Front()) # 2
print(q2.Rear()) # 3
print("---")
# Edge case: empty queue
q3 = MyCircularQueue(1)
print(q3.Front()) # -1
print(q3.Rear()) # -1
print(q3.deQueue()) # False
print(q3.enQueue(5)) # True
print(q3.Front()) # 5
print(q3.Rear()) # 5
print(q3.isFull()) # True
Complexity
- Time:
O(1)— all operations are O(1) with modular arithmetic - Space:
O(k)— fixed array of size k
2. Linked List Approach
Intuition
Alternatively, we can use a doubly linked list with a count. enQueue appends to the tail and deQueue removes from the head. No modular arithmetic needed, but we have node allocation overhead. This is cleaner conceptually but slightly more memory-intensive due to pointer overhead per node.
Solution
class Node:
def __init__(self, val):
self.val = val
self.next = None
class MyCircularQueue:
def __init__(self, k: int):
self.k = k
self.count = 0
self.head = None # front of queue
self.tail = None # rear of queue
def enQueue(self, value: int) -> bool:
if self.isFull():
return False
node = Node(value)
if self.tail:
self.tail.next = node
self.tail = node
if not self.head:
self.head = node
self.count += 1
return True
def deQueue(self) -> bool:
if self.isEmpty():
return False
self.head = self.head.next
if not self.head:
self.tail = None
self.count -= 1
return True
def Front(self) -> int:
return self.head.val if self.head else -1
def Rear(self) -> int:
return self.tail.val if self.tail else -1
def isEmpty(self) -> bool:
return self.count == 0
def isFull(self) -> bool:
return self.count == self.k
# Test
q = MyCircularQueue(3)
print(q.enQueue(1), q.enQueue(2), q.enQueue(3)) # True True True
print(q.enQueue(4)) # False
print(q.Rear()) # 3
print(q.deQueue()) # True
print(q.enQueue(4)) # True
print(q.Rear()) # 4
Complexity
- Time:
O(1)— all operations are O(1) - Space:
O(k)— at most k nodes allocated
Common Pitfalls
Using head == tail to check empty/full — it’s ambiguous. If head and tail point to the same index, you can’t tell if the queue is empty or full. That’s why tracking count separately (or keeping one slot always empty) is the standard solution.
Forgetting modular arithmetic. When the tail index reaches k-1, the next enQueue should write to index 0. Always compute tail as (head + count) % k — never just count.
Off-by-one in Rear(). Rear is (head + count - 1) % k, not (head + count) % k. The -1 is because count elements occupy indices head through head + count - 1.
LRU Cache
Difficulty: Medium Source: NeetCode
Problem
Design a data structure that follows the constraints of a Least Recently Used (LRU) cache. Implement the
LRUCacheclass:
LRUCache(capacity)— Initialize the LRU cache with positive sizecapacity.get(key)— Return the value of thekeyif the key exists, otherwise return-1.put(key, value)— Update the value of thekeyif the key exists. Otherwise, add the key-value pair to the cache. If the number of keys exceeds thecapacityfrom this operation, evict the least recently used key.Both
getandputmust each run inO(1)average time complexity.Example 1: Input: [“LRUCache”,“put”,“put”,“get”,“put”,“get”,“put”,“get”,“get”,“get”] [[2],[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]] Output: [null,null,null,1,null,-1,null,-1,3,4]
Constraints:
- 1 <= capacity <= 3000
- 0 <= key <= 10^4
- 0 <= value <= 10^5
- At most 2 * 10^5 calls will be made to get and put
Prerequisites
Before attempting this problem, you should be comfortable with:
- Doubly Linked List — insertion and deletion at O(1) given a node reference
- Hash Map — O(1) key lookup that returns a direct node reference
- Dummy/Sentinel Nodes — using head and tail sentinels to avoid edge cases in list manipulation
1. Brute Force — OrderedDict (Python Cheat)
Intuition
Python’s collections.OrderedDict maintains insertion order and supports move_to_end(). We can use it directly: on each get or put, move the accessed key to the end (most recently used). The front of the dict is always the LRU. On capacity overflow, pop the first item.
This is great for interviews if the interviewer allows it, but you should also know the manual implementation below.
Solution
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity: int):
self.cap = capacity
self.cache = OrderedDict()
def get(self, key: int) -> int:
if key not in self.cache:
return -1
self.cache.move_to_end(key) # mark as most recently used
return self.cache[key]
def put(self, key: int, value: int) -> None:
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.cap:
self.cache.popitem(last=False) # remove LRU (first item)
# Test cases
cache = LRUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1)) # 1
cache.put(3, 3) # evicts key 2 (LRU)
print(cache.get(2)) # -1 (evicted)
cache.put(4, 4) # evicts key 1 (LRU)
print(cache.get(1)) # -1 (evicted)
print(cache.get(3)) # 3
print(cache.get(4)) # 4
Complexity
- Time:
O(1)— OrderedDict operations are O(1) - Space:
O(capacity)
2. Doubly Linked List + Hash Map (Manual, Optimal)
Intuition
This is the textbook solution. We need two things:
- O(1) access by key → hash map:
key → node - O(1) LRU eviction + recency update → doubly linked list in access order
The doubly linked list maintains nodes in order from least recently used (near head sentinel) to most recently used (near tail sentinel). Using two sentinel nodes (dummy_head and dummy_tail) means we never have to check for null neighbors during insertion/deletion.
dummy_head <-> [LRU node] <-> ... <-> [MRU node] <-> dummy_tail
Every time we get or put a key:
- Remove the node from its current position in the list.
- Insert it right before
dummy_tail(marking it as MRU).
When capacity is exceeded:
- The node right after
dummy_headis the LRU — remove it from the list and delete from the map.
Algorithm
get(key): If key not in map, return -1. Otherwise, remove node from list, insert before tail (MRU), return value.put(key, value): If key exists, remove node. Create new node, insert before tail, add to map. If over capacity, remove node after head (LRU) from list and map.
Solution
class Node:
"""Doubly linked list node."""
def __init__(self, key=0, val=0):
self.key = key
self.val = val
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.cap = capacity
self.cache = {} # key -> Node
# Sentinel nodes: left = LRU end, right = MRU end
self.left = Node() # dummy head
self.right = Node() # dummy tail
self.left.next = self.right
self.right.prev = self.left
def _remove(self, node: Node):
"""Remove a node from the doubly linked list."""
prev, nxt = node.prev, node.next
prev.next = nxt
nxt.prev = prev
def _insert_mru(self, node: Node):
"""Insert a node right before the dummy tail (mark as MRU)."""
prev = self.right.prev
prev.next = node
node.prev = prev
node.next = self.right
self.right.prev = node
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self._remove(node)
self._insert_mru(node)
return node.val
def put(self, key: int, value: int) -> None:
if key in self.cache:
self._remove(self.cache[key])
node = Node(key, value)
self.cache[key] = node
self._insert_mru(node)
if len(self.cache) > self.cap:
# Evict LRU: the node right after dummy head
lru = self.left.next
self._remove(lru)
del self.cache[lru.key]
# Test cases
cache = LRUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1)) # 1 (now 1 is MRU, 2 is LRU)
cache.put(3, 3) # evicts key 2
print(cache.get(2)) # -1 (evicted)
cache.put(4, 4) # evicts key 1
print(cache.get(1)) # -1 (evicted)
print(cache.get(3)) # 3
print(cache.get(4)) # 4
print("---")
# Updating existing key
cache2 = LRUCache(2)
cache2.put(1, 1)
cache2.put(2, 2)
cache2.put(1, 10) # update key 1, makes it MRU
cache2.put(3, 3) # evicts key 2 (LRU), not key 1
print(cache2.get(1)) # 10
print(cache2.get(2)) # -1 (evicted)
print(cache2.get(3)) # 3
print("---")
# Capacity 1
cache3 = LRUCache(1)
cache3.put(1, 1)
cache3.put(2, 2) # evicts 1
print(cache3.get(1)) # -1
print(cache3.get(2)) # 2
Complexity
- Time:
O(1)— hash map lookup + linked list insert/remove are all O(1) - Space:
O(capacity)— at mostcapacitynodes in the list and map
Common Pitfalls
Using a singly linked list. To remove a node in O(1), you need access to its predecessor. A doubly linked list gives you node.prev directly. With a singly linked list, you’d need to traverse from the head to find the predecessor — that’s O(n).
Not storing the key in the node. When evicting the LRU node from the list, you need to remove it from the hash map too. Since you only have the node at that point (not the key), the node must store its own key.
Handling the put update case. When put is called with an existing key, you must first remove the old node from the list before inserting the updated one. Otherwise you’ll have a stale node floating in the list and the cache size will grow incorrectly.
Forgetting del self.cache[lru.key] after removing the LRU from the linked list. The map still holds a reference to the evicted node — without deletion, your cache will grow past capacity.
LFU Cache
Difficulty: Hard Source: NeetCode
Problem
Design and implement a data structure for a Least Frequently Used (LFU) cache. Implement the
LFUCacheclass:
LFUCache(capacity)— Initialize the LFU cache with the given capacity.get(key)— Return the value ofkeyif it exists, otherwise return-1. Accessing a key increments its frequency.put(key, value)— Insert or update the key-value pair. If the number of keys exceeds capacity after insertion, evict the least frequently used key. Ties are broken by evicting the least recently used (LRU) among those with the same frequency.Both
getandputmust run inO(1)average time complexity.Example 1: Input: [“LFUCache”,“put”,“put”,“get”,“put”,“get”,“put”,“get”,“get”,“get”] [[2],[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]] Output: [null,null,null,1,null,-1,null,-1,3,4]
Constraints:
- 1 <= capacity <= 10^4
- 0 <= key <= 10^5
- 0 <= value <= 10^9
- At most 2 * 10^5 calls will be made to get and put
Prerequisites
Before attempting this problem, you should be comfortable with:
- LRU Cache — the same doubly-linked-list + hash map pattern (see problem 11)
- OrderedDict — maintains insertion order, supports O(1) move to end
- Min-Frequency Tracking — maintaining the current minimum frequency for O(1) eviction
1. Hash Maps + OrderedDict per Frequency (Optimal)
Intuition
LFU is harder than LRU because eviction depends on frequency, not just recency. But among keys with the same (minimum) frequency, we still evict the LRU one. So the structure is:
key_to_val— maps key → valuekey_to_freq— maps key → current frequencyfreq_to_keys— maps frequency → OrderedDict of keys (preserves insertion/access order for LRU tie-breaking)min_freq— the current minimum frequency (the frequency bucket we evict from)
On get(key):
- Return -1 if not found.
- Increment
key_to_freq[key]. - Move the key from its old frequency bucket to the new one.
- If the old bucket was
min_freqand is now empty, incrementmin_freq. - Return the value.
On put(key, value):
- If key already exists, update its value and call the same “increment frequency” logic as
get. - If key is new, check capacity. If full, evict the LRU from
freq_to_keys[min_freq]. - Insert the new key with frequency 1. Set
min_freq = 1.
The OrderedDict per frequency bucket gives us LRU ordering within each bucket — popitem(last=False) removes the oldest entry.
Algorithm
_increment_freq(key): Remove key fromfreq_to_keys[old_freq]. If old_freq == min_freq and bucket is now empty, min_freq += 1. Add key tofreq_to_keys[new_freq](at the end = MRU within that bucket).get(key): If not found return -1. Else_increment_freq(key), returnkey_to_val[key].put(key, value): If cap == 0, return. If key exists, update value,_increment_freq(key). Else if at capacity, evict LFU/LRU. Insert new key with freq=1, min_freq=1.
Solution
from collections import defaultdict, OrderedDict
class LFUCache:
def __init__(self, capacity: int):
self.cap = capacity
self.key_to_val = {} # key -> value
self.key_to_freq = {} # key -> frequency
self.freq_to_keys = defaultdict(OrderedDict) # freq -> OrderedDict{key: None}
self.min_freq = 0
def _increment_freq(self, key: int):
freq = self.key_to_freq[key]
# Remove from current frequency bucket
del self.freq_to_keys[freq][key]
# If this was the minimum frequency bucket and it's now empty, bump min_freq
if freq == self.min_freq and not self.freq_to_keys[freq]:
self.min_freq += 1
# Add to new frequency bucket (at end = most recently used in this freq)
new_freq = freq + 1
self.key_to_freq[key] = new_freq
self.freq_to_keys[new_freq][key] = None
def get(self, key: int) -> int:
if key not in self.key_to_val:
return -1
self._increment_freq(key)
return self.key_to_val[key]
def put(self, key: int, value: int) -> None:
if self.cap == 0:
return
if key in self.key_to_val:
# Update existing key
self.key_to_val[key] = value
self._increment_freq(key)
else:
# Evict if at capacity
if len(self.key_to_val) >= self.cap:
# Remove LRU from the minimum frequency bucket
lfu_key, _ = self.freq_to_keys[self.min_freq].popitem(last=False)
del self.key_to_val[lfu_key]
del self.key_to_freq[lfu_key]
# Insert new key with frequency 1
self.key_to_val[key] = value
self.key_to_freq[key] = 1
self.freq_to_keys[1][key] = None
self.min_freq = 1
# Test cases from the problem
cache = LFUCache(2)
cache.put(1, 1) # freq[1]=1
cache.put(2, 2) # freq[2]=1
print(cache.get(1)) # 1 freq[1]=2
cache.put(3, 3) # evict key 2 (freq=1, LRU among freq-1 keys); freq[3]=1
print(cache.get(2)) # -1 (evicted)
print(cache.get(3)) # 3 freq[3]=2
cache.put(4, 4) # evict key 4? no -- evict LFU: both 3 and 1 have freq=2? No.
# min_freq=1 (only key 4's freq=1 after insert), evict key 3? Let's trace.
# Actually: after put(3,3): cache={1:freq2, 3:freq1}, min_freq=1
# get(3): freq[3]=2, min_freq bumps to 2 (freq-1 bucket now empty)
# cache={1:freq2, 3:freq2}, min_freq=2
# put(4): at capacity(2), evict from freq-2 bucket. LRU is key 1 (inserted first). Evict 1.
# Insert 4 with freq=1, min_freq=1
print(cache.get(1)) # -1 (evicted)
print(cache.get(3)) # 3 freq[3]=3
print(cache.get(4)) # 4 freq[4]=2
print("---")
# Simpler test: verify LRU tie-breaking
cache2 = LFUCache(3)
cache2.put(1, 1)
cache2.put(2, 2)
cache2.put(3, 3)
# All have freq=1. Access 1 and 2 to give them freq=2.
cache2.get(1)
cache2.get(2)
# Now freq[1]=2, freq[2]=2, freq[3]=1. min_freq=1.
# Put 4 -> evicts LFU = key 3 (only key with freq=1)
cache2.put(4, 4)
print(cache2.get(3)) # -1 (evicted)
print(cache2.get(1)) # 1
print(cache2.get(2)) # 2
print(cache2.get(4)) # 4
print("---")
# Edge: capacity 1
cache3 = LFUCache(1)
cache3.put(1, 1)
cache3.put(2, 2) # evicts 1
print(cache3.get(1)) # -1
print(cache3.get(2)) # 2
Complexity
- Time:
O(1)average for bothgetandput— all dictionary and OrderedDict operations are O(1) amortized - Space:
O(capacity)— bounded by number of stored keys
Common Pitfalls
Not updating min_freq on insertion. When you insert a brand new key, its frequency is always 1, so min_freq = 1. Forgetting to reset min_freq means you’ll try to evict from the wrong (possibly empty) frequency bucket.
Not bumping min_freq after _increment_freq. If the old min_freq bucket becomes empty after incrementing a key’s frequency, min_freq must increase. But only increase it by 1 — you can’t skip frequencies because the new frequency is guaranteed to be old + 1.
Confusing LFU with LRU. LRU evicts the key that was accessed the longest time ago. LFU evicts the key that has been accessed the fewest total times (with LRU as tie-breaker). The OrderedDict within each frequency bucket handles the tie-breaking: popitem(last=False) removes the key that was added to that bucket earliest = the LRU within that frequency group.
Handling capacity 0. The constraints say capacity >= 1, but it’s good practice to guard against it. If cap == 0, put should do nothing.
Merge K Sorted Lists
Difficulty: Hard Source: NeetCode
Problem
You are given an array of
klinked-listslists, each linked-list is sorted in ascending order. Merge all the linked-lists into one sorted linked-list and return it.Example 1: Input: lists = [[1,4,5],[1,3,4],[2,6]] Output: [1,1,2,3,4,4,5,6]
Example 2: Input: lists = [] Output: []
Example 3: Input: lists = [[]] Output: []
Constraints:
- k == lists.length
- 0 <= k <= 10^4
- 0 <= lists[i].length <= 500
- -10^4 <= lists[i][j] <= 10^4
- lists[i] is sorted in ascending order
- The sum of all lists[i].length will not exceed 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Merge Two Sorted Lists — the core merge operation (see problem 2)
- Min-Heap / Priority Queue — efficiently finding the smallest among k candidates
- Divide and Conquer — breaking a problem into halves recursively
1. Brute Force — Collect All, Sort, Rebuild
Intuition
Don’t think about the list structure at all. Dump every value from every list into a Python list, sort it, then build a new linked list. Simple but ignores the fact that all input lists are already sorted.
Algorithm
- Walk all k lists and collect all values into one Python list.
- Sort the values.
- Build a new linked list from the sorted values.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def mergeKLists_brute(lists):
values = []
for head in lists:
cur = head
while cur:
values.append(cur.val)
cur = cur.next
values.sort()
dummy = ListNode(0)
cur = dummy
for v in values:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
# Test cases
lists = [build_list([1,4,5]), build_list([1,3,4]), build_list([2,6])]
print("Output: ", end=""); print_list(mergeKLists_brute(lists)) # 1->1->2->3->4->4->5->6
print("Output: ", end=""); print_list(mergeKLists_brute([])) # None
print("Output: ", end=""); print_list(mergeKLists_brute([None])) # None
Complexity
- Time:
O(N log N)where N = total number of nodes across all lists - Space:
O(N)— storing all values
2. Min-Heap (Optimal)
Intuition
We want to always pick the smallest current head across all k lists. A min-heap gives us the minimum in O(log k). We maintain a heap of (value, index, node) tuples — one entry per list for the current head node.
Why include index? Because when two nodes have the same value, Python will try to compare the ListNode objects — which isn’t defined. The index i acts as a tiebreaker.
lists: [1->4->5], [1->3->4], [2->6]
Initial heap: [(1,0,node1), (1,1,node1), (2,2,node2)]
(sorted by value then index)
Pop (1,0,node1_from_list0): add to result, push (4,0,next_node)
Pop (1,1,node1_from_list1): add to result, push (3,1,next_node)
Pop (2,2,node2_from_list2): add to result, push (6,2,next_node)
...and so on until heap is empty
Algorithm
- Initialize a min-heap with
(node.val, i, node)for each non-empty list head. - While the heap is not empty:
- Pop the smallest
(val, i, node). - Attach
nodeto the result list. - If
node.nextexists, push(node.next.val, i, node.next)onto the heap.
- Pop the smallest
- Return
dummy.next.
Solution
import heapq
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def mergeKLists(lists):
dummy = ListNode(0)
cur = dummy
# Initialize heap with (value, list_index, node)
# list_index breaks ties when values are equal
heap = []
for i, head in enumerate(lists):
if head:
heapq.heappush(heap, (head.val, i, head))
while heap:
val, i, node = heapq.heappop(heap)
cur.next = node
cur = cur.next
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
# Test cases
lists = [build_list([1,4,5]), build_list([1,3,4]), build_list([2,6])]
print("Output: ", end=""); print_list(mergeKLists(lists)) # 1->1->2->3->4->4->5->6
print("Output: ", end=""); print_list(mergeKLists([])) # None
print("Output: ", end=""); print_list(mergeKLists([None])) # None
# Single list
print("Output: ", end=""); print_list(mergeKLists([build_list([1,2,3])])) # 1->2->3
# All single-element lists
lists2 = [build_list([3]), build_list([1]), build_list([2])]
print("Output: ", end=""); print_list(mergeKLists(lists2)) # 1->2->3
Complexity
- Time:
O(N log k)— N total nodes, each pushed/popped from a heap of size at most k - Space:
O(k)— heap size is at most k at any time
3. Divide and Conquer
Intuition
Instead of merging all lists at once, merge them pairwise — like a tournament bracket. Pair up lists [0,1], [2,3], [4,5], etc., merge each pair, then repeat with the halved set of lists. After log k rounds, only one merged list remains. This reuses our O(m+n) merge-two-lists function efficiently.
Round 1: merge([0,1]), merge([2,3]), merge([4,5]) → k/2 lists
Round 2: merge([0+1, 2+3]), merge([4+5]) → k/4 lists
Round 3: merge(all) → 1 list
Total work: each node participates in O(log k) merges → O(N log k) overall.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def mergeTwoLists(l1, l2):
dummy = ListNode(0)
cur = dummy
while l1 and l2:
if l1.val <= l2.val:
cur.next = l1
l1 = l1.next
else:
cur.next = l2
l2 = l2.next
cur = cur.next
cur.next = l1 if l1 else l2
return dummy.next
def mergeKLists_dc(lists):
if not lists:
return None
while len(lists) > 1:
merged = []
for i in range(0, len(lists), 2):
l1 = lists[i]
l2 = lists[i + 1] if i + 1 < len(lists) else None
merged.append(mergeTwoLists(l1, l2))
lists = merged
return lists[0]
# Test cases
lists = [build_list([1,4,5]), build_list([1,3,4]), build_list([2,6])]
print("Output: ", end=""); print_list(mergeKLists_dc(lists)) # 1->1->2->3->4->4->5->6
print("Output: ", end=""); print_list(mergeKLists_dc([])) # None
print("Output: ", end=""); print_list(mergeKLists_dc([None])) # None
lists2 = [build_list([3]), build_list([1]), build_list([2])]
print("Output: ", end=""); print_list(mergeKLists_dc(lists2)) # 1->2->3
Complexity
- Time:
O(N log k)— log k rounds of merging, each round processes all N nodes - Space:
O(log k)— recursion depth (or O(1) if done iteratively as above)
Common Pitfalls
Using (val, node) in the heap without an index tiebreaker. If two nodes have the same value, Python tries to compare ListNode objects. Since ListNode doesn’t implement __lt__, this raises a TypeError. Always include the list index as a tiebreaker: (val, i, node).
Not checking if lists[i] is None before pushing to heap. Some lists might be empty. Only push to the heap if the head node is not None.
Naive sequential merging: O(kN) instead of O(N log k). Merging list by list (merge first two, merge result with third, etc.) means the first merged list gets processed k times. Always use the heap or divide-and-conquer approach to avoid this.
Reverse Nodes In K Group
Difficulty: Hard Source: NeetCode
Problem
Given the head of a linked list, reverse the nodes of the list
kat a time, and return the modified list.kis a positive integer and is less than or equal to the length of the linked list. If the number of nodes is not a multiple ofkthen left-out nodes, in the end, should remain as is. You may not alter the values in the list’s nodes, only nodes themselves may be changed.Example 1: Input: head = [1,2,3,4,5], k = 2 Output: [2,1,4,3,5]
Example 2: Input: head = [1,2,3,4,5], k = 3 Output: [3,2,1,4,5]
Constraints:
- The number of nodes in the list is
n- 1 <= k <= n <= 5000
- 0 <= Node.val <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Reversing a Linked List — in-place reversal of a sublist (see problems 1 and 9)
- Dummy Head Trick — sentinel node to uniformly handle the first group
- Group Boundary Detection — checking if k nodes remain before committing to a reversal
1. Recursive Approach
Intuition
Think about the problem recursively: reverse the first k nodes, then recursively reverse the rest, and connect them. Before reversing, check if there are at least k nodes remaining — if not, leave them as is.
head = [1,2,3,4,5], k=2
Check: 2 nodes remaining? Yes.
Reverse first 2: [2,1] and save pointer to node 3.
Recurse on [3,4,5]:
Check: 2 nodes remaining? Yes.
Reverse first 2: [4,3] and save pointer to node 5.
Recurse on [5]:
Check: 2 nodes remaining? No. Return head (5) unchanged.
Connect: 3.next = 5
Return 4 (head of reversed [4,3,5])
Connect: 1.next = result of recursion = [4,3,5]
Return 2 (head of reversed [2,1,4,3,5])
Algorithm
- Check if at least k nodes exist starting from
head. If not, returnhead. - Reverse the first k nodes, keeping track of the k-th node and the node after it.
- The original
headbecomes the tail of the reversed group. Connect it toreverseKGroup(k+1th node, k). - Return the k-th node (new head of this group).
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def reverseKGroup_recursive(head, k):
# Check if there are at least k nodes
count = 0
cur = head
while cur and count < k:
cur = cur.next
count += 1
if count < k:
return head # fewer than k nodes left, don't reverse
# Reverse k nodes starting from head
prev = None
curr = head
for _ in range(k):
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
# After loop:
# prev = k-th node (new head of this reversed group)
# curr = (k+1)-th node (start of remaining list)
# head = original head (now the tail of the reversed group)
# Recursively reverse the rest and connect
head.next = reverseKGroup_recursive(curr, k)
return prev # new head of this group
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup_recursive(head, 2)) # 2->1->4->3->5
head2 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup_recursive(head2, 3)) # 3->2->1->4->5
head3 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup_recursive(head3, 1)) # 1->2->3->4->5
head4 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup_recursive(head4, 5)) # 5->4->3->2->1
head5 = build_list([1])
print("Output: ", end=""); print_list(reverseKGroup_recursive(head5, 1)) # 1
Complexity
- Time:
O(n)— each node is visited a constant number of times - Space:
O(n/k)— recursion stack depth (one frame per group)
2. Iterative with Dummy Head (Optimal)
Intuition
The recursive approach has O(n/k) stack depth. We can eliminate the recursion by iterating group by group, carefully tracking four pointers at each step:
group_prev— the node just before the current group (starts as dummy)group_next— the node just after the current group- The current group itself (k nodes starting from
group_prev.next)
For each group:
- Find the k-th node from
group_prev. If it doesn’t exist, stop. - Save
group_next = kth_node.next, then reverse the group. - Re-attach:
group_prev.next = kth_node(new head),original_head.next = group_next. - Advance
group_prev = original_head(which is now the tail of the reversed group).
dummy -> 1 -> 2 -> 3 -> 4 -> 5, k=2
group_prev = dummy
Find kth node (k=2): node 2
group_next = node 3
Reverse [1,2]: prev=2->1->None, curr=3
Re-attach: dummy.next = 2, 1.next = 3
Advance: group_prev = node 1
State: dummy -> 2 -> 1 -> 3 -> 4 -> 5
group_prev = node 1
Find kth node: node 4
group_next = node 5
Reverse [3,4]: prev=4->3->None, curr=5
Re-attach: 1.next = 4, 3.next = 5
Advance: group_prev = node 3
State: dummy -> 2 -> 1 -> 4 -> 3 -> 5
group_prev = node 3
Find kth node: only node 5 exists, count < k, stop.
Result: 2 -> 1 -> 4 -> 3 -> 5
Algorithm
- Create
dummynode, setgroup_prev = dummy. - Loop:
a. Find the k-th node from
group_prev. If not found, break. b. Save the start of this group:group_start = group_prev.next. c. Save the node after the group:group_next = kth.next. d. Disconnect the group:kth.next = None. e. Reverse the group (returns new head = original kth node). f. Re-attach:group_prev.next = reversed_head,group_start.next = group_next. g. Advancegroup_prev = group_start. - Return
dummy.next.
Solution
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
if not values:
return None
head = ListNode(values[0])
cur = head
for v in values[1:]:
cur.next = ListNode(v)
cur = cur.next
return head
def print_list(head):
result = []
while head:
result.append(str(head.val))
head = head.next
print(" -> ".join(result) if result else "None")
def get_kth(node, k):
"""Return the k-th node from 'node' (1-indexed), or None if not enough nodes."""
while node and k > 0:
node = node.next
k -= 1
return node # None if fewer than k nodes remain
def reverseKGroup(head, k):
dummy = ListNode(0, head)
group_prev = dummy
while True:
# Find the k-th node in the current group
kth = get_kth(group_prev, k)
if not kth:
break # fewer than k nodes left, stop
group_start = group_prev.next # first node of current group
group_next = kth.next # first node of next group
# Reverse the group [group_start ... kth]
# Disconnect from rest first
kth.next = None
prev = None
cur = group_start
while cur:
nxt = cur.next
cur.next = prev
prev = cur
cur = nxt
# prev = kth (new head), group_start = old head (new tail)
# Re-attach
group_prev.next = prev # connect to new head of reversed group
group_start.next = group_next # connect tail of reversed group to remainder
# Advance group_prev to the tail of the just-reversed group
group_prev = group_start
return dummy.next
# Test cases
head = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup(head, 2)) # 2->1->4->3->5
head2 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup(head2, 3)) # 3->2->1->4->5
head3 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup(head3, 1)) # 1->2->3->4->5
head4 = build_list([1, 2, 3, 4, 5])
print("Output: ", end=""); print_list(reverseKGroup(head4, 5)) # 5->4->3->2->1
head5 = build_list([1, 2])
print("Output: ", end=""); print_list(reverseKGroup(head5, 2)) # 2->1
# Remainder left untouched
head6 = build_list([1, 2, 3, 4])
print("Output: ", end=""); print_list(reverseKGroup(head6, 3)) # 3->2->1->4
Complexity
- Time:
O(n)— each node is reversed exactly once - Space:
O(1)— no recursion, only a constant number of pointers
Common Pitfalls
Not checking for k remaining nodes before reversing. If you reverse whenever you can, you’ll flip the last partial group too. Always call get_kth first and bail if there aren’t enough nodes.
Losing the connection to the next group. Before reversing, always save group_next = kth.next. After reversing, the tail of the reversed group (group_start) must point to group_next, not None.
Not advancing group_prev correctly. After reversing a group, group_prev should move to group_start (the old head, now the tail). This is where the next group begins. If you advance to the new head instead, you’ll process the same nodes again.
Off-by-one in get_kth. The function get_kth(node, k) finds the k-th node from node by advancing k times from node (not from node.next). Make sure you advance exactly k times and return the result — return None if the loop exits because node became None.
Recursion
A function that calls itself — like two mirrors facing each other, creating infinite reflections. Each reflection is a smaller version of the whole, and somewhere, the reflections stop. That stopping point is what makes recursion useful rather than infinite.
Recursion is one of the most elegant ideas in computer science. Once it clicks, you will see it everywhere: file systems, trees, sorting algorithms, compilers, and natural language parsers all lean on it.
The Core Idea
Every recursive solution has exactly two parts:
| Part | What it does | What happens without it |
|---|---|---|
| Base case | Stops the recursion | Infinite loop → stack overflow |
| Recursive case | Breaks the problem into a smaller version of itself | Nothing gets computed |
Think of it like Russian nesting dolls (matryoshka). You open a doll to find a smaller doll inside, and you keep opening until you reach a doll that cannot be opened — that is the base case.
How the Call Stack Works
When a function calls itself, Python pushes a new frame onto the call stack. Each frame holds its own local variables and waits for the one below it to return. Once the base case is reached, the stack unwinds — each frame picks up where it left off.
flowchart TD
A["countdown(3)\n calls countdown(2)"] --> B["countdown(2)\n calls countdown(1)"]
B --> C["countdown(1)\n calls countdown(0)"]
C --> D["countdown(0)\n BASE CASE\n returns immediately"]
D -->|"returns"| C
C -->|"prints 1, returns"| B
B -->|"prints 2, returns"| A
A -->|"prints 3, returns"| E["done"]
style D fill:#2d6a4f,color:#fff
style E fill:#1b4332,color:#fff
Here it is in code:
def countdown(n):
if n <= 0: # base case — stop here
print("Go!")
return
print(n)
countdown(n - 1) # recursive case — smaller problem
countdown(3)
The Danger: Stack Overflow
Python keeps a limit on how deep the call stack can go (usually 1000 frames). If your base case is never reached — or the input is enormous — Python raises a RecursionError.
import sys
# Python's default recursion limit
print("Default recursion limit:", sys.getrecursionlimit())
# This function has no base case — it will crash
def infinite(n):
return infinite(n + 1)
# Uncomment the line below to see the error:
# infinite(0)
print("Recursion errors happen when the stack gets too deep.")
print("Always make sure your base case is reachable!")
The fix is almost always one of:
- Make sure the base case exists and is reachable.
- Switch to an iterative solution for very deep inputs.
- Use memoization so repeated calls do not re-enter the stack.
The Two-Question Test
Before writing any recursive function, answer these two questions:
- What is the smallest version of this problem I can solve directly? → That is your base case.
- How does solving a slightly smaller version help me solve the full version? → That is your recursive case.
If you can answer both clearly, the code almost writes itself.
In This Section
- Factorial — the cleanest first recursion example: call stack unwinding, iterative comparison, real-world combinatorics
- Fibonacci Sequence — why naive recursion can be dangerously slow, and how memoization rescues it
Factorial
How many ways can 5 friends sit in a row? The first seat has 5 choices, the second has 4, then 3, then 2, then 1. Multiply them together: 5 × 4 × 3 × 2 × 1 = 120. That is 5 factorial, written 5!
Factorial is the perfect first recursion problem because the mathematical definition and the recursive code are nearly identical.
The Mathematical Definition
0! = 1 ← base case (defined by convention)
n! = n × (n - 1)! ← recursive case
Reading that second line: “the factorial of n is n multiplied by the factorial of n minus 1.” The definition refers to itself. That is exactly what recursion does.
Translating Math to Code
def factorial(n):
# Base case: factorial(0) = 1
if n <= 0:
return 1
# Recursive case: n! = n × (n-1)!
return n * factorial(n - 1)
# Print a small table
print(f"{'n':>3} {'n!':>10}")
print("-" * 16)
for n in range(8):
print(f"{n:>3} {factorial(n):>10}")
The code mirrors the math almost word for word. That directness is recursion’s greatest strength.
Watching the Call Stack Unwind
When you call factorial(4), Python does not compute the answer in one shot. It opens a chain of suspended function calls, then closes them one by one as answers come back.
flowchart TD
A["factorial(4)\nwaiting for factorial(3)..."] --> B["factorial(3)\nwaiting for factorial(2)..."]
B --> C["factorial(2)\nwaiting for factorial(1)..."]
C --> D["factorial(1)\nwaiting for factorial(0)..."]
D --> E["factorial(0)\nBASE CASE → returns 1"]
E -->|"returns 1"| D2["factorial(1)\n1 × 1 = 1 → returns 1"]
D2 -->|"returns 1"| C2["factorial(2)\n2 × 1 = 2 → returns 2"]
C2 -->|"returns 2"| B2["factorial(3)\n3 × 2 = 6 → returns 6"]
B2 -->|"returns 6"| A2["factorial(4)\n4 × 6 = 24 → returns 24"]
style E fill:#2d6a4f,color:#fff
style A2 fill:#1b4332,color:#fff
Phase 1 (going down): Each call discovers it needs a smaller answer first, so it suspends and calls itself again. The call stack grows.
Phase 2 (coming back up): Once the base case returns, each suspended call wakes up, finishes its multiplication, and returns. The call stack shrinks.
Call Stack Depth
Every frame on the call stack costs memory. For factorial(n), the stack reaches depth n before it starts unwinding:
def factorial_with_depth(n, depth=0):
indent = " " * depth
print(f"{indent}factorial({n}) called — stack depth: {depth + 1}")
if n <= 0:
print(f"{indent} → base case, returning 1")
return 1
result = n * factorial_with_depth(n - 1, depth + 1)
print(f"{indent} → returning {result}")
return result
factorial_with_depth(5)
For factorial, a stack depth equal to n is fine. But if n were one million, you would hit Python’s recursion limit. This is why recursion depth matters.
Iterative Version — Same Answer, No Stack
def factorial_iterative(n):
result = 1
for x in range(2, n + 1):
result *= x
return result
# Verify both give the same answers
def factorial_recursive(n):
if n <= 0:
return 1
return n * factorial_recursive(n - 1)
print(f"{'n':>3} {'recursive':>12} {'iterative':>12} {'match':>6}")
print("-" * 38)
for n in range(10):
r = factorial_recursive(n)
i = factorial_iterative(n)
print(f"{n:>3} {r:>12} {i:>12} {str(r == i):>6}")
| Recursive | Iterative | |
|---|---|---|
| Readability | Mirrors the math definition | Requires mental translation |
| Stack usage | O(n) frames | O(1) — no stack growth |
| Risk | Stack overflow for large n | None |
| When to prefer | Teaching, prototyping | Production with large inputs |
Real-World Uses
Combinatorics — How many ways can you arrange a deck of 52 cards? That is 52!, a number with 68 digits. Probability theory depends on factorials for counting arrangements.
Permutations in cryptography — The strength of many encryption schemes comes from the astronomical number of possible key arrangements, which factorials quantify.
Binomial coefficients — Choosing k items from n uses factorials: C(n, k) = n! / (k! × (n-k)!). This appears in statistics, Pascal’s triangle, and machine learning (binomial distributions).
def factorial(n):
if n <= 0:
return 1
return n * factorial(n - 1)
def choose(n, k):
"""How many ways to choose k items from n? (combinations)"""
return factorial(n) // (factorial(k) * factorial(n - k))
# How many 5-card poker hands exist from a 52-card deck?
hands = choose(52, 5)
print(f"Possible 5-card poker hands: {hands:,}")
# How many ways to arrange a 3-person podium from 10 contestants?
# This is a permutation: P(10, 3) = 10! / (10-3)!
def permute(n, k):
return factorial(n) // factorial(n - k)
podiums = permute(10, 3)
print(f"3-place podium arrangements from 10 people: {podiums:,}")
Key Takeaways
- Factorial is the archetypal recursive problem: the definition IS the algorithm.
- The call stack grows going down, shrinks coming back up — understand this deeply and all recursion becomes clear.
- For large inputs, the iterative version is safer; the recursive version is more readable.
Fibonacci Sequence
This sequence appears in sunflower seeds, galaxy spirals, and stock markets. Each number is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34…
It is also the most important example of why naive recursion can be dangerously slow — and how a single insight (memoization) transforms it from unusable to instant.
The Definition
fib(0) = 0 ← base case
fib(1) = 1 ← base case
fib(n) = fib(n-1) + fib(n-2) ← recursive case
Two base cases, one recursive case. Translating directly to Python:
Naive Recursive Version
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
for n in range(10):
print(f"fib({n}) = {fib(n)}")
Elegant. Clean. And for small n, perfectly fine. But there is a hidden catastrophe waiting for larger inputs.
The Hidden Catastrophe: Exponential Work
To compute fib(5), Python builds this entire call tree:
flowchart TD
A["fib(5)"] --> B["fib(4)"]
A --> C["fib(3) #1"]
B --> D["fib(3) #2"]
B --> E["fib(2) #1"]
D --> F["fib(2) #2"]
D --> G["fib(1) #1"]
E --> H["fib(1) #2"]
E --> I["fib(0) #1"]
F --> J["fib(1) #3"]
F --> K["fib(0) #2"]
C --> L["fib(2) #3"]
C --> M["fib(1) #4"]
L --> N["fib(1) #5"]
L --> O["fib(0) #3"]
style A fill:#1b4332,color:#fff
style C fill:#b5451b,color:#fff
style D fill:#b5451b,color:#fff
style L fill:#b5451b,color:#fff
Notice fib(3) is computed twice, fib(2) is computed three times, and fib(1) five times. This is called overlapping subproblems, and it gets catastrophically worse as n grows.
# Count how many times each fib(n) gets called
call_counts = {}
def fib_counted(n):
call_counts[n] = call_counts.get(n, 0) + 1
if n <= 1:
return n
return fib_counted(n - 1) + fib_counted(n - 2)
fib_counted(10)
print(f"{'fib(n)':>8} {'times called':>14}")
print("-" * 25)
for k in sorted(call_counts):
print(f"{'fib(' + str(k) + ')':>8} {call_counts[k]:>14}")
total = sum(call_counts.values())
print(f"\nTotal function calls to compute fib(10): {total}")
print("For fib(40) this would be over 300 million calls.")
The number of calls grows as roughly 2^n. For fib(50), that is over one quadrillion calls. A modern laptop doing billions of operations per second would still take years.
Memoization: Remember What You Computed
The fix is simple: if you have already computed fib(k), store the answer and return it immediately instead of recomputing.
This is called memoization (not memorization — it comes from “memo”, as in a written note to yourself).
def fib_memo(n, cache={}):
# Already computed? Return instantly.
if n in cache:
return cache[n]
# Base cases
if n <= 1:
return n
# Compute, store, return
cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
return cache[n]
# Now fib(40) is instant
for n in [10, 20, 30, 40, 50]:
print(f"fib({n}) = {fib_memo(n)}")
Python also has a built-in decorator for this:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_cached(n):
if n <= 1:
return n
return fib_cached(n - 1) + fib_cached(n - 2)
# Identical results, zero extra code
for n in [10, 20, 30, 40, 50]:
print(f"fib({n}) = {fib_cached(n)}")
@lru_cache automatically memoizes any function. It is one of the most useful tools in Python for recursive algorithms.
Bottom-Up: Iterative Version
Memoization (top-down) works by caching recursive calls. The iterative approach (bottom-up) builds from the smallest values upward, never recursing at all:
def fib_iterative(n):
if n <= 1:
return n
prev2, prev1 = 0, 1
for _ in range(2, n + 1):
prev2, prev1 = prev1, prev1 + prev2
return prev1
# Verify all three approaches give the same results
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1:
return n
return fib_memo(n - 1) + fib_memo(n - 2)
print(f"{'n':>4} {'iterative':>12} {'memoized':>12} {'match':>6}")
print("-" * 40)
for n in range(12):
i = fib_iterative(n)
m = fib_memo(n)
print(f"{n:>4} {i:>12} {m:>12} {str(i == m):>6}")
Comparing All Three Approaches
import time
from functools import lru_cache
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
@lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1:
return n
return fib_memo(n - 1) + fib_memo(n - 2)
def fib_iter(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
n = 30
print(f"Computing fib({n}):\n")
start = time.time()
result = fib_naive(n)
naive_time = time.time() - start
print(f" Naive recursive: {result} ({naive_time:.4f}s)")
# Clear the cache first so it starts fresh
fib_memo.cache_clear()
start = time.time()
result = fib_memo(n)
memo_time = time.time() - start
print(f" Memoized: {result} ({memo_time:.6f}s)")
start = time.time()
result = fib_iter(n)
iter_time = time.time() - start
print(f" Iterative: {result} ({iter_time:.6f}s)")
print(f"\nMemoized is roughly {naive_time / max(memo_time, 0.000001):.0f}x faster than naive.")
| Approach | Time complexity | Space complexity | Stack depth |
|---|---|---|---|
| Naive recursive | O(2^n) | O(n) stack | O(n) |
| Memoized recursive | O(n) | O(n) cache + O(n) stack | O(n) |
| Iterative | O(n) | O(1) | O(1) |
Real-World Appearances
Nature — The number of petals on most flowers, the spiral count in sunflower seed heads, and the branching patterns of trees all follow Fibonacci numbers. The ratio of consecutive Fibonacci numbers converges to the golden ratio (1.618…), which appears throughout art and architecture.
def fib_iterative(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
# Fibonacci numbers converge to the golden ratio
print("Ratio of consecutive Fibonacci numbers (converges to golden ratio):\n")
for n in range(2, 16):
f_n = fib_iterative(n)
f_prev = fib_iterative(n - 1)
ratio = f_n / f_prev
print(f" fib({n}) / fib({n-1}) = {f_n} / {f_prev} = {ratio:.8f}")
import math
print(f"\n Golden ratio φ = {(1 + math.sqrt(5)) / 2:.8f}")
Dynamic programming — Fibonacci is the entry point to DP. The memoization pattern you learned here — identify overlapping subproblems, cache results, build from smaller to larger — is the exact same pattern used in harder problems like longest common subsequence, knapsack, and edit distance.
Financial models — Fibonacci retracement levels (23.6%, 38.2%, 61.8%) are used in technical analysis of stock and commodity markets. Whether or not they predict prices, they are watched by enough traders that they become self-fulfilling.
Key Takeaways
- Naive recursion on Fibonacci is O(2^n) — practically unusable past n=40.
- The problem is overlapping subproblems: the same values computed repeatedly.
- Memoization caches intermediate results, cutting the complexity to O(n).
- The iterative bottom-up version is O(n) time and O(1) space — the most efficient.
- This pattern (naive recursion → memoization → iterative DP) is one of the most important algorithmic techniques you will ever learn.
Sorting Algorithms
Your phone contacts, your Spotify playlist, your Amazon search results — all sorted. Sorting is one of the most fundamental operations in computing. Without it, searching would be slow, databases would grind to a halt, and every app you use daily would feel sluggish.
Understanding sorting algorithms teaches you to think about trade-offs: speed vs. memory, simplicity vs. efficiency, best-case vs. worst-case. These are the same trade-offs you encounter in every engineering decision.
Why do we need multiple sorting algorithms?
There is no single best sorting algorithm. The right choice depends on:
- How much data? 10 items vs. 10 million items changes everything.
- Is it already partially sorted? Some algorithms exploit existing order.
- Do equal elements need to stay in their original order? (Stability)
- How much extra memory can we use? In-place vs. auxiliary space.
- What is the data’s distribution? Uniform? Skewed? Bounded range?
Algorithm Comparison
| Algorithm | Best Case | Average Case | Worst Case | Space | Stable? |
|---|---|---|---|---|---|
| Insertion Sort | O(n) | O(n²) | O(n²) | O(1) | Yes |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | Yes |
| Quick Sort | O(n log n) | O(n log n) | O(n²) | O(log n) | No |
| Bucket Sort | O(n + k) | O(n + k) | O(n²) | O(n + k) | Yes |
k = number of buckets
Visualising Complexity
flowchart TD
subgraph Legend["Time Complexity Growth"]
A["n = 1,000 elements"]
end
subgraph Insertion["Insertion Sort — O(n²)"]
B["1,000² = 1,000,000 operations"]
end
subgraph Merge["Merge Sort — O(n log n)"]
C["1,000 × 10 = 10,000 operations"]
end
subgraph Quick["Quick Sort — O(n log n) avg"]
D["1,000 × 10 ≈ 10,000 operations"]
end
subgraph Bucket["Bucket Sort — O(n + k)"]
E["1,000 + k ≈ linear operations"]
end
A --> B
A --> C
A --> D
A --> E
When to use each
flowchart TD
Start([You need to sort data]) --> Q1{How many elements?}
Q1 -->|"Small — under ~50"| Q2{Nearly sorted?}
Q1 -->|"Large — thousands+"| Q3{Known numeric range?}
Q2 -->|Yes| Ins[Insertion Sort\nExploits existing order]
Q2 -->|No| Ins2[Insertion Sort\nStill fast for tiny n]
Q3 -->|Yes, uniform spread| Bkt[Bucket Sort\nLinear time possible]
Q3 -->|No / unknown| Q4{Need stable sort?}
Q4 -->|Yes| Mrg[Merge Sort\nGuaranteed O n log n]
Q4 -->|No| Qck[Quick Sort\nFastest in practice]
In this section
- Insertion Sort — Start here. It mirrors how humans naturally sort cards.
- Merge Sort — Learn divide and conquer. Guaranteed O(n log n) always.
- Quick Sort — The workhorse of standard libraries. Fast in practice.
- Bucket Sort — Linear time when you know your data’s range.
Insertion Sort
This is exactly how you sort playing cards in your hand. You pick up one card at a time and slide it left until it lands in the right spot among the cards you already hold. Every human does this instinctively — and now you will teach a computer to do it too.
The Core Idea
Imagine the array is split into two regions: a sorted left side and an unsorted right side. At each step, we take the first element from the unsorted side — call it the key — and insert it into its correct position in the sorted side by shifting larger elements one step to the right.
flowchart LR
subgraph Before["Before each pass"]
S["Sorted region"] --> K["KEY (next to insert)"] --> U["Unsorted region"]
end
Step-by-Step on [5, 2, 4, 6, 1, 3]
flowchart TD
Start["Initial: [5, 2, 4, 6, 1, 3]"]
subgraph Pass1["Pass 1 — key = 2"]
P1A["[5, 2, 4, 6, 1, 3] ← 2 < 5, shift 5 right"]
P1B["[2, 5, 4, 6, 1, 3] ← insert 2 at index 0"]
end
subgraph Pass2["Pass 2 — key = 4"]
P2A["[2, 5, 4, 6, 1, 3] ← 4 < 5, shift 5 right"]
P2B["[2, 4, 5, 6, 1, 3] ← 4 > 2, insert here"]
end
subgraph Pass3["Pass 3 — key = 6"]
P3A["[2, 4, 5, 6, 1, 3] ← 6 > 5, already in place"]
end
subgraph Pass4["Pass 4 — key = 1"]
P4A["[2, 4, 5, 6, 1, 3] ← 1 < 6, shift"]
P4B["[2, 4, 5, 5, 1, 3] ← 1 < 5, shift"]
P4C["[2, 4, 4, 5, 1, 3] ← 1 < 4, shift"]
P4D["[2, 2, 4, 5, 1, 3] ← 1 < 2, shift"]
P4E["[1, 2, 4, 5, 6, 3] ← insert 1 at index 0"]
end
subgraph Pass5["Pass 5 — key = 3"]
P5A["[1, 2, 4, 5, 6, 3] ← 3 < 6, shift"]
P5B["[1, 2, 4, 5, 5, 3] ← 3 < 5, shift"]
P5C["[1, 2, 4, 4, 5, 3] ← 3 < 4, shift"]
P5D["[1, 2, 3, 4, 5, 6] ← 3 > 2, insert here"]
end
Done["Done: [1, 2, 3, 4, 5, 6]"]
Start --> Pass1 --> Pass2 --> Pass3 --> Pass4 --> Pass5 --> Done
Implementation
def insertion_sort(arr):
# Work on a copy so we don't mutate the original
arr = arr[:]
# The sorted region starts with just arr[0].
# We grow it one element at a time.
for i in range(1, len(arr)):
key = arr[i] # The element we are about to insert
j = i - 1 # Start comparing from the rightmost sorted element
# Shift every sorted element that is larger than key one step right.
# This opens up a slot for key.
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
# j+1 is now the correct position for key
arr[j + 1] = key
return arr
# --- Demo ---
data = [5, 2, 4, 6, 1, 3]
print("Before:", data)
print("After: ", insertion_sort(data))
# Nearly-sorted data — very fast!
nearly_sorted = [1, 2, 4, 3, 5, 6]
print("\nNearly sorted before:", nearly_sorted)
print("Nearly sorted after: ", insertion_sort(nearly_sorted))
Time and Space Complexity
| Case | When it happens | Operations |
|---|---|---|
| Best — O(n) | Already sorted. Inner while never runs. | n comparisons, 0 swaps |
| Average — O(n²) | Random data | ~n²/4 comparisons |
| Worst — O(n²) | Reverse sorted: [6,5,4,3,2,1] | ~n²/2 comparisons |
Space: O(1) — sorting happens in-place with only a few extra variables.
Why the best case is O(n)
When the array is already sorted, the while condition arr[j] > key is immediately false for every pass. The outer loop still runs n-1 times, but the inner loop does zero work each time — giving exactly n-1 comparisons total, which is O(n).
When to use Insertion Sort
Use it when:
- The array has fewer than ~50 elements (constant factors dominate at small sizes).
- The data is nearly sorted — even one or two elements out of place is fine.
- You need a stable sort with no extra memory.
- You need to sort a stream of incoming elements one at a time (online algorithm).
Avoid it when:
- n is large (thousands+) and data is random — O(n²) will be painfully slow.
Real-world appearances
- Python’s Timsort uses insertion sort on small subarrays (runs of fewer than 64 elements) before merging. This is why Python’s built-in
sorted()is so fast on real-world data. - Database indexes — when inserting a new row into a small, already-indexed B-tree leaf, the database effectively runs insertion sort to keep keys ordered.
- Card games and board game AI — any program that maintains a small sorted hand of items and inserts new ones one at a time.
Merge Sort
Divide and conquer — split the problem in half until it’s trivial, then combine. Merge sort is the clearest real-world example of this technique: break the array in half, sort each half recursively, then merge the two sorted halves back together. The magic is in the merge step.
The Core Idea
A single-element array is already sorted. That’s the base case. Merge sort works backwards from there: if you have two sorted arrays, you can combine them into one sorted array in O(n) time by just comparing the front elements and taking the smaller one each time.
flowchart TD
subgraph Strategy["Divide and Conquer Strategy"]
D["DIVIDE — split in half until size = 1"]
C["CONQUER — each size-1 array is trivially sorted"]
M["MERGE — combine sorted pairs bottom-up"]
D --> C --> M
end
Full Split and Merge Tree for [38, 27, 43, 3, 9, 82, 10]
flowchart TD
Root["[38, 27, 43, 3, 9, 82, 10]"]
subgraph SplitLeft["Split left half"]
L1["[38, 27, 43, 3]"]
L2["[38, 27]"]
L3["[43, 3]"]
L4["[38]"]
L5["[27]"]
L6["[43]"]
L7["[3]"]
end
subgraph SplitRight["Split right half"]
R1["[9, 82, 10]"]
R2["[9]"]
R3["[82, 10]"]
R4["[82]"]
R5["[10]"]
end
subgraph MergeLeft["Merge left half back up"]
M1["[27, 38]"]
M2["[3, 43]"]
M3["[3, 27, 38, 43]"]
end
subgraph MergeRight["Merge right half back up"]
M4["[10, 82]"]
M5["[9, 10, 82]"]
end
Done["[3, 9, 10, 27, 38, 43, 82]"]
Root --> L1
Root --> R1
L1 --> L2 --> L4
L2 --> L5
L1 --> L3 --> L6
L3 --> L7
L4 --> M1
L5 --> M1
L6 --> M2
L7 --> M2
M1 --> M3
M2 --> M3
R1 --> R2
R1 --> R3 --> R4
R3 --> R5
R4 --> M4
R5 --> M4
R2 --> M5
M4 --> M5
M3 --> Done
M5 --> Done
The Merge Step in Detail
The merge step is where the real work happens. Given two sorted arrays, we pick the smaller front element each time:
flowchart LR
subgraph Input["Two sorted halves"]
Left["Left: [3, 27, 38, 43]\n ^\n i"]
Right["Right: [9, 10, 82]\n ^\n j"]
end
subgraph Steps["Merge steps"]
S1["Compare 3 vs 9 → take 3 → result: [3]"]
S2["Compare 27 vs 9 → take 9 → result: [3, 9]"]
S3["Compare 27 vs 10→ take 10 → result: [3, 9, 10]"]
S4["Compare 27 vs 82→ take 27 → result: [3, 9, 10, 27]"]
S5["Compare 38 vs 82→ take 38 → result: [3, 9, 10, 27, 38]"]
S6["Compare 43 vs 82→ take 43 → result: [3, 9, 10, 27, 38, 43]"]
S7["Left exhausted → append 82 → result: [3, 9, 10, 27, 38, 43, 82]"]
end
Input --> S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
Implementation
def merge_sort(nums):
# Base case: a list of 0 or 1 elements is already sorted
if len(nums) <= 1:
return nums[:]
# DIVIDE — find the midpoint and split
mid = len(nums) // 2
left = merge_sort(nums[:mid]) # Recursively sort the left half
right = merge_sort(nums[mid:]) # Recursively sort the right half
# MERGE — combine two sorted halves into one sorted list
return merge(left, right)
def merge(left, right):
result = []
i = 0 # Pointer into left
j = 0 # Pointer into right
# Compare front elements; always take the smaller one
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
# One side is exhausted — append the remainder of the other side
result.extend(left[i:])
result.extend(right[j:])
return result
# --- Demo ---
data = [38, 27, 43, 3, 9, 82, 10]
print("Before:", data)
print("After: ", merge_sort(data))
# Merge sort handles all cases equally well
print("\nReverse sorted:", merge_sort([6, 5, 4, 3, 2, 1]))
print("Already sorted:", merge_sort([1, 2, 3, 4, 5, 6]))
print("Duplicates: ", merge_sort([3, 1, 4, 1, 5, 9, 2, 6]))
Why O(n log n)?
The key insight is that the recursion tree has log n levels (because we halve the array each time), and each level does O(n) total work in the merge step.
flowchart TD
subgraph Level0["Level 0 — 1 merge of n elements → n work"]
L0["merge([3,27,38,43], [9,10,82]) → n comparisons"]
end
subgraph Level1["Level 1 — 2 merges of n/2 elements each → n work"]
L1A["merge([27,38], [3,43])"]
L1B["merge([82], [9,10])"]
end
subgraph Level2["Level 2 — 4 merges of n/4 elements each → n work"]
L2A["merge([38],[27])"]
L2B["merge([43],[3])"]
L2C["merge([82],[10])"]
end
subgraph Summary["Total"]
S["log₂(n) levels × n work per level = O(n log n)"]
end
Level2 --> Level1 --> Level0 --> Summary
For n=7: log₂(7) ≈ 3 levels × 7 work per level = ~21 operations. Compare that to insertion sort’s worst case of 7² = 49.
Time and Space Complexity
| Case | Time | Why |
|---|---|---|
| Best | O(n log n) | Always splits and merges the same way |
| Average | O(n log n) | Same structure regardless of input |
| Worst | O(n log n) | Guaranteed — no bad pivot problem |
Space: O(n) — merge sort needs an auxiliary array of size n to hold the merged result. This is the trade-off vs. in-place algorithms.
Stable: Yes — when left[i] <= right[j], we take from the left. Equal elements preserve their original order.
When to use Merge Sort
Use it when:
- You need a guaranteed O(n log n) worst case (Quick Sort can degrade to O(n²)).
- Stability matters — you need equal elements to stay in their original order.
- You are sorting a linked list (merge sort works naturally; Quick Sort does not).
- Data doesn’t fit in RAM — external merge sort splits a huge file into sorted chunks, writes them to disk, then merges the chunk files. This is how databases sort tables too large to fit in memory.
Trade-off: Requires O(n) extra space for the temporary merged array.
Real-world appearances
- Python’s Timsort (used by
sorted()andlist.sort()) is a hybrid of merge sort and insertion sort. It finds naturally ordered runs in the data and merges them — making it extremely fast on real-world data. - External sorting — merge sort is the foundation of every large-scale sort that uses disk or distributed storage. MapReduce’s shuffle-and-sort phase is essentially a distributed merge sort.
- Inversion counting — a classic interview problem solved by modifying the merge step to count how many times we pick from the right before the left.
Quick Sort
The fastest sorting algorithm in practice — used by C’s qsort(), Java’s Arrays.sort() for primitives, and the internals of most standard libraries. Quick sort is O(n log n) on average with tiny constant factors and sorts in-place, meaning it needs no extra array. That combination makes it the default choice for general-purpose sorting.
The Core Idea
Pick an element called the pivot. Rearrange the array so that:
- Every element less than or equal to the pivot is to its left.
- Every element greater than the pivot is to its right.
- The pivot is now in its final sorted position.
Then recursively apply the same logic to the left and right sub-arrays. When every sub-array is size 1, you are done.
flowchart TD
subgraph Idea["Quick Sort Strategy"]
P["Pick a PIVOT"]
Part["PARTITION — smaller elements left, larger right"]
Rec["RECURSE on both sides"]
P --> Part --> Rec
end
Partition Step Visualised (Lomuto Scheme)
We use the last element as the pivot and maintain a boundary i that tracks where the “less than pivot” region ends.
Array: [3, 6, 8, 10, 1, 2, 1], pivot = 1 (last element)
flowchart TD
subgraph Init["Setup: pivot = arr[right] = 1, i = left - 1 = -1"]
A["[3, 6, 8, 10, 1, 2, 1]"]
end
subgraph J0["j=0: arr[j]=3, 3 > pivot(1) — no swap, i stays -1"]
B["[3, 6, 8, 10, 1, 2, 1] boundary i=-1"]
end
subgraph J1["j=1: arr[j]=6, 6 > pivot(1) — no swap, i stays -1"]
C["[3, 6, 8, 10, 1, 2, 1] boundary i=-1"]
end
subgraph J2["j=2: arr[j]=8, 8 > pivot — no swap"]
D["[3, 6, 8, 10, 1, 2, 1] boundary i=-1"]
end
subgraph J3["j=3: arr[j]=10, 10 > pivot — no swap"]
E["[3, 6, 8, 10, 1, 2, 1] boundary i=-1"]
end
subgraph J4["j=4: arr[j]=1, 1 <= pivot — i becomes 0, swap arr[0] and arr[4]"]
F["[1, 6, 8, 10, 3, 2, 1] boundary i=0"]
end
subgraph J5["j=5: arr[j]=2, 2 > pivot — no swap"]
G["[1, 6, 8, 10, 3, 2, 1] boundary i=0"]
end
subgraph Final["Place pivot: swap arr[i+1]=arr[1] with arr[right]=arr[6]"]
H["[1, 1, 8, 10, 3, 2, 6] pivot 1 is now at index 1 (final position)"]
end
subgraph Result["Recurse on [1] and [8, 10, 3, 2, 6]"]
I["Left [1] — size 1, done"]
J["Right [8,10,3,2,6] — sort recursively"]
end
Init --> J0 --> J1 --> J2 --> J3 --> J4 --> J5 --> Final --> Result
Implementation (Lomuto Partition Scheme)
def quick_sort(nums):
arr = nums[:] # Work on a copy to avoid mutating the input
_sort(arr, 0, len(arr) - 1)
return arr
def _sort(arr, left, right):
# Base case: a sub-array of 0 or 1 elements is already sorted
if left >= right:
return
pivot_index = _partition(arr, left, right)
# Recursively sort elements before and after the pivot
_sort(arr, left, pivot_index - 1)
_sort(arr, pivot_index + 1, right)
def _partition(arr, left, right):
pivot = arr[right] # Lomuto: always use the last element as pivot
i = left - 1 # i tracks the end of the "less than pivot" region
for j in range(left, right):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i] # grow the left region
# Place the pivot in its correct final position
arr[i + 1], arr[right] = arr[right], arr[i + 1]
return i + 1 # Return the pivot's final index
# --- Demo ---
data = [3, 6, 8, 10, 1, 2, 1]
print("Before:", data)
print("After: ", quick_sort(data))
print("\nRandom: ", quick_sort([5, 1, 4, 2, 8, 3]))
print("Already sorted:", quick_sort([1, 2, 3, 4, 5, 6]))
print("Reverse sorted:", quick_sort([6, 5, 4, 3, 2, 1]))
Time Complexity — The Full Picture
flowchart TD
subgraph Best["Best / Average Case — O(n log n)"]
BA["Pivot lands near the middle each time"]
BA --> BA2["Recursion tree has log n levels"]
BA2 --> BA3["Each level does n total work in partitioning"]
BA3 --> BA4["Result: n × log n = O(n log n)"]
end
subgraph Worst["Worst Case — O(n²)"]
WA["Pivot always lands at one end"]
WA --> WA2["Happens when array is already sorted\nand we always pick the last element"]
WA2 --> WA3["Recursion tree has n levels (not log n)"]
WA3 --> WA4["Each level does n, n-1, n-2 ... work"]
WA4 --> WA5["Result: n + (n-1) + ... + 1 = O(n²)"]
end
Why the worst case matters
If you receive [1, 2, 3, 4, 5, ..., 1000] and always pick the last element as pivot, the pivot is always the largest. The “less than” partition is empty, and the “greater than” partition has n-1 elements. This degrades to O(n²) — as slow as bubble sort.
How to avoid it: Randomise the pivot selection (pick a random index before partitioning, swap it to the end). This makes the O(n²) case astronomically unlikely in practice.
Time and Space Complexity
| Case | Time | When it happens |
|---|---|---|
| Best | O(n log n) | Pivot always splits array in half |
| Average | O(n log n) | Random data, random pivot |
| Worst | O(n²) | Already-sorted data with last-element pivot |
Space: O(log n) — no extra array needed, but the recursion call stack uses O(log n) space on average (O(n) in the worst case).
Stable: No — the partition step may change the relative order of equal elements.
When to use Quick Sort
Use it when:
- You want the fastest practical sorting for large random datasets.
- Sorting in-place matters (no extra O(n) memory like merge sort).
- You can randomise the pivot to avoid the O(n²) worst case.
Avoid it when:
- You need a guaranteed worst-case O(n log n) — use Merge Sort instead.
- Stability matters — use Merge Sort or Timsort instead.
- You are sorting a linked list — partitioning by index doesn’t translate well.
Real-world appearances
- C standard library
qsort()— the name literally comes from Quick Sort. - Java
Arrays.sort()for primitives — uses a dual-pivot Quick Sort (Yaroslavskiy’s algorithm), which is faster than single-pivot in practice. - Database query optimisation — when a database engine sorts rows for an
ORDER BYclause on non-indexed columns, it typically uses an in-place sort similar to Quick Sort. - Median-of-three pivot — a common production optimisation: pick the pivot as the median of the first, middle, and last elements. This eliminates the sorted-array worst case without full randomisation.
Bucket Sort
When you know your data’s range, you can sort in linear time — faster than any comparison sort. Comparison-based algorithms like merge sort and quick sort have a theoretical lower bound of O(n log n). Bucket sort sidesteps this entirely because it doesn’t compare elements against each other. Instead, it uses the value itself as a guide to where each element belongs.
The Core Idea
Divide the value range into equal-width buckets. Scatter each element into its bucket. Sort each bucket individually (they are much smaller). Concatenate all buckets in order.
flowchart LR
subgraph Input["Input array"]
A["[29, 25, 3, 49, 9, 37, 21, 43]"]
end
subgraph Scatter["Scatter into buckets (range 0-49, 5 buckets of width 10)"]
B0["Bucket 0\n[0-9]\n[3, 9]"]
B1["Bucket 1\n[10-19]\n[]"]
B2["Bucket 2\n[20-29]\n[29, 25, 21]"]
B3["Bucket 3\n[30-39]\n[37]"]
B4["Bucket 4\n[40-49]\n[49, 43]"]
end
subgraph Sort["Sort each bucket"]
S0["[3, 9]"]
S1["[]"]
S2["[21, 25, 29]"]
S3["[37]"]
S4["[43, 49]"]
end
subgraph Gather["Concatenate in order"]
R["[3, 9, 21, 25, 29, 37, 43, 49]"]
end
A --> B0
A --> B1
A --> B2
A --> B3
A --> B4
B0 --> S0 --> R
B1 --> S1 --> R
B2 --> S2 --> R
B3 --> S3 --> R
B4 --> S4 --> R
Bucket Index Formula
For a value x in range [min_value, max_value] with k buckets:
index = (x - min_value) * k // (max_value - min_value + 1)
This maps every value linearly to a bucket index in [0, k-1].
Implementation
def bucket_sort(nums, bucket_count=5):
if not nums:
return []
min_value = min(nums)
max_value = max(nums)
# Edge case: all values are identical — already sorted
if min_value == max_value:
return nums[:]
# Create empty buckets
buckets = [[] for _ in range(bucket_count)]
span = max_value - min_value + 1 # Total range of values
# SCATTER — distribute each element into its bucket
for x in nums:
# Map value to bucket index (clamp to avoid off-by-one at max_value)
index = (x - min_value) * bucket_count // span
index = min(index, bucket_count - 1) # Safety clamp
buckets[index].append(x)
# SORT — sort each bucket individually (insertion sort shines here)
result = []
for bucket in buckets:
result.extend(sorted(bucket)) # sorted() uses Timsort on small lists
return result
# --- Demo 1: Integers in a known range ---
scores = [29, 25, 3, 49, 9, 37, 21, 43]
print("Exam scores before:", scores)
print("Exam scores after: ", bucket_sort(scores))
# --- Demo 2: Floats between 0.0 and 1.0 ---
def bucket_sort_floats(nums, bucket_count=10):
if not nums:
return []
buckets = [[] for _ in range(bucket_count)]
for x in nums:
index = min(int(x * bucket_count), bucket_count - 1)
buckets[index].append(x)
result = []
for bucket in buckets:
result.extend(sorted(bucket))
return result
floats = [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68]
print("\nFloats before:", floats)
print("Floats after: ", bucket_sort_floats(floats))
# --- Demo 3: Grades 0-100 ---
grades = [88, 42, 75, 91, 63, 55, 79, 84, 97, 61]
print("\nGrades before:", grades)
print("Grades after: ", bucket_sort(grades, bucket_count=10))
Time and Space Complexity
flowchart TD
subgraph Best["Best / Average Case — O(n + k)"]
BA1["Elements are uniformly distributed"]
BA2["Each bucket holds approximately n/k elements"]
BA3["Sorting k buckets of size n/k takes k × O(n/k log n/k)"]
BA4["With good distribution ≈ O(n)"]
BA1 --> BA2 --> BA3 --> BA4
end
subgraph Worst["Worst Case — O(n²)"]
WA1["All elements land in the same bucket"]
WA2["Inner sort now handles all n elements"]
WA3["If using insertion sort inside: O(n²)"]
WA1 --> WA2 --> WA3
end
subgraph Key["Key insight"]
K["Bucket sort is only fast when distribution is UNIFORM\nIf data is skewed, one bucket gets everything"]
end
| Case | Time | Space |
|---|---|---|
| Best | O(n + k) | O(n + k) |
| Average (uniform data) | O(n + k) | O(n + k) |
| Worst (all in one bucket) | O(n²) | O(n + k) |
n = number of elements, k = number of buckets
Stable: Yes — elements are appended to buckets in order, and each bucket is sorted stably.
When Bucket Sort Shines
Use it when:
- You know the range of your data in advance.
- Data is roughly uniformly distributed across that range.
- You need to beat the O(n log n) barrier for large datasets.
Classic sweet spots:
- Floats uniformly distributed between 0.0 and 1.0.
- Test scores between 0 and 100.
- Ages, percentages, ratings — any bounded numeric data.
Avoid it when:
- Data is heavily skewed — most values cluster in one region.
- You don’t know the range ahead of time.
- Values aren’t numeric or easily mappable to an index.
Real-world appearances
- Sorting exam scores — 100 buckets for grades 0-100 means most buckets have just 1-2 students. Sorting is nearly instant regardless of class size.
- Painter’s algorithm in 3D rendering — to draw objects back-to-front, render engines bucket-sort objects by depth (z-value). Each depth bucket is drawn in order, creating the correct visual layering without z-buffer complexity.
- Network packet ordering — packets arriving out of order are placed into sequence-number buckets and read out in order. The bounded sequence space maps perfectly to fixed buckets.
- Radix sort foundation — Radix sort (a cousin of bucket sort) applies bucket distribution digit by digit, achieving O(n) for integers. It is used in GPU sorting and large-scale data processing pipelines.
Binary Search
Imagine a dictionary with 100,000 words. A linear search checks every word one by one — up to 100,000 checks in the worst case. Binary search finds any word in at most 17 checks. That is the power of O(log n).
How is that possible? Because a dictionary is already sorted. When you open it to the middle, you instantly know whether your word comes before or after that page — and you can throw away half the book. Do that again and again, and you converge on your answer with stunning speed.
The Core Idea
Binary search works by repeatedly halving the search space:
- Look at the middle element.
- If it equals the target, you’re done.
- If the target is smaller, throw away the right half.
- If the target is larger, throw away the left half.
- Repeat on the surviving half.
The one hard requirement: the array (or range) must be sorted. Binary search relies on the guarantee that everything to the left is smaller and everything to the right is larger. Without that guarantee, discarding half the array would be wrong.
Visualising the Halving
Searching for 7 in the sorted array [1, 3, 5, 7, 9, 11, 13]:
graph TD
A["Full array: [1, 3, 5, 7, 9, 11, 13]\nmid = 5 → too small, go right"]
B["Right half: [7, 9, 11, 13]\nmid = 9 → too big, go left"]
C["Left half: [7]\nmid = 7 → FOUND! ✓"]
A --> B
B --> C
Each arrow cuts the remaining candidates in half. Starting with 7 elements, we needed only 3 steps.
Linear vs Binary Search: A Timing Example
import time
def linear_search(nums, target):
for i, val in enumerate(nums):
if val == target:
return i
return -1
def binary_search(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
# Build a sorted list of 1,000,000 numbers
data = list(range(1_000_000))
target = 999_999 # worst case: near the very end
start = time.perf_counter()
result = linear_search(data, target)
linear_time = time.perf_counter() - start
start = time.perf_counter()
result = binary_search(data, target)
binary_time = time.perf_counter() - start
print(f"Linear search: found at index {result}, took {linear_time:.6f}s")
print(f"Binary search: found at index {result}, took {binary_time:.6f}s")
print(f"Binary search was ~{linear_time / binary_time:.0f}x faster")
Why O(log n)?
Each step halves the remaining search space. Starting with n elements:
| Step | Elements remaining |
|---|---|
| 0 | n |
| 1 | n / 2 |
| 2 | n / 4 |
| k | n / 2^k |
We stop when n / 2^k = 1, so k = log₂(n). For n = 1,000,000, that is just 20 steps.
import math
sizes = [100, 1_000, 10_000, 100_000, 1_000_000, 1_000_000_000]
print(f"{'Array size':<20} {'Linear (worst)':<20} {'Binary (worst)'}")
print("-" * 58)
for n in sizes:
binary_steps = math.ceil(math.log2(n))
print(f"{n:<20,} {n:<20,} {binary_steps}")
Real-World Applications
Binary search shows up everywhere sorted data exists:
Dictionary lookup — paper dictionaries and spell-checkers both exploit sorted order to find words in O(log n) time rather than scanning every entry.
git bisect — when a bug was introduced somewhere across hundreds of commits, git bisect performs a binary search through commit history. You tell it which commit is good and which is bad; it checks the midpoint, you mark it good or bad, and it keeps halving until it pinpoints the exact commit that introduced the bug.
Database B-tree indexes — a database index on a column is a sorted tree structure. Looking up a row by a value uses binary-search-like traversal, which is why indexed lookups are O(log n) even across millions of rows.
Autocomplete — as you type, a search engine narrows down candidates from a sorted trie or sorted word list using binary search to find the prefix boundary in O(log n) time.
In This Section
| Chapter | Topic |
|---|---|
| Search Array | Classic binary search on a sorted array — iterative and recursive, with edge cases |
| Search Range | Finding first/last occurrences, and binary search on a numeric range |
Search Array
Binary search on a sorted array is the purest form of the algorithm. The idea is simple: maintain a window [left, right] that contains all possible positions for the target, then shrink it by half at every step.
Prerequisite: the array must be sorted in ascending order.
Step-by-Step Walkthrough
Search for 7 in [1, 3, 5, 7, 9, 11, 13] (indices 0–6):
graph LR
subgraph Step1["Step 1: left=0, right=6, mid=3"]
A1["[1, 3, 5, **7**, 9, 11, 13]"]
B1["nums[3] = 7\nnums[mid]=5 < 7 → move left up"]
end
subgraph Step2["Step 2: left=0, right=6, mid=3 (mid = (0+6)//2 = 3, nums[3]=7)"]
A2["Corrected: left=0, right=6\nmid = 3, nums[3] = 7 — wait, let us trace carefully"]
end
Let’s trace it precisely — array indices 0 through 6:
sequenceDiagram
participant Array as [1, 3, 5, 7, 9, 11, 13]
participant State as left / mid / right
Note over State: left=0, right=6
State->>Array: mid = 0 + (6-0)//2 = 3 → nums[3] = 7
Array-->>State: 7 == target → FOUND at index 3
That found it in a single step because 7 happens to land exactly on the first midpoint. Here is a longer trace searching for 9:
sequenceDiagram
participant Array as [1, 3, 5, 7, 9, 11, 13]
participant State as left / mid / right
Note over State: left=0, right=6
State->>Array: mid=3, nums[3]=7 < 9 → too small
Note over State: left=4, right=6
State->>Array: mid=5, nums[5]=11 > 9 → too big
Note over State: left=4, right=4
State->>Array: mid=4, nums[4]=9 == 9 → FOUND ✓
Three steps to find 9 in a 7-element array.
The Safe Midpoint Formula
You will often see mid = (left + right) // 2. This works in Python because Python integers have unlimited precision — but in languages like C, Java, or Go, left + right can overflow if both values are large (close to the maximum integer value), producing a negative or garbage result.
The safe formula is:
mid = left + (right - left) // 2
This is mathematically identical but never overflows because right - left is always a small non-negative number. Even in Python it is good practice to write it this way — you will carry the habit into other languages.
# Demonstrating the overflow risk in fixed-width arithmetic
import ctypes # simulate 32-bit int overflow
MAX_INT = 2**31 - 1 # 2,147,483,647
left = 2_000_000_000
right = 2_100_000_000
unsafe_mid = ctypes.c_int32(left + right).value # wraps around!
safe_mid = left + (right - left) // 2 # always correct
print(f"Unsafe mid (overflow simulation): {unsafe_mid}")
print(f"Safe mid: {safe_mid}")
print(f"Expected: {(left + right) // 2}")
Iterative Implementation
def binary_search(nums, target):
"""
Return the index of target in the sorted list nums, or -1 if not found.
Time: O(log n)
Space: O(1)
"""
left = 0
right = len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1 # target must be in the right half
else:
right = mid - 1 # target must be in the left half
return -1 # window collapsed — target is not in the array
# --- Tests ---
arr = [1, 3, 5, 7, 9, 11, 13]
print(binary_search(arr, 7)) # 3
print(binary_search(arr, 1)) # 0 (first element)
print(binary_search(arr, 13)) # 6 (last element)
print(binary_search(arr, 6)) # -1 (not in array)
print(binary_search([], 5)) # -1 (empty array)
Why left <= right?
The loop condition left <= right means we keep searching while the window contains at least one element. When left > right the window is empty — the target cannot be there.
Recursive Implementation
The recursive version maps more naturally to the mathematical definition but uses O(log n) call-stack space.
def binary_search_recursive(nums, target, left=None, right=None):
"""
Recursive binary search.
Time: O(log n)
Space: O(log n) — call stack depth
"""
if left is None:
left = 0
if right is None:
right = len(nums) - 1
# Base case: empty window
if left > right:
return -1
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
return binary_search_recursive(nums, target, mid + 1, right)
else:
return binary_search_recursive(nums, target, left, mid - 1)
arr = [1, 3, 5, 7, 9, 11, 13]
print(binary_search_recursive(arr, 7)) # 3
print(binary_search_recursive(arr, 6)) # -1
Edge Cases
def binary_search(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
# 1. Empty array
print("Empty: ", binary_search([], 5)) # -1
# 2. Single element — present
print("Single hit: ", binary_search([7], 7)) # 0
# 3. Single element — absent
print("Single miss: ", binary_search([7], 3)) # -1
# 4. Target smaller than every element
print("Below range: ", binary_search([3, 5, 7], 1)) # -1
# 5. Target larger than every element
print("Above range: ", binary_search([3, 5, 7], 9)) # -1
# 6. Duplicates — returns ONE matching index (not necessarily the first)
print("Duplicate: ", binary_search([2, 2, 2, 2], 2)) # some valid index
# 7. Two elements
print("Two-elem hit: ", binary_search([4, 8], 8)) # 1
print("Two-elem miss:", binary_search([4, 8], 5)) # -1
Note on duplicates: the standard binary search returns an index where the target occurs, not necessarily the first or last. If you need the exact first or last occurrence, see Search Range.
Complexity Summary
| Metric | Value | Why |
|---|---|---|
| Time (best) | O(1) | Target is the first midpoint checked |
| Time (average/worst) | O(log n) | Halving the window each step |
| Space (iterative) | O(1) | Only a handful of integer variables |
| Space (recursive) | O(log n) | One stack frame per halving step |
Real-World Connections
Sorted contacts list — your phone’s contacts app stores names in sorted order. Searching for “Zhang Wei” among 500 contacts takes at most 9 comparisons (log₂ 500 ≈ 9), not 500.
Database index scan — a B-tree index on a users table sorted by email lets the database engine use binary-search-style page lookups to find a row in O(log n) page reads instead of a full table scan.
bisect module in Python — Python’s standard library ships bisect.bisect_left and bisect.bisect_right, which are C-speed binary search implementations you can drop into any sorted list:
import bisect
contacts = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"]
# Find insertion point (equivalent to find-first)
pos = bisect.bisect_left(contacts, "Carol")
print(f"'Carol' found at index: {pos}") # 2
# Check membership
name = "Dave"
pos = bisect.bisect_left(contacts, name)
found = pos < len(contacts) and contacts[pos] == name
print(f"'Dave' in contacts: {found}") # True
name = "Zara"
pos = bisect.bisect_left(contacts, name)
found = pos < len(contacts) and contacts[pos] == name
print(f"'Zara' in contacts: {found}") # False
Search Range
What if the array has duplicates and you need the first or last occurrence?
Standard binary search returns some matching index — but not necessarily the leftmost or rightmost one. For example, in [1, 2, 2, 2, 3, 4], searching for 2 might return index 1, 2, or 3 depending on where the midpoint happens to fall.
Finding a precise boundary — or searching over a numeric range rather than an array — is a powerful generalisation of binary search that unlocks a whole family of problems.
The Boundary Search Idea
When you find a match, instead of stopping immediately, keep searching in one direction to see if there is a closer match:
- First occurrence (left boundary): on a hit, record the index and continue searching left (
right = mid - 1). - Last occurrence (right boundary): on a hit, record the index and continue searching right (
left = mid + 1).
graph TD
subgraph Standard["Standard binary search — stops on first hit"]
S1["left=0, right=5\nmid=2, nums[2]=2 → HIT, return 2"]
end
subgraph Left["find_first — keeps going left after a hit"]
L1["left=0, right=5\nmid=2, nums[2]=2 → record 2, right=1"]
L2["left=0, right=1\nmid=0, nums[0]=1 → too small, left=1"]
L3["left=1, right=1\nmid=1, nums[1]=2 → record 1, right=0"]
L4["left > right → return 1 ✓"]
L1 --> L2 --> L3 --> L4
end
subgraph Right["find_last — keeps going right after a hit"]
R1["left=0, right=5\nmid=2, nums[2]=2 → record 2, left=3"]
R2["left=3, right=5\nmid=4, nums[4]=3 → too big, right=3"]
R3["left=3, right=3\nmid=3, nums[3]=2 → record 3, left=4"]
R4["left > right → return 3 ✓"]
R1 --> R2 --> R3 --> R4
end
Array used: [1, 2, 2, 2, 3, 4], target = 2
Implementing find_first and find_last
def find_first(nums, target):
"""
Return the index of the FIRST occurrence of target, or -1 if not found.
Time: O(log n) Space: O(1)
"""
left, right = 0, len(nums) - 1
answer = -1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
answer = mid # record this hit...
right = mid - 1 # ...but keep searching left
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return answer
def find_last(nums, target):
"""
Return the index of the LAST occurrence of target, or -1 if not found.
Time: O(log n) Space: O(1)
"""
left, right = 0, len(nums) - 1
answer = -1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
answer = mid # record this hit...
left = mid + 1 # ...but keep searching right
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return answer
def search_range(nums, target):
"""Return [first_index, last_index] of target, or [-1, -1] if absent."""
return [find_first(nums, target), find_last(nums, target)]
# Tests
arr = [1, 2, 2, 2, 3, 4]
print(search_range(arr, 2)) # [1, 3]
print(search_range(arr, 1)) # [0, 0]
print(search_range(arr, 4)) # [5, 5]
print(search_range(arr, 5)) # [-1, -1]
# All duplicates
print(search_range([7, 7, 7, 7], 7)) # [0, 3]
# Target absent from middle
print(search_range([1, 1, 3, 3], 2)) # [-1, -1]
Binary Search on a Numeric Range
Binary search does not have to operate on an array at all. You can apply it to any monotonic condition over a numeric range.
Classic example: finding the integer square root of n — the largest integer k such that k² ≤ n.
Instead of a sorted array, the “array” is the range [0, n], and the condition k² ≤ n is True for small k and False for large k — a perfect sorted boundary to binary-search on.
graph LR
subgraph "find_sqrt(26)"
A["left=0, right=26\nmid=13, 13²=169 > 26 → right=12"]
B["left=0, right=12\nmid=6, 6²=36 > 26 → right=5"]
C["left=0, right=5\nmid=2, 2²=4 ≤ 26 → answer=2, left=3"]
D["left=3, right=5\nmid=4, 4²=16 ≤ 26 → answer=4, left=5"]
E["left=5, right=5\nmid=5, 5²=25 ≤ 26 → answer=5, left=6"]
F["left > right → return 5 ✓"]
A --> B --> C --> D --> E --> F
end
def find_sqrt(n):
"""
Return the integer square root of n (floor(sqrt(n))) using binary search.
Time: O(log n) Space: O(1)
"""
if n < 2:
return n
left, right = 1, n // 2 # sqrt(n) <= n//2 for n >= 4
answer = 1
while left <= right:
mid = left + (right - left) // 2
if mid * mid <= n:
answer = mid # mid is a valid candidate; try larger
left = mid + 1
else:
right = mid - 1 # mid² too big; try smaller
return answer
# Verify against math.isqrt
import math
test_cases = [0, 1, 4, 8, 9, 26, 100, 1000, 999_999]
print(f"{'n':<12} {'find_sqrt':<14} {'math.isqrt':<14} {'match'}")
print("-" * 46)
for n in test_cases:
result = find_sqrt(n)
expected = math.isqrt(n)
print(f"{n:<12} {result:<14} {expected:<14} {'✓' if result == expected else '✗'}")
The General Pattern
These three problems share the same skeleton — binary search on a condition:
def binary_search_condition(lo, hi, condition):
"""
Find the LAST position where condition is True in the range [lo, hi].
Assumes condition is True for some prefix [lo..k] and False for [k+1..hi]
— i.e., the condition is monotonically non-increasing.
Returns the boundary index k, or lo-1 if condition is False everywhere.
"""
answer = lo - 1 # sentinel: condition never True
while lo <= hi:
mid = lo + (hi - lo) // 2
if condition(mid):
answer = mid
lo = mid + 1 # record hit, search right for a later hit
else:
hi = mid - 1 # search left
return answer
# Example 1: find last index where nums[i] <= target (like upper_bound)
nums = [1, 3, 3, 3, 5, 7, 9]
target = 3
last_le = binary_search_condition(0, len(nums) - 1, lambda i: nums[i] <= target)
print(f"Last index where nums[i] <= {target}: {last_le}") # 3
# Example 2: integer square root via the same template
n = 50
sqrt_n = binary_search_condition(1, n, lambda k: k * k <= n)
print(f"Integer sqrt of {n}: {sqrt_n}") # 7 (7² = 49 ≤ 50, 8² = 64 > 50)
Complexity Summary
| Problem | Time | Space |
|---|---|---|
| find_first | O(log n) | O(1) |
| find_last | O(log n) | O(1) |
| search_range | O(log n) | O(1) |
| find_sqrt | O(log n) | O(1) |
Each problem performs at most two passes over log n positions, or a single pass over a numeric range of size n — so the complexity stays logarithmic.
Real-World Connections
Finding date ranges in logs — given millions of log lines sorted by timestamp, finding all lines between two timestamps is exactly a find_first / find_last pair on the timestamp column.
Python bisect module — bisect.bisect_left(a, x) returns the first index where x could be inserted to keep a sorted (equivalent to find_first), and bisect.bisect_right(a, x) returns the last such index. These are used internally by Python’s SortedList containers.
import bisect
events = [
"2024-03-01 login",
"2024-03-02 purchase",
"2024-03-02 refund",
"2024-03-02 logout",
"2024-03-05 login",
]
# All events on 2024-03-02
lo = bisect.bisect_left(events, "2024-03-02")
hi = bisect.bisect_right(events, "2024-03-02\xff") # \xff sorts after all printable chars
print("Events on 2024-03-02:")
for e in events[lo:hi]:
print(" ", e)
C++ STL lower_bound / upper_bound — C++’s <algorithm> header provides lower_bound (first position ≥ target) and upper_bound (first position > target) on any sorted range. They are the direct equivalents of find_first and the index after find_last, and they are used extensively in competitive programming and systems code.
Binary Search Problems
Practice problems for binary search on arrays, answers, and monotonic conditions.
Binary Search
Difficulty: Easy Source: NeetCode
Problem
Given an array of integers
numswhich is sorted in ascending order, and an integertarget, write a function to searchtargetinnums. Iftargetexists, return its index. Otherwise, return-1.You must write an algorithm with
O(log n)runtime complexity.Example 1: Input:
nums = [-1, 0, 3, 5, 9, 12],target = 9Output:4Example 2: Input:
nums = [-1, 0, 3, 5, 9, 12],target = 2Output:-1Constraints:
1 <= nums.length <= 10^4-10^4 < nums[i], target < 10^4- All integers in
numsare uniquenumsis sorted in ascending order
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sorted Arrays — understanding that sorted order lets you eliminate half the search space at each step
- Index Arithmetic — computing midpoints and adjusting bounds without going out of range
1. Brute Force
Intuition
Scan every element from left to right and return the index the moment you find the target. Simple and correct, but completely ignores the sorted property — you’re doing O(n) work when O(log n) is achievable.
Algorithm
- For each index
ifrom0tolen(nums) - 1:- If
nums[i] == target, returni.
- If
- Return
-1if the loop finishes without a match.
Solution
def search_linear(nums, target):
for i in range(len(nums)):
if nums[i] == target:
return i
return -1
print(search_linear([-1, 0, 3, 5, 9, 12], 9)) # 4
print(search_linear([-1, 0, 3, 5, 9, 12], 2)) # -1
print(search_linear([5], 5)) # 0
Complexity
- Time:
O(n) - Space:
O(1)
2. Binary Search
Intuition
Because the array is sorted, you can compare the middle element with the target and immediately discard half the array. If nums[mid] == target you’re done. If nums[mid] < target the answer must be to the right, so move lo up. If nums[mid] > target it must be to the left, so move hi down. Each iteration halves the search space, giving O(log n).
Algorithm
- Set
lo = 0andhi = len(nums) - 1. - While
lo <= hi:- Compute
mid = lo + (hi - lo) // 2. - If
nums[mid] == target, returnmid. - If
nums[mid] < target, setlo = mid + 1. - Else set
hi = mid - 1.
- Compute
- Return
-1.
flowchart LR
S(["nums=[-1,0,3,5,9,12] target=9\nlo=0 hi=5"])
S --> m0["mid=2 nums[2]=3 < 9 → lo=3"]
m0 --> m1["mid=4 nums[4]=9 = 9 → return 4"]
Solution
def search(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
print(search([-1, 0, 3, 5, 9, 12], 9)) # 4
print(search([-1, 0, 3, 5, 9, 12], 2)) # -1
print(search([5], 5)) # 0
Complexity
- Time:
O(log n) - Space:
O(1)
Common Pitfalls
Using mid = (lo + hi) // 2 in languages with fixed-width integers. In Python integers don’t overflow, but in C/Java this can wrap around for large values. Prefer lo + (hi - lo) // 2 as a habit — it’s safe everywhere.
Loop condition lo < hi vs lo <= hi. With lo < hi you exit one iteration early and may never check the element at the final lo position. Use lo <= hi so every candidate gets examined.
Moving lo = mid or hi = mid instead of mid ± 1. This can cause an infinite loop when lo and hi are adjacent — mid would equal lo and the bounds would never change.
Search Insert Position
Difficulty: Easy Source: NeetCode
Problem
Given a sorted array of distinct integers
numsand a target value, return the index if the target is found. If not, return the index where it would be inserted in order.You must write an algorithm with
O(log n)runtime complexity.Example 1: Input:
nums = [1, 3, 5, 6],target = 5Output:2Example 2: Input:
nums = [1, 3, 5, 6],target = 2Output:1Example 3: Input:
nums = [1, 3, 5, 6],target = 7Output:4Constraints:
1 <= nums.length <= 10^4-10^4 <= nums[i] <= 10^4numscontains distinct values sorted in ascending order-10^4 <= target <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search — halving the search space using sorted order
- Loop Invariants — understanding what
lorepresents when the loop exits
1. Brute Force
Intuition
Walk the array from left to right. The first index where nums[i] >= target is either the exact match or the correct insertion point. If no such index exists, the target belongs at the end.
Algorithm
- For each index
ifrom0tolen(nums) - 1:- If
nums[i] >= target, returni.
- If
- Return
len(nums)(insert at the end).
Solution
def searchInsert_linear(nums, target):
for i in range(len(nums)):
if nums[i] >= target:
return i
return len(nums)
print(searchInsert_linear([1, 3, 5, 6], 5)) # 2
print(searchInsert_linear([1, 3, 5, 6], 2)) # 1
print(searchInsert_linear([1, 3, 5, 6], 7)) # 4
print(searchInsert_linear([1, 3, 5, 6], 0)) # 0
Complexity
- Time:
O(n) - Space:
O(1)
2. Binary Search
Intuition
Run a standard binary search. The key insight is what happens when the target is not found: the loop terminates with lo > hi, and at that point lo is exactly where the target should be inserted. This is because lo always points to the leftmost index that is still a valid candidate, and when the search space collapses, lo has landed on the first position where nums[lo] > target.
Algorithm
- Set
lo = 0,hi = len(nums) - 1. - While
lo <= hi:- Compute
mid = lo + (hi - lo) // 2. - If
nums[mid] == target, returnmid. - If
nums[mid] < target, setlo = mid + 1. - Else set
hi = mid - 1.
- Compute
- Return
lo(the insertion position).
flowchart LR
S(["nums=[1,3,5,6] target=2\nlo=0 hi=3"])
S --> m0["mid=1 nums[1]=3 > 2 → hi=0"]
m0 --> m1["mid=0 nums[0]=1 < 2 → lo=1"]
m1 --> done["lo=1 > hi=0 → return lo=1"]
Solution
def searchInsert(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return lo
print(searchInsert([1, 3, 5, 6], 5)) # 2
print(searchInsert([1, 3, 5, 6], 2)) # 1
print(searchInsert([1, 3, 5, 6], 7)) # 4
print(searchInsert([1, 3, 5, 6], 0)) # 0
Complexity
- Time:
O(log n) - Space:
O(1)
Common Pitfalls
Returning -1 when the target is not found. Unlike a plain search problem, here a miss is not a failure — lo gives you the answer. Never return -1.
Forgetting the insert-at-end case in the brute-force. If the loop finishes without finding nums[i] >= target, the target is larger than every element. Return len(nums), not len(nums) - 1.
Confusing the insertion index with the insertion value. You return where to insert, not what to insert. The return value is an index into nums.
Guess Number Higher or Lower
Difficulty: Easy Source: NeetCode
Problem
We are playing a guessing game. I pick a number from
1ton. You have to guess which number I picked.Every time you guess wrong, I will tell you whether the number I picked is higher or lower than your guess.
You call a pre-defined API
guess(num)which returns three possible results:
-1: Your guess is higher than the number I picked (i.e.num > pick)1: Your guess is lower than the number I picked (i.e.num < pick)0: Your guess is equal to the number I picked (i.e.num == pick)Return the number that I picked.
Example 1: Input:
n = 10,pick = 6Output:6Example 2: Input:
n = 1,pick = 1Output:1Example 3: Input:
n = 2,pick = 1Output:1Constraints:
1 <= n <= 2^31 - 11 <= pick <= n
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search — using feedback to eliminate half the search space each iteration
- API / Black-box Functions — treating
guess()as an oracle rather than inspecting its internals
2. Binary Search
Intuition
The numbers 1 to n form a sorted sequence. Each call to guess(mid) tells you exactly which half contains the answer, so binary search is a natural fit. There’s no brute-force section here because the problem requires logarithmic behaviour — a linear scan would time-out for large n.
Algorithm
- Set
lo = 1,hi = n. - While
lo <= hi:- Compute
mid = lo + (hi - lo) // 2. - Call
result = guess(mid). - If
result == 0, returnmid— that’s the picked number. - If
result == 1, the pick is higher, so setlo = mid + 1. - If
result == -1, the pick is lower, so sethi = mid - 1.
- Compute
- The loop always terminates because
pickis guaranteed to be in[1, n].
flowchart LR
S(["n=10 pick=6\nlo=1 hi=10"])
S --> m0["mid=5 guess(5)=1 (too low) → lo=6"]
m0 --> m1["mid=8 guess(8)=-1 (too high) → hi=7"]
m1 --> m2["mid=6 guess(6)=0 → return 6"]
Solution
def guessNumber(n, pick):
# Simulated API — in the real problem this is provided by the judge
def guess(num):
if num > pick:
return -1
elif num < pick:
return 1
else:
return 0
lo, hi = 1, n
while lo <= hi:
mid = lo + (hi - lo) // 2
result = guess(mid)
if result == 0:
return mid
elif result == 1:
lo = mid + 1
else:
hi = mid - 1
print(guessNumber(10, 6)) # 6
print(guessNumber(1, 1)) # 1
print(guessNumber(2, 1)) # 1
Complexity
- Time:
O(log n) - Space:
O(1)
Common Pitfalls
Mixing up the return values of guess(). -1 means your guess was too high (bring hi down), and 1 means your guess was too low (bring lo up). It’s the opposite of what many people expect — the sign reflects the relationship of pick to num, not num to pick.
Integer overflow on mid = (lo + hi) // 2. For n up to 2^31 - 1, lo + hi can overflow in languages with 32-bit integers. Python handles big integers natively, but use lo + (hi - lo) // 2 as a portable habit.
Returning outside the loop. Because pick is always in range, the loop is guaranteed to find the answer. You don’t need a fallback return, but adding one (e.g. -1) is harmless and prevents linter warnings.
Sqrt(x)
Difficulty: Easy Source: NeetCode
Problem
Given a non-negative integer
x, return the square root ofxrounded down to the nearest integer. The returned integer should be non-negative as well.You must not use any built-in exponent function or operator, such as
pow(x, 0.5)orx ** 0.5.Example 1: Input:
x = 4Output:2Example 2: Input:
x = 8Output:2Explanation: The square root of 8 is 2.828…, and since we round down, 2 is returned.Constraints:
0 <= x <= 2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search on the Answer — searching a range of candidate values rather than array indices
- Integer Arithmetic — comparing squares without using floating-point
1. Brute Force
Intuition
Start from 1 and walk upward, squaring each candidate. The moment m * m exceeds x, the previous candidate was the floor of the square root. This works, but the loop runs O(√x) times — slow for large inputs.
Algorithm
- Handle
x == 0separately, returning0. - For
mfrom1upward:- If
m * m > x, returnm - 1.
- If
Solution
def mySqrt_linear(x):
if x == 0:
return 0
m = 1
while m * m <= x:
m += 1
return m - 1
print(mySqrt_linear(4)) # 2
print(mySqrt_linear(8)) # 2
print(mySqrt_linear(0)) # 0
Complexity
- Time:
O(√x) - Space:
O(1)
2. Binary Search
Intuition
The function f(m) = m * m is monotonically increasing, so we can binary-search the range [0, x] for the largest m where m * m <= x. Every time mid * mid <= x we record mid as a candidate answer and try going higher; otherwise we go lower. When the search ends, the last recorded candidate is the floor square root.
Algorithm
- Set
lo = 0,hi = x,ans = 0. - While
lo <= hi:- Compute
mid = lo + (hi - lo) // 2. - If
mid * mid <= x: setans = mid, thenlo = mid + 1(try larger). - Else: set
hi = mid - 1(too big, go smaller).
- Compute
- Return
ans.
flowchart LR
S(["x=8 lo=0 hi=8 ans=0"])
S --> m0["mid=4 4*4=16 > 8 → hi=3"]
m0 --> m1["mid=1 1*1=1 ≤ 8 → ans=1 lo=2"]
m1 --> m2["mid=2 2*2=4 ≤ 8 → ans=2 lo=3"]
m2 --> m3["mid=3 3*3=9 > 8 → hi=2"]
m3 --> done["lo=3 > hi=2 → return ans=2"]
Solution
def mySqrt(x):
lo, hi, ans = 0, x, 0
while lo <= hi:
mid = lo + (hi - lo) // 2
if mid * mid <= x:
ans = mid
lo = mid + 1
else:
hi = mid - 1
return ans
print(mySqrt(4)) # 2
print(mySqrt(8)) # 2
print(mySqrt(0)) # 0
Complexity
- Time:
O(log x) - Space:
O(1)
Common Pitfalls
Setting hi = x causes many unnecessary iterations for large x. A tighter upper bound is x // 2 + 1 (for x > 1), since √x <= x/2 for all x >= 4. Still O(log x) but with a smaller constant.
Using floating-point arithmetic. mid ** 0.5 introduces rounding errors. Stick to integer multiplication mid * mid to keep everything exact.
Not handling x == 0 or x == 1. Both are valid inputs. With hi = x and the algorithm above they are handled correctly (the loop runs once), but always verify edge cases.
Search a 2D Matrix
Difficulty: Medium Source: NeetCode
Problem
You are given an
m x ninteger matrixmatrixwith the following two properties:
- Each row is sorted in non-decreasing order.
- The first integer of each row is greater than the last integer of the previous row.
Given an integer
target, returntrueiftargetis inmatrixorfalseotherwise.You must write a solution in
O(log(m * n))time complexity.Example 1: Input:
matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]],target = 3Output:trueExample 2: Input:
matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]],target = 13Output:falseConstraints:
m == matrix.length,n == matrix[i].length1 <= m, n <= 100-10^4 <= matrix[i][j], target <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search — halving the search space each iteration
- 2D Index Mapping — converting a flat index
iinto(row, col)using division and modulo
1. Brute Force
Intuition
Scan every cell row by row and column by column. Straightforward but completely ignores the sorted structure.
Algorithm
- For each row
rinmatrix:- For each element
valin rowr:- If
val == target, returnTrue.
- If
- For each element
- Return
False.
Solution
def searchMatrix_brute(matrix, target):
for row in matrix:
for val in row:
if val == target:
return True
return False
m = [[1, 3, 5, 7], [10, 11, 16, 20], [23, 30, 34, 60]]
print(searchMatrix_brute(m, 3)) # True
print(searchMatrix_brute(m, 13)) # False
Complexity
- Time:
O(m * n) - Space:
O(1)
2. Binary Search (Row-First)
Intuition
Because each row is sorted and every row’s first element exceeds the previous row’s last, you can first binary-search for the correct row (the one whose range contains the target), then binary-search inside that row.
Algorithm
- Binary search rows: find the last row where
row[0] <= target. - Binary search that row for
target. - Return whether the target was found.
Solution
def searchMatrix_rowFirst(matrix, target):
m, n = len(matrix), len(matrix[0])
# Find the candidate row
top, bot = 0, m - 1
row = -1
while top <= bot:
mid = (top + bot) // 2
if matrix[mid][0] <= target <= matrix[mid][n - 1]:
row = mid
break
elif matrix[mid][0] > target:
bot = mid - 1
else:
top = mid + 1
if row == -1:
return False
# Binary search inside that row
lo, hi = 0, n - 1
while lo <= hi:
mid = (lo + hi) // 2
if matrix[row][mid] == target:
return True
elif matrix[row][mid] < target:
lo = mid + 1
else:
hi = mid - 1
return False
m = [[1, 3, 5, 7], [10, 11, 16, 20], [23, 30, 34, 60]]
print(searchMatrix_rowFirst(m, 3)) # True
print(searchMatrix_rowFirst(m, 13)) # False
Complexity
- Time:
O(log m + log n) - Space:
O(1)
3. Binary Search (Flat Index) — Optimal
Intuition
Since the entire matrix is sorted when read row by row, you can treat it as a single flat sorted array of length m * n and run one binary search. Map any flat index i back to 2D with row = i // n, col = i % n. This is the cleanest single-pass solution.
Algorithm
- Set
lo = 0,hi = m * n - 1. - While
lo <= hi:- Compute
mid = lo + (hi - lo) // 2. - Map to 2D:
r = mid // n,c = mid % n. - Compare
matrix[r][c]withtargetand update bounds.
- Compute
- Return
Trueon a match,Falseif the loop ends.
flowchart LR
S(["Treat 3x4 matrix as flat array of length 12\nlo=0 hi=11"])
S --> m0["mid=5 matrix[1][1]=11 < 3? No\nmatrix[1][1]=11 > 3 → hi=4"]
m0 --> m1["mid=2 matrix[0][2]=5 > 3 → hi=1"]
m1 --> m2["mid=0 matrix[0][0]=1 < 3 → lo=1"]
m2 --> m3["mid=1 matrix[0][1]=3 == 3 → True"]
Solution
def searchMatrix(matrix, target):
m, n = len(matrix), len(matrix[0])
lo, hi = 0, m * n - 1
while lo <= hi:
mid = lo + (hi - lo) // 2
val = matrix[mid // n][mid % n]
if val == target:
return True
elif val < target:
lo = mid + 1
else:
hi = mid - 1
return False
m = [[1, 3, 5, 7], [10, 11, 16, 20], [23, 30, 34, 60]]
print(searchMatrix(m, 3)) # True
print(searchMatrix(m, 13)) # False
print(searchMatrix([[1]], 1)) # True
Complexity
- Time:
O(log(m * n)) - Space:
O(1)
Common Pitfalls
Confusing this with Search a 2D Matrix II (LeetCode 240). That problem does not guarantee that row i+1 starts after row i ends — the two problems require different algorithms.
Off-by-one in hi. The flat array has indices 0 to m * n - 1. Setting hi = m * n instead of m * n - 1 causes an index-out-of-bounds on the very first mid calculation in the worst case.
Wrong column count in the index mapping. The formula is row = mid // n and col = mid % n where n is the number of columns, not rows. Using m by mistake gives wrong results.
Koko Eating Bananas
Difficulty: Medium Source: NeetCode
Problem
Koko loves to eat bananas. There are
npiles of bananas, thei-th pile haspiles[i]bananas. The guards have gone and will come back inhhours.Koko can decide her bananas-per-hour eating speed of
k. Each hour, she chooses some pile of bananas and eatskbananas from that pile. If the pile has less thankbananas, she eats all of them instead and will not eat any more bananas during this hour.Koko likes to eat slowly but still wants to finish eating all the bananas before the guards return.
Return the minimum integer
ksuch that she can eat all the bananas withinhhours.Example 1: Input:
piles = [3, 6, 7, 11],h = 8Output:4Example 2: Input:
piles = [30, 11, 23, 4, 20],h = 5Output:30Constraints:
1 <= piles.length <= 10^4piles.length <= h <= 10^91 <= piles[i] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search on the Answer — the search space is a range of possible values, not array indices
- Ceiling Division — computing
ceil(pile / k)using integer arithmetic:(pile + k - 1) // kormath.ceil(pile / k)
1. Brute Force
Intuition
Try every possible speed from 1 upward. For each speed k, compute the total hours needed by summing ceil(pile / k) for every pile. The first k where total hours <= h is the answer. This is correct but extremely slow for large pile values.
Algorithm
- For
kfrom1tomax(piles):- Compute
hours = sum(ceil(pile / k) for pile in piles). - If
hours <= h, returnk.
- Compute
Solution
import math
def minEatingSpeed_brute(piles, h):
for k in range(1, max(piles) + 1):
hours = sum(math.ceil(pile / k) for pile in piles)
if hours <= h:
return k
print(minEatingSpeed_brute([3, 6, 7, 11], 8)) # 4
print(minEatingSpeed_brute([30, 11, 23, 4, 20], 5)) # 30
Complexity
- Time:
O(max(piles) * n) - Space:
O(1)
2. Binary Search on Speed
Intuition
The feasibility function canFinish(k) is monotone: if speed k works, then speed k+1 also works. This is the classic signal to binary-search on the answer. The search space is [1, max(piles)] — at speed max(piles) Koko finishes every pile in one hour, so she can always finish in n <= h hours. At speed 1 she needs sum(piles) hours which might be too slow. Binary search finds the minimum feasible speed in O(log(max(piles))) feasibility checks, each costing O(n).
Algorithm
- Set
lo = 1,hi = max(piles),ans = hi. - While
lo <= hi:- Compute
mid = lo + (hi - lo) // 2. - Compute
hours = sum(ceil(pile / mid) for pile in piles). - If
hours <= h: recordans = mid, then search left (hi = mid - 1) for a smaller valid speed. - Else: search right (
lo = mid + 1) — too slow.
- Compute
- Return
ans.
flowchart LR
S(["piles=[3,6,7,11] h=8\nlo=1 hi=11"])
S --> m0["mid=6 hours=1+1+2+2=6 ≤ 8 → ans=6 hi=5"]
m0 --> m1["mid=3 hours=1+2+3+4=10 > 8 → lo=4"]
m1 --> m2["mid=4 hours=1+2+2+3=8 ≤ 8 → ans=4 hi=3"]
m2 --> done["lo=4 > hi=3 → return 4"]
Solution
import math
def minEatingSpeed(piles, h):
lo, hi = 1, max(piles)
ans = hi
while lo <= hi:
mid = lo + (hi - lo) // 2
hours = sum(math.ceil(pile / mid) for pile in piles)
if hours <= h:
ans = mid
hi = mid - 1
else:
lo = mid + 1
return ans
print(minEatingSpeed([3, 6, 7, 11], 8)) # 4
print(minEatingSpeed([30, 11, 23, 4, 20], 5)) # 30
print(minEatingSpeed([1, 1, 1, 1], 4)) # 1
Complexity
- Time:
O(n log(max(piles))) - Space:
O(1)
Common Pitfalls
Using hours = sum(pile // k for pile in piles). Floor division undercounts — Koko must stay for the full hour even if she finishes a pile partway through. Always use ceiling division: math.ceil(pile / k) or (pile + k - 1) // k.
Setting hi = sum(piles) instead of max(piles). Koko eats at most one pile per hour, so a speed higher than the largest pile never helps. max(piles) is the tight upper bound and keeps log small.
Forgetting to update ans before narrowing left. When hours <= h, you’ve found a valid speed but maybe not the minimum. Record it and keep looking left with hi = mid - 1.
Capacity to Ship Packages Within D Days
Difficulty: Medium Source: NeetCode
Problem
A conveyor belt has packages that must be shipped from one port to another within
daysdays.The
i-th package on the conveyor belt has a weight ofweights[i]. Each day, we load the ship with packages in the order given by the conveyor belt. We may not load more weight than the maximum weight capacity of the ship.Return the least weight capacity of the ship that will result in all the packages on the conveyor belt being shipped within
daysdays.Example 1: Input:
weights = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],days = 5Output:15Example 2: Input:
weights = [3, 2, 2, 4, 1, 4],days = 3Output:6Constraints:
1 <= days <= weights.length <= 5001 <= weights[i] <= 500
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search on the Answer — the answer (capacity) lies in a numeric range and the feasibility check is monotone
- Greedy Simulation — greedily packing as many packages as possible each day to check feasibility
1. Brute Force
Intuition
Try every capacity from max(weights) (the minimum possible — we must at least fit the heaviest single package) up to sum(weights) (ship everything in one day). For each candidate capacity, simulate the loading process and count how many days are needed. The first capacity where days needed <= days is the answer.
Algorithm
- For
capfrommax(weights)tosum(weights):- Simulate loading: greedily fill each day’s load without exceeding
cap. - Count the number of days used.
- If days used
<= days, returncap.
- Simulate loading: greedily fill each day’s load without exceeding
Solution
def shipWithinDays_brute(weights, days):
def canShip(cap):
day_count, current = 1, 0
for w in weights:
if current + w > cap:
day_count += 1
current = 0
current += w
return day_count <= days
for cap in range(max(weights), sum(weights) + 1):
if canShip(cap):
return cap
print(shipWithinDays_brute([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5)) # 15
print(shipWithinDays_brute([3, 2, 2, 4, 1, 4], 3)) # 6
Complexity
- Time:
O((sum - max) * n)— can be very slow - Space:
O(1)
2. Binary Search on Capacity
Intuition
The feasibility function canShip(cap) is monotone: if we can ship in days days with capacity cap, we can also ship with any higher capacity. That monotonicity is the binary search signal. The search space is [max(weights), sum(weights)]. Binary-search for the smallest feasible capacity by recording valid candidates and always trying smaller ones.
Algorithm
- Define
canShip(cap): greedily fill each day; start a new day when the next package would exceedcap. Return whether the day count<= days. - Set
lo = max(weights),hi = sum(weights),ans = hi. - While
lo <= hi:mid = lo + (hi - lo) // 2.- If
canShip(mid):ans = mid,hi = mid - 1. - Else:
lo = mid + 1.
- Return
ans.
flowchart LR
S(["weights=[1..10] days=5\nlo=10 hi=55"])
S --> m0["mid=32 canShip=True → ans=32 hi=31"]
m0 --> m1["mid=20 canShip=True → ans=20 hi=19"]
m1 --> m2["mid=14 canShip=False → lo=15"]
m2 --> m3["mid=17 canShip=True → ans=17 hi=16"]
m3 --> m4["mid=15 canShip=True → ans=15 hi=14"]
m4 --> done["lo=15 > hi=14 → return 15"]
Solution
def shipWithinDays(weights, days):
def canShip(cap):
day_count, current = 1, 0
for w in weights:
if current + w > cap:
day_count += 1
current = 0
current += w
return day_count <= days
lo, hi = max(weights), sum(weights)
ans = hi
while lo <= hi:
mid = lo + (hi - lo) // 2
if canShip(mid):
ans = mid
hi = mid - 1
else:
lo = mid + 1
return ans
print(shipWithinDays([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5)) # 15
print(shipWithinDays([3, 2, 2, 4, 1, 4], 3)) # 6
print(shipWithinDays([1, 2, 3, 1, 1], 4)) # 3
Complexity
- Time:
O(n log(sum(weights))) - Space:
O(1)
Common Pitfalls
Setting lo = 1 instead of max(weights). A capacity smaller than the heaviest package can never work — that package simply cannot be loaded. Starting from max(weights) avoids checking infeasible capacities and makes the lower bound tight.
Not starting a new day correctly in the simulation. When a package causes an overflow, start a new day with that package as the first item (current = w after resetting, not current = 0). A subtle bug: resetting to 0 and then adding w in the next loop iteration is equivalent — but only if you do add it. Double-check your loop flow.
Confusing this with the Koko problem. The structure is identical (binary search on the answer, monotone feasibility check), but the feasibility check here is a greedy packing simulation rather than a sum of ceilings.
Find Minimum in Rotated Sorted Array
Difficulty: Medium Source: NeetCode
Problem
Suppose an array of length
nsorted in ascending order is rotated between1andntimes. For example, the array[0, 1, 2, 4, 5, 6, 7]might become[4, 5, 6, 7, 0, 1, 2]if rotated 4 times.Given the sorted rotated array
numsof unique elements, return the minimum element of this array.You must write an algorithm that runs in
O(log n)time.Example 1: Input:
nums = [3, 4, 5, 1, 2]Output:1Example 2: Input:
nums = [4, 5, 6, 7, 0, 1, 2]Output:0Example 3: Input:
nums = [11, 13, 15, 17]Output:11Constraints:
n == nums.length1 <= n <= 5000-5000 <= nums[i] <= 5000- All integers in
numsare uniquenumsis sorted and rotated between1andntimes
Prerequisites
Before attempting this problem, you should be comfortable with:
- Rotated Arrays — understanding how a rotation creates two sorted subarrays joined at a pivot
- Binary Search on a Modified Condition — using a property other than direct value comparison to decide which half to search
1. Brute Force
Intuition
Just scan every element and track the minimum. The rotation structure is irrelevant — a linear scan finds the answer trivially. The challenge is doing better.
Algorithm
- Return
min(nums).
Solution
def findMin_linear(nums):
return min(nums)
print(findMin_linear([3, 4, 5, 1, 2])) # 1
print(findMin_linear([4, 5, 6, 7, 0, 1, 2])) # 0
print(findMin_linear([11, 13, 15, 17])) # 11
Complexity
- Time:
O(n) - Space:
O(1)
2. Binary Search
Intuition
A rotated sorted array consists of two sorted halves. The minimum element sits at the start of the second (right) half — it’s the only element smaller than its predecessor. The key observation: if nums[mid] > nums[hi], the minimum must be in the right half (because the right half starts lower than mid). Otherwise, the minimum is in the left half including mid. Track the running minimum as you go.
Algorithm
- Set
lo = 0,hi = len(nums) - 1,ans = nums[0]. - While
lo <= hi:mid = lo + (hi - lo) // 2.- Update
ans = min(ans, nums[mid]). - If
nums[mid] > nums[hi]: minimum is in the right half →lo = mid + 1. - Else: minimum is in the left half (including
mid, already recorded) →hi = mid - 1.
- Return
ans.
flowchart LR
S(["nums=[4,5,6,7,0,1,2]\nlo=0 hi=6 ans=4"])
S --> m0["mid=3 nums[3]=7 7>nums[6]=2 → min is right lo=4"]
m0 --> m1["mid=5 nums[5]=1 1≤nums[6]=2 → ans=1 hi=4"]
m1 --> m2["mid=4 nums[4]=0 0≤nums[4]=0 → ans=0 hi=3"]
m2 --> done["lo=4 > hi=3 → return 0"]
Solution
def findMin(nums):
lo, hi = 0, len(nums) - 1
ans = nums[0]
while lo <= hi:
mid = lo + (hi - lo) // 2
ans = min(ans, nums[mid])
if nums[mid] > nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return ans
print(findMin([3, 4, 5, 1, 2])) # 1
print(findMin([4, 5, 6, 7, 0, 1, 2])) # 0
print(findMin([11, 13, 15, 17])) # 11
Complexity
- Time:
O(log n) - Space:
O(1)
Common Pitfalls
Comparing nums[mid] with nums[lo] instead of nums[hi]. Using lo as the reference is trickier because the array could be fully sorted (no rotation), making nums[lo] < nums[mid] ambiguous. Comparing with nums[hi] cleanly identifies which half contains the pivot.
Not handling the no-rotation case. If the array is fully sorted, nums[mid] <= nums[hi] always, so hi keeps shrinking and you eventually reach nums[0] — the minimum. The algorithm handles it naturally.
Stopping too early. Don’t return nums[mid] the moment nums[lo] <= nums[hi] (the current window is sorted). Always continue to narrow the window — the minimum might still be anywhere inside.
Search in Rotated Sorted Array
Difficulty: Medium Source: NeetCode
Problem
There is an integer array
numssorted in ascending order (with distinct values).Prior to being passed to your function,
numsis possibly rotated at an unknown pivot indexksuch that the resulting array is[nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]].Given the array
numsafter the possible rotation and an integertarget, return the index oftargetif it is innums, or-1if it is not innums.You must write an algorithm with
O(log n)runtime complexity.Example 1: Input:
nums = [4, 5, 6, 7, 0, 1, 2],target = 0Output:4Example 2: Input:
nums = [4, 5, 6, 7, 0, 1, 2],target = 3Output:-1Constraints:
1 <= nums.length <= 5000-10^4 <= nums[i] <= 10^4- All values of
numsare uniquenumsis an ascending array that is possibly rotated-10^4 <= target <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search — standard half-elimination approach
- Rotated Arrays — recognizing which half of the array is sorted at any given
mid
1. Brute Force
Intuition
Ignore the rotation entirely and scan every element linearly.
Algorithm
- For each index
i, ifnums[i] == target, returni. - Return
-1.
Solution
def search_linear(nums, target):
for i, v in enumerate(nums):
if v == target:
return i
return -1
print(search_linear([4, 5, 6, 7, 0, 1, 2], 0)) # 4
print(search_linear([4, 5, 6, 7, 0, 1, 2], 3)) # -1
Complexity
- Time:
O(n) - Space:
O(1)
2. Binary Search
Intuition
At any split point mid, at least one of the two halves — [lo, mid] or [mid, hi] — must be fully sorted. You can tell which by comparing nums[lo] with nums[mid]. Once you know which half is sorted, check whether the target falls inside that half’s value range. If it does, search that half; otherwise search the other. This gives the usual O(log n) binary search despite the rotation.
Algorithm
- Set
lo = 0,hi = len(nums) - 1. - While
lo <= hi:mid = lo + (hi - lo) // 2.- If
nums[mid] == target, returnmid. - Left half is sorted (
nums[lo] <= nums[mid]):- If
nums[lo] <= target < nums[mid]: search left →hi = mid - 1. - Else: search right →
lo = mid + 1.
- If
- Right half is sorted (otherwise):
- If
nums[mid] < target <= nums[hi]: search right →lo = mid + 1. - Else: search left →
hi = mid - 1.
- If
- Return
-1.
flowchart LR
S(["nums=[4,5,6,7,0,1,2] target=0\nlo=0 hi=6"])
S --> m0["mid=3 nums[3]=7 ≠ 0\nnums[0]=4 ≤ nums[3]=7 → left sorted\ntarget=0 not in [4,7) → lo=4"]
m0 --> m1["mid=5 nums[5]=1 ≠ 0\nnums[4]=0 ≤ nums[5]=1 → left sorted\ntarget=0 in [0,1) → hi=4"]
m1 --> m2["mid=4 nums[4]=0 == 0 → return 4"]
Solution
def search(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2
if nums[mid] == target:
return mid
# Left half is sorted
if nums[lo] <= nums[mid]:
if nums[lo] <= target < nums[mid]:
hi = mid - 1
else:
lo = mid + 1
# Right half is sorted
else:
if nums[mid] < target <= nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return -1
print(search([4, 5, 6, 7, 0, 1, 2], 0)) # 4
print(search([4, 5, 6, 7, 0, 1, 2], 3)) # -1
print(search([1], 0)) # -1
Complexity
- Time:
O(log n) - Space:
O(1)
Common Pitfalls
Using strict inequality nums[lo] < nums[mid] to detect the sorted half. When lo == mid (single element window), nums[lo] == nums[mid], so you need <= to correctly identify the left half as sorted.
Wrong range check for the sorted half. For the left half, the target must satisfy nums[lo] <= target < nums[mid] (exclusive upper bound because mid itself was already checked). For the right half: nums[mid] < target <= nums[hi] (exclusive lower bound). Getting these bounds wrong causes you to search the wrong half.
Forgetting to return -1 after the loop. Unlike “find minimum”, this problem can legitimately miss the target entirely.
Search in Rotated Sorted Array II
Difficulty: Medium Source: NeetCode
Problem
There is an integer array
numssorted in non-decreasing order (not necessarily with distinct values).Before being passed to your function,
numsis rotated at an unknown pivot indexk. Given the arraynumsafter the possible rotation and an integertarget, returntrueiftargetis innums, orfalseif it is not innums.You must decrease the overall operation steps as much as possible.
Example 1: Input:
nums = [2, 5, 6, 0, 0, 1, 2],target = 0Output:trueExample 2: Input:
nums = [2, 5, 6, 0, 0, 1, 2],target = 3Output:falseConstraints:
1 <= nums.length <= 5000-10^4 <= nums[i] <= 10^4numsis guaranteed to be rotated at some pivot-10^4 <= target <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Search in Rotated Sorted Array (no duplicates) — the same half-sorted logic applies here as the base case
- Handling Ambiguity — recognizing when duplicates make it impossible to determine the sorted half
1. Brute Force
Intuition
With duplicates, linear search is straightforward and sometimes the only option in the worst case anyway.
Algorithm
- Return
target in nums.
Solution
def search_linear(nums, target):
return target in nums
print(search_linear([2, 5, 6, 0, 0, 1, 2], 0)) # True
print(search_linear([2, 5, 6, 0, 0, 1, 2], 3)) # False
Complexity
- Time:
O(n) - Space:
O(1)
2. Binary Search with Duplicate Handling
Intuition
The approach is the same as the no-duplicates version, with one critical extra case: when nums[lo] == nums[mid] == nums[hi], we can’t tell which half is sorted — the rotation pivot could be anywhere. In this case, the only safe move is to shrink both boundaries by one (lo += 1, hi -= 1) and try again. This degenerate case makes the worst-case O(n) (e.g. all same elements), but the average case remains O(log n).
Algorithm
- Set
lo = 0,hi = len(nums) - 1. - While
lo <= hi:mid = lo + (hi - lo) // 2.- If
nums[mid] == target, returnTrue. - Ambiguous case (
nums[lo] == nums[mid] == nums[hi]):lo += 1,hi -= 1. - Left half sorted (
nums[lo] <= nums[mid]):- If
nums[lo] <= target < nums[mid]:hi = mid - 1. - Else:
lo = mid + 1.
- If
- Right half sorted:
- If
nums[mid] < target <= nums[hi]:lo = mid + 1. - Else:
hi = mid - 1.
- If
- Return
False.
flowchart LR
S(["nums=[2,5,6,0,0,1,2] target=0\nlo=0 hi=6"])
S --> m0["mid=3 nums[3]=0 == 0 → return True"]
Solution
def search(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2
if nums[mid] == target:
return True
# Duplicates make it impossible to determine the sorted half
if nums[lo] == nums[mid] == nums[hi]:
lo += 1
hi -= 1
# Left half is sorted
elif nums[lo] <= nums[mid]:
if nums[lo] <= target < nums[mid]:
hi = mid - 1
else:
lo = mid + 1
# Right half is sorted
else:
if nums[mid] < target <= nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return False
print(search([2, 5, 6, 0, 0, 1, 2], 0)) # True
print(search([2, 5, 6, 0, 0, 1, 2], 3)) # False
print(search([1, 0, 1, 1, 1], 0)) # True
Complexity
- Time:
O(log n)average,O(n)worst case (all duplicates) - Space:
O(1)
Common Pitfalls
Skipping the ambiguous case entirely. If you just copy the no-duplicates solution, you’ll get wrong answers on arrays like [1, 1, 1, 0, 1] where nums[lo] == nums[mid] but the pivot is in the right half.
Only shrinking one side when ambiguous. When nums[lo] == nums[mid] == nums[hi], you genuinely cannot eliminate either half — shrink both. Shrinking only lo or only hi can lead to an infinite loop or a missed target.
Returning an index instead of a boolean. This variant returns true/false, not an index. The problem is subtly different from LeetCode 33.
Time Based Key-Value Store
Difficulty: Medium Source: NeetCode
Problem
Design a time-based key-value data structure that can store multiple values for the same key at different time stamps and retrieve the key’s value at a certain timestamp.
Implement the
TimeMapclass:
TimeMap()Initializes the object.void set(String key, String value, int timestamp)Stores the key with the value at the given time timestamp.String get(String key, int timestamp)Returns a value such thatsetwas called previously withtimestamp_prev <= timestamp. If there are multiple such values, it returns the value associated with the largesttimestamp_prev. If there are no values, it returns"".Example 1: Input:
set("foo", "bar", 1) get("foo", 1) → "bar" get("foo", 3) → "bar" set("foo", "bar2", 4) get("foo", 4) → "bar2" get("foo", 5) → "bar2"Constraints:
1 <= key.length, value.length <= 100keyandvalueconsist of lowercase English letters and digits1 <= timestamp <= 10^7- All calls to
setare made with strictly increasingtimestamp- At most
2 * 10^5calls will be made tosetandget
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — mapping keys to lists of values
- Binary Search — finding the largest timestamp
<= queryin a sorted list - Upper-bound / Floor Search — a binary search variant that finds the rightmost valid position
1. Brute Force
Intuition
Store all (timestamp, value) pairs for each key in a list. On get, scan the whole list for the largest timestamp that doesn’t exceed the query. Simple but slow on large inputs.
Algorithm
set(key, value, timestamp): append(timestamp, value)tostore[key].get(key, timestamp): iteratestore[key], track the best(timestamp_prev, value)wheretimestamp_prev <= timestamp. Return the associated value, or"".
Solution
class TimeMapBrute:
def __init__(self):
self.store = {}
def set(self, key, value, timestamp):
if key not in self.store:
self.store[key] = []
self.store[key].append((timestamp, value))
def get(self, key, timestamp):
if key not in self.store:
return ""
best = ""
for ts, val in self.store[key]:
if ts <= timestamp:
best = val # list is in increasing order so last valid is the answer
return best
tm = TimeMapBrute()
tm.set("foo", "bar", 1)
print(tm.get("foo", 1)) # bar
print(tm.get("foo", 3)) # bar
tm.set("foo", "bar2", 4)
print(tm.get("foo", 4)) # bar2
print(tm.get("foo", 5)) # bar2
Complexity
- Time:
set O(1),get O(n)per key - Space:
O(n)total
2. Binary Search on Timestamps
Intuition
The problem guarantees timestamps are inserted in strictly increasing order, so each key’s list is already sorted by timestamp. This is the perfect setup for a binary search: on get, find the largest timestamp <= query using a floor-search variant of binary search. Instead of checking equality, we track the rightmost position where timestamps[mid] <= timestamp.
Algorithm
set(key, value, timestamp): append(timestamp, value)tostore[key].get(key, timestamp):- If key doesn’t exist, return
"". - Binary search
store[key]for the largest index wherets <= timestamp. - Set
lo = 0,hi = len(pairs) - 1,ans = "". - While
lo <= hi: computemid, ifpairs[mid][0] <= timestamprecordans = pairs[mid][1]and go right (lo = mid + 1); else go left (hi = mid - 1). - Return
ans.
- If key doesn’t exist, return
flowchart LR
S(["get('foo', 3)\npairs=[(1,'bar'),(4,'bar2')]\nlo=0 hi=1 ans=''"])
S --> m0["mid=0 ts=1 ≤ 3 → ans='bar' lo=1"]
m0 --> m1["mid=1 ts=4 > 3 → hi=0"]
m1 --> done["lo=1 > hi=0 → return 'bar'"]
Solution
class TimeMap:
def __init__(self):
self.store = {}
def set(self, key, value, timestamp):
if key not in self.store:
self.store[key] = []
self.store[key].append((timestamp, value))
def get(self, key, timestamp):
if key not in self.store:
return ""
pairs = self.store[key]
lo, hi, ans = 0, len(pairs) - 1, ""
while lo <= hi:
mid = lo + (hi - lo) // 2
if pairs[mid][0] <= timestamp:
ans = pairs[mid][1]
lo = mid + 1 # look for a later (larger) valid timestamp
else:
hi = mid - 1
return ans
tm = TimeMap()
tm.set("foo", "bar", 1)
print(tm.get("foo", 1)) # bar
print(tm.get("foo", 3)) # bar
tm.set("foo", "bar2", 4)
print(tm.get("foo", 4)) # bar2
print(tm.get("foo", 5)) # bar2
print(tm.get("foo", 0)) # (empty string)
Complexity
- Time:
set O(1),get O(log n)per key - Space:
O(n)total
Common Pitfalls
Going left when ts <= timestamp instead of right. You want the largest valid timestamp, so when you find a match, record it and keep moving right (lo = mid + 1) to see if there’s a later one. Going left loses valid candidates.
Returning ans without initialising it to "". If the query timestamp is earlier than every stored timestamp, no ts <= timestamp condition fires and ans stays as the empty string — the correct answer. Make sure ans = "" is your initial value.
Assuming set timestamps are unique across keys. Timestamps are strictly increasing per method call overall, but two different keys can have the same timestamp stored if set is called once per key at the same time. The binary search logic is unaffected, but don’t rely on global uniqueness.
Split Array Largest Sum
Difficulty: Hard Source: NeetCode
Problem
Given an integer array
numsand an integerk, splitnumsintoknon-empty subarrays such that the largest sum of any subarray is minimized.Return the minimized largest sum of the split.
Example 1: Input:
nums = [7, 2, 5, 10, 8],k = 2Output:18Explanation: Split into[7,2,5]and[10,8]. Largest sum is 18. Other splits are worse.Example 2: Input:
nums = [1, 2, 3, 4, 5],k = 2Output:9Explanation: Split into[1,2,3,4]and[5]. Largest sum is 9.Constraints:
1 <= nums.length <= 10000 <= nums[i] <= 10^61 <= k <= min(50, nums.length)
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search on the Answer — the answer (largest subarray sum) lies in a known range and the feasibility check is monotone
- Greedy Partitioning — greedily packing elements into the fewest subarrays given a maximum sum cap
1. Brute Force (Recursion with Memoization)
Intuition
Try every possible split position recursively. At each position decide how many elements go into the current subarray, then recurse on the rest with k - 1 pieces. Take the minimum over all choices of the maximum subarray sum produced. Memoize on (index, k) to avoid redundant recomputation.
Algorithm
- Define
dp(i, pieces): minimum possible largest sum when splittingnums[i:]intopiecesparts. - Base case:
pieces == 1→ returnsum(nums[i:]). - For each
jfromiton - pieces: try puttingnums[i:j+1]in the current part. Recurse ondp(j+1, pieces-1). Track the minimum ofmax(current_sum, recursive_result).
Solution
from functools import lru_cache
def splitArray_memo(nums, k):
prefix = [0] * (len(nums) + 1)
for i, v in enumerate(nums):
prefix[i + 1] = prefix[i] + v
def range_sum(i, j): # sum of nums[i..j] inclusive
return prefix[j + 1] - prefix[i]
@lru_cache(maxsize=None)
def dp(i, pieces):
if pieces == 1:
return range_sum(i, len(nums) - 1)
best = float("inf")
for j in range(i, len(nums) - pieces + 1):
cur = range_sum(i, j)
best = min(best, max(cur, dp(j + 1, pieces - 1)))
return best
return dp(0, k)
print(splitArray_memo([7, 2, 5, 10, 8], 2)) # 18
print(splitArray_memo([1, 2, 3, 4, 5], 2)) # 9
Complexity
- Time:
O(n² * k) - Space:
O(n * k)
2. Binary Search on the Answer
Intuition
The answer — the minimized largest subarray sum — lies between max(nums) (each element is its own subarray, any fewer subarrays must have a larger max) and sum(nums) (one subarray). The feasibility function is: “given a maximum allowed sum cap, can we split nums into at most k subarrays?” This is monotone — if cap works, any larger cap also works. Binary-search for the smallest feasible cap.
The feasibility check is a simple greedy: accumulate elements until adding the next one would exceed cap, then start a new subarray.
Algorithm
canSplit(cap): greedily fill subarrays; count how many are needed. Returncount <= k.- Binary search
[max(nums), sum(nums)]for the smallest cap wherecanSplitreturnsTrue.
flowchart LR
S(["nums=[7,2,5,10,8] k=2\nlo=10 hi=32"])
S --> m0["mid=21 canSplit: [7,2,5]=14≤21, [10,8]=18≤21 → 2 parts ✓ → ans=21 hi=20"]
m0 --> m1["mid=15 canSplit: [7,2,5]=14≤15, [10]≤15, [8]≤15 → 3 parts ✗ → lo=16"]
m1 --> m2["mid=18 canSplit: [7,2,5]=14≤18, [10,8]=18≤18 → 2 parts ✓ → ans=18 hi=17"]
m2 --> m3["mid=16 canSplit: [7,2,5]=14≤16, [10]≤16, [8]≤16 → 3 parts ✗ → lo=17"]
m3 --> done["mid=17 [7,2,5]=14≤17, [10]≤17, [8]≤17 → 3 parts ✗ → lo=18 > hi=17 → return 18"]
Solution
def splitArray(nums, k):
def canSplit(cap):
parts, current = 1, 0
for v in nums:
if current + v > cap:
parts += 1
current = 0
current += v
return parts <= k
lo, hi = max(nums), sum(nums)
ans = hi
while lo <= hi:
mid = lo + (hi - lo) // 2
if canSplit(mid):
ans = mid
hi = mid - 1
else:
lo = mid + 1
return ans
print(splitArray([7, 2, 5, 10, 8], 2)) # 18
print(splitArray([1, 2, 3, 4, 5], 2)) # 9
print(splitArray([1, 4, 4], 3)) # 4
Complexity
- Time:
O(n log(sum(nums))) - Space:
O(1)
Common Pitfalls
Setting lo = 0 or lo = 1. The minimum feasible cap is max(nums) — any cap smaller than the largest element cannot accommodate that element at all.
Using parts > k when starting a new part. Start a new part when adding the next element would exceed cap (current + v > cap), not after. The element that caused the overflow belongs to the new part.
Confusing minimization direction. You’re minimizing the largest sum, so when canSplit(mid) is True, record ans = mid and try smaller (hi = mid - 1). This is the opposite of a lower-bound search.
Median of Two Sorted Arrays
Difficulty: Hard Source: NeetCode
Problem
Given two sorted arrays
nums1andnums2of sizemandnrespectively, return the median of the two sorted arrays.The overall run time complexity should be
O(log(m + n)).Example 1: Input:
nums1 = [1, 3],nums2 = [2]Output:2.0Explanation: merged array =[1, 2, 3], median is2.Example 2: Input:
nums1 = [1, 2],nums2 = [3, 4]Output:2.5Explanation: merged array =[1, 2, 3, 4], median is(2 + 3) / 2 = 2.5.Constraints:
m == nums1.length,n == nums2.length0 <= m, n <= 10001 <= m + n <= 2000-10^6 <= nums1[i], nums2[i] <= 10^6
Prerequisites
Before attempting this problem, you should be comfortable with:
- Median Definition — the value separating the lower and upper halves; accounts for even/odd total length
- Binary Search on a Partition — searching for a cut point rather than a value
- Merge of Two Sorted Arrays — understanding what a valid partition of both arrays looks like
1. Brute Force — Merge and Find
Intuition
Merge both sorted arrays into one sorted array, then read off the median directly. Easy to implement, but requires O(m + n) time and space, violating the O(log(m + n)) requirement.
Algorithm
- Merge
nums1andnums2into a single sorted arraymerged. - If total length is odd, return the middle element.
- If even, return the average of the two middle elements.
Solution
def findMedianSortedArrays_merge(nums1, nums2):
merged = sorted(nums1 + nums2)
total = len(merged)
mid = total // 2
if total % 2 == 1:
return float(merged[mid])
return (merged[mid - 1] + merged[mid]) / 2.0
print(findMedianSortedArrays_merge([1, 3], [2])) # 2.0
print(findMedianSortedArrays_merge([1, 2], [3, 4])) # 2.5
print(findMedianSortedArrays_merge([0, 0], [0, 0])) # 0.0
Complexity
- Time:
O((m + n) log(m + n)) - Space:
O(m + n)
2. Binary Search on the Partition
Intuition
Imagine cutting both arrays simultaneously into left and right halves such that:
- The total number of elements on the left equals
(m + n) // 2(or// 2 + 1for odd total). - Every element on the left is
<=every element on the right.
If we binary-search for the cut position i in the smaller array (nums1), the cut position in the larger array j is determined: j = half - i. At each i, check the boundary elements:
nums1[i-1] <= nums2[j](left of A doesn’t bleed into right of B)nums2[j-1] <= nums1[i](left of B doesn’t bleed into right of A)
If both hold, we’ve found the right partition. Use float('inf') / float('-inf') to handle boundary cases where a partition is at the very edge.
Algorithm
- Ensure
nums1is the shorter array (swap if needed). - Set
lo = 0,hi = len(nums1). - While
lo <= hi:i = (lo + hi) // 2— cut innums1(0..m elements on left).j = half - i— cut innums2.- Let
A_left = nums1[i-1](or-inf),A_right = nums1[i](or+inf), similarly for B. - If
A_left <= B_rightandB_left <= A_right: valid partition.- Odd total: return
max(A_left, B_left). - Even total: return
(max(A_left, B_left) + min(A_right, B_right)) / 2.
- Odd total: return
- If
A_left > B_right:iis too large →hi = i - 1. - Else:
iis too small →lo = i + 1.
flowchart LR
S(["nums1=[1,2] nums2=[3,4] half=2\nlo=0 hi=2"])
S --> m0["i=1 j=1\nA_left=1 A_right=2\nB_left=3 B_right=4\n1≤4 but 3>2 → i too small → lo=2"]
m0 --> m1["i=2 j=0\nA_left=2 A_right=+inf\nB_left=-inf B_right=3\n2≤3 and -inf≤inf → valid\neven: (max(2,-inf)+min(inf,3))/2=(2+3)/2=2.5"]
Solution
def findMedianSortedArrays(nums1, nums2):
# Always binary-search the smaller array
if len(nums1) > len(nums2):
nums1, nums2 = nums2, nums1
m, n = len(nums1), len(nums2)
half = (m + n) // 2
lo, hi = 0, m
while lo <= hi:
i = (lo + hi) // 2 # cut in nums1: i elements on left
j = half - i # cut in nums2: j elements on left
A_left = nums1[i - 1] if i > 0 else float("-inf")
A_right = nums1[i] if i < m else float("inf")
B_left = nums2[j - 1] if j > 0 else float("-inf")
B_right = nums2[j] if j < n else float("inf")
if A_left <= B_right and B_left <= A_right:
# Found the correct partition
if (m + n) % 2 == 1:
return float(min(A_right, B_right))
return (max(A_left, B_left) + min(A_right, B_right)) / 2.0
elif A_left > B_right:
hi = i - 1 # i is too large
else:
lo = i + 1 # i is too small
return 0.0 # unreachable if inputs are valid
print(findMedianSortedArrays([1, 3], [2])) # 2.0
print(findMedianSortedArrays([1, 2], [3, 4])) # 2.5
print(findMedianSortedArrays([], [1])) # 1.0
Complexity
- Time:
O(log(min(m, n))) - Space:
O(1)
Common Pitfalls
Binary-searching the longer array. Always search the shorter one — it gives the smaller log factor and keeps j non-negative (since j = half - i and half <= n when m <= n).
Off-by-one in the odd-total median. For an odd total m + n, the median is the single middle element. With half = (m + n) // 2, the left side has half elements, so the median is min(A_right, B_right) — the smallest element on the right.
Not guarding with inf at the edges. When i == 0, nums1 contributes nothing to the left half; A_left should be -inf so it never triggers the “too large” condition. When i == m, A_right should be +inf so it never prevents a valid partition.
Find in Mountain Array
Difficulty: Hard Source: NeetCode
Problem
You may recall that an array
arris a mountain array if and only if:
arr.length >= 3- There exists some index
i(0-indexed) with0 < i < arr.length - 1such that:
arr[0] < arr[1] < ... < arr[i]arr[i] > arr[i+1] > ... > arr[arr.length - 1]Given a mountain array
mountainArrand an integertarget, return the minimum index such thatmountainArr.get(index) == target. If such an index does not exist, return-1.You can only access the array via the
MountainArrayinterface:
MountainArray.get(index)returns the element at indexindexMountainArray.length()returns the length of the arraySubmissions that call
getmore than100times will be judged as Wrong Answer.Example 1: Input:
mountainArr = [1, 2, 3, 4, 5, 3, 1],target = 3Output:2Example 2: Input:
mountainArr = [0, 1, 2, 4, 2, 1],target = 3Output:-1Constraints:
3 <= mountainArr.length() <= 10^40 <= target <= 10^90 <= mountainArr.get(index) <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search on a Unimodal Function — finding a peak in an array that first increases then decreases
- Binary Search on Ascending / Descending Arrays — adapting the comparison direction for a descending half
- API / Black-box Access — calling
.get()is expensive; minimise calls
3. Three-Phase Binary Search — Optimal
Intuition
A mountain array has three distinct phases you can exploit in order:
-
Find the peak index — binary search for the point where the array switches from increasing to decreasing. At
mid, ifget(mid) < get(mid+1)the peak is to the right; otherwise it’s atmidor to the left. -
Search the ascending left half — standard binary search in
[0, peak]. Return the index if found. -
Search the descending right half — binary search in
[peak+1, n-1], but flip the comparison direction (larger elements are on the left now).
Always search the ascending half first because the problem asks for the minimum index. There is no brute-force section here; a linear scan would risk exceeding the 100-call limit.
Algorithm
Phase 1 — Find peak:
lo = 0,hi = n - 2(peak can’t be the last index).- While
lo < hi:mid = (lo + hi) // 2. Ifget(mid) < get(mid+1):lo = mid + 1. Else:hi = mid. peak = lo.
Phase 2 — Ascending binary search [0, peak]:
- Standard binary search. If
get(mid) == target, returnmid. Ifget(mid) < target,lo = mid+1. Elsehi = mid-1.
Phase 3 — Descending binary search [peak+1, n-1]:
- Same structure but flip: if
get(mid) > target,lo = mid+1. Ifget(mid) < target,hi = mid-1.
flowchart TD
A["Phase 1: Find peak index"]
A --> B["Peak found at index p"]
B --> C["Phase 2: Binary search ascending half [0, p]"]
C --> D{"Found target?"}
D -- Yes --> E["Return index (minimum index guaranteed)"]
D -- No --> F["Phase 3: Binary search descending half [p+1, n-1]"]
F --> G{"Found target?"}
G -- Yes --> H["Return index"]
G -- No --> I["Return -1"]
Solution
class MountainArray:
"""Simulated interface for local testing."""
def __init__(self, arr):
self._arr = arr
self._calls = 0
def get(self, index):
self._calls += 1
return self._arr[index]
def length(self):
return len(self._arr)
def findInMountainArray(target, mountainArr):
n = mountainArr.length()
# Phase 1: Find peak index
lo, hi = 0, n - 2
while lo < hi:
mid = (lo + hi) // 2
if mountainArr.get(mid) < mountainArr.get(mid + 1):
lo = mid + 1
else:
hi = mid
peak = lo
# Phase 2: Binary search ascending half [0, peak]
lo, hi = 0, peak
while lo <= hi:
mid = (lo + hi) // 2
val = mountainArr.get(mid)
if val == target:
return mid
elif val < target:
lo = mid + 1
else:
hi = mid - 1
# Phase 3: Binary search descending half [peak+1, n-1]
lo, hi = peak + 1, n - 1
while lo <= hi:
mid = (lo + hi) // 2
val = mountainArr.get(mid)
if val == target:
return mid
elif val > target: # descending: larger values are on the left
lo = mid + 1
else:
hi = mid - 1
return -1
ma1 = MountainArray([1, 2, 3, 4, 5, 3, 1])
print(findInMountainArray(3, ma1)) # 2
ma2 = MountainArray([0, 1, 2, 4, 2, 1])
print(findInMountainArray(3, ma2)) # -1
ma3 = MountainArray([1, 5, 2])
print(findInMountainArray(2, ma3)) # 2
Complexity
- Time:
O(log n)— three independent binary searches, eachO(log n) - Space:
O(1) - API calls: at most
3 * log2(10^4) ≈ 42, well within the 100-call limit
Common Pitfalls
Searching the right half before the left half. The problem asks for the minimum index. If the target appears on both the ascending and descending sides, the ascending side has the smaller index. Always search left first.
Setting hi = n - 1 when finding the peak. The peak can’t be the very last element (by mountain array definition, the last element is on the descending slope). Use hi = n - 2 so mid + 1 is always a valid index.
Flipping the wrong comparison in the descending search. In a descending array, larger values are towards the left. When get(mid) > target, the target is to the right — so set lo = mid + 1. This is the reverse of the ascending case.
Reusing peak as a boundary in both halves. Search the ascending half as [0, peak] (inclusive) and the descending half as [peak + 1, n - 1]. Don’t include peak in the descending search — it was already checked in phase 2.
Find K Closest Elements
Difficulty: Medium Source: NeetCode
Problem
Given a sorted integer array
arr, two integerskandx, return thekclosest integers toxin the array. The result should also be sorted in ascending order. An integerais closer toxthan an integerbif|a - x| < |b - x|, or|a - x| == |b - x|anda < b.Example 1: Input: arr = [1,2,3,4,5], k = 4, x = 3 Output: [1,2,3,4]
Example 2: Input: arr = [1,2,3,4,5], k = 4, x = -1 Output: [1,2,3,4]
Constraints:
- 1 <= k <= arr.length <= 10^4
- arr is sorted in ascending order
- -10^4 <= arr[i], x <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Search — efficient searching in sorted arrays
- Sliding Window — maintaining a fixed-size window; here we find where to position it
- Tie-breaking rules — when two elements are equally close, prefer the smaller one
1. Brute Force (Sort by Distance)
Intuition
Sort all elements by their distance to x (with ties broken by the element value itself). Take the first k elements from that sorted list, then re-sort them in ascending order to get the answer. Simple to reason about, but sorting the whole array to find k elements is wasteful.
Algorithm
- Sort
arrby the key(abs(a - x), a)— distance first, value as tiebreaker. - Take the first
kelements. - Sort those
kelements and return.
Solution
def findClosestElements_brute(arr, k, x):
# Sort by distance to x, then by value for ties
sorted_by_dist = sorted(arr, key=lambda a: (abs(a - x), a))
result = sorted(sorted_by_dist[:k])
return result
# Test cases
print(findClosestElements_brute([1, 2, 3, 4, 5], 4, 3)) # [1, 2, 3, 4]
print(findClosestElements_brute([1, 2, 3, 4, 5], 4, -1)) # [1, 2, 3, 4]
print(findClosestElements_brute([1, 2, 3, 4, 5], 4, 5)) # [2, 3, 4, 5]
Complexity
- Time:
O(n log n)— sorting the array plus sorting k results - Space:
O(n)— storing the sorted copy
2. Binary Search on Left Boundary
Intuition
Since the array is sorted, the answer is always a contiguous window of size k. We just need to find where that window starts. Binary search on the left boundary mid of the window: if x - arr[mid] > arr[mid + k] - x, the window should slide right (the left element is farther from x than the right element just outside the window). Otherwise, slide left. This neatly finds the optimal left index in O(log(n-k)) time.
Algorithm
- Binary search for the left boundary:
lo = 0,hi = len(arr) - k- While
lo < hi, computemid = (lo + hi) // 2. - If
x - arr[mid] > arr[mid + k] - x, setlo = mid + 1(slide right). - Else set
hi = mid(slide left or stay).
- Return
arr[lo:lo + k].
graph TD
A["Binary search on left index lo"] --> B["mid = (lo+hi)//2"]
B --> C{"x - arr[mid] > arr[mid+k] - x?"}
C -->|Yes: left side farther| D["lo = mid + 1"]
C -->|No: right side farther or equal| E["hi = mid"]
D --> B
E --> B
B --> F["lo converges → return arr[lo:lo+k]"]
Solution
def findClosestElements(arr, k, x):
lo = 0
hi = len(arr) - k # right boundary of search: window can start at most here
while lo < hi:
mid = (lo + hi) // 2
# Compare left edge arr[mid] vs right edge arr[mid+k] (just outside window)
if x - arr[mid] > arr[mid + k] - x:
# Left element is farther → shift window to the right
lo = mid + 1
else:
# Right element is farther or equidistant (prefer smaller → stay left)
hi = mid
return arr[lo:lo + k]
# Test cases
print(findClosestElements([1, 2, 3, 4, 5], 4, 3)) # [1, 2, 3, 4]
print(findClosestElements([1, 2, 3, 4, 5], 4, -1)) # [1, 2, 3, 4]
print(findClosestElements([1, 2, 3, 4, 5], 4, 5)) # [2, 3, 4, 5]
Complexity
- Time:
O(log(n - k) + k)— binary search plus slicing k elements - Space:
O(1)extra (output excluded)
Common Pitfalls
Binary search range is [0, n - k], not [0, n - 1]. The window of size k must fit entirely in the array, so the left boundary can be at most n - k. Searching beyond that causes index-out-of-bounds when accessing arr[mid + k].
The tie-breaking direction. When x - arr[mid] == arr[mid + k] - x, the problem says prefer the smaller value. Since arr[mid] < arr[mid + k] (sorted array), we prefer arr[mid], meaning we keep hi = mid (not lo = mid + 1). The > (strict greater-than) in the condition handles this correctly.
Confusing mid+k as inside vs outside the window. arr[lo:lo+k] means indices lo, lo+1, ..., lo+k-1. So arr[mid+k] is the element just outside the window’s right edge — it’s the candidate that gets added if we shift right.
Trees
Your file system, your company org chart, the HTML DOM of every webpage — all trees.
Open your terminal and run ls /usr. What you see is a tree: a root directory that branches into children, each of which can branch into more children. Zoom out and your entire hard drive is a single tree rooted at /. Zoom in on any webpage and the HTML is a tree where <html> is the root, <body> is a child, and every <div>, <p>, and <span> hangs off it. The same shape appears in compilers, databases, AI decision-making, and network routing.
Trees are the most important non-linear data structure you will learn.
What makes something a tree?
A tree is a collection of nodes connected by edges, where:
- There is exactly one root node at the top.
- Every other node has exactly one parent.
- A node with no children is called a leaf.
- Nodes can have zero or more children.
This is different from a linked list (purely linear) or a graph (edges can form cycles). A tree has no cycles — you can never follow edges and end up back where you started.
flowchart TD
CEO["CEO (root)"]
CEO --> VP_Eng["VP Engineering"]
CEO --> VP_Sales["VP Sales"]
VP_Eng --> Lead_BE["Backend Lead"]
VP_Eng --> Lead_FE["Frontend Lead"]
VP_Sales --> Sales_EU["EU Sales"]
VP_Sales --> Sales_US["US Sales"]
Lead_BE --> Dev1["Engineer A (leaf)"]
Lead_BE --> Dev2["Engineer B (leaf)"]
Lead_FE --> Dev3["Engineer C (leaf)"]
Every company org chart is a tree. The CEO is the root. VPs are internal nodes. Individual contributors with no direct reports are leaves.
Key vocabulary
| Term | Meaning |
|---|---|
| Root | The single top-level node — has no parent |
| Leaf | A node with no children |
| Height | Number of edges on the longest path from root to a leaf |
| Depth | Number of edges from the root to a given node |
| Subtree | A node plus all of its descendants — itself a valid tree |
| Level | All nodes at the same depth form a level |
What you will build toward
This section covers the most important tree variant — the Binary Tree — and the most important algorithm built on it — the Binary Search Tree. By the end you will know how to:
- Binary Tree — understand the node/root/leaf vocabulary, build trees in code, and think about the three ways to walk one.
- Binary Search Tree — exploit the left < node < right ordering rule to search in O(log n) time instead of O(n).
- BST Insert and Remove — keep a BST sorted through insertions and the tricky three-case deletion.
- Depth-First Search — dive deep before backtracking; produce sorted output, copy a tree, evaluate expressions.
- Breadth-First Search — sweep level by level with a queue; find shortest paths and serialize trees.
- BST Sets and Maps — use a BST as the engine behind an ordered set or ordered dictionary.
Each topic builds directly on the previous one. Start with Binary Trees and work through in order.
Binary Tree
Every node has at most two children — left and right. Simple rule, enormous power.
That one constraint is all it takes to unlock fast searching, elegant sorting, file compression, and the way your compiler understands 2 + 3 * 4. A binary tree is the foundation everything in this section is built on.
Anatomy of a binary tree
Let’s label every part of a concrete tree before we write any code.
flowchart TD
A["1 ← root (depth 0)"]
B["2 ← internal node (depth 1)"]
C["3 ← internal node (depth 1)"]
D["4 ← leaf (depth 2)"]
E["5 ← leaf (depth 2)"]
F["6 ← leaf (depth 2)"]
A -->|"left child"| B
A -->|"right child"| C
B -->|"left child"| D
B -->|"right child"| E
C -->|"left child"| F
| Concept | Definition | In the diagram above |
|---|---|---|
| Root | The top node — has no parent | Node 1 |
| Leaf | A node with no children | Nodes 4, 5, 6 |
| Height | Longest root-to-leaf edge count | 2 (root → 2 → 4) |
| Depth | Edge count from root to this node | Node 6 is at depth 2 |
| Subtree | Any node + all its descendants | Node 2 with children 4, 5 |
The TreeNode class
A node in a binary tree needs to store three things: its value, a pointer to its left child, and a pointer to its right child. If a child does not exist, the pointer is None.
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
# Build the tree from the diagram above manually
root = TreeNode(
1,
left=TreeNode(
2,
left=TreeNode(4),
right=TreeNode(5),
),
right=TreeNode(
3,
left=TreeNode(6),
),
)
print("Root:", root.value)
print("Root's left child:", root.left.value)
print("Root's right child:", root.right.value)
print("Root's left-left grandchild:", root.left.left.value)
print("Root's left-right grandchild:", root.left.right.value)
print("Root's right-left grandchild:", root.right.left.value)
Building a tree by nesting constructor calls like this is the clearest way to see the shape all at once. Each inner TreeNode(...) becomes a subtree.
Computing height and depth
Height and depth are two sides of the same coin. Height is measured from a node downward to its deepest leaf; depth is measured from the root down to a node.
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6)),
)
def height(node):
"""Height of a subtree = longest path from node down to a leaf."""
if node is None:
return -1 # empty tree has height -1 by convention
return 1 + max(height(node.left), height(node.right))
def depth(root, target_value, current_depth=0):
"""Depth of a node = number of edges from root to that node."""
if root is None:
return -1 # not found
if root.value == target_value:
return current_depth
left_result = depth(root.left, target_value, current_depth + 1)
if left_result != -1:
return left_result
return depth(root.right, target_value, current_depth + 1)
print("Height of whole tree:", height(root)) # 2
print("Height of node 2's subtree:", height(root.left)) # 1
print("Depth of node 6:", depth(root, 6)) # 2
print("Depth of node 1 (root):", depth(root, 1)) # 0
The three traversal orders
Walking a binary tree means visiting every node exactly once. There are three classic orders, all defined recursively:
| Order | Visit sequence | Memory trick |
|---|---|---|
| Pre-order | Root → Left → Right | Pre = root comes before children |
| In-order | Left → Root → Right | Root is in the middle |
| Post-order | Left → Right → Root | Root comes after children |
We will implement all three fully in the Depth-First Search chapter. For now, see how the orders differ on the same tree:
flowchart TD
A["1"] --> B["2"]
A --> C["3"]
B --> D["4"]
B --> E["5"]
C --> F["6"]
- Pre-order: 1 → 2 → 4 → 5 → 3 → 6
- In-order: 4 → 2 → 5 → 1 → 6 → 3
- Post-order: 4 → 5 → 2 → 6 → 3 → 1
Real-world: expression trees
When your compiler reads 2 + 3 * 4, it cannot just evaluate left to right — operator precedence means * must happen before +. The compiler builds an expression tree where operators are internal nodes and numbers are leaves.
flowchart TD
Plus["+"]
Plus --> Two["2"]
Plus --> Mult["*"]
Mult --> Three["3"]
Mult --> Four["4"]
Post-order traversal evaluates this tree correctly:
- Visit
2(leaf) → value is 2 - Visit
3(leaf) → value is 3 - Visit
4(leaf) → value is 4 - Visit
*→ compute 3 * 4 = 12 - Visit
+→ compute 2 + 12 = 14
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
# Represents: 2 + (3 * 4)
expr_tree = TreeNode(
"+",
left=TreeNode("2"),
right=TreeNode("*", left=TreeNode("3"), right=TreeNode("4")),
)
def evaluate(node):
"""Evaluate an expression tree using post-order traversal."""
if node is None:
return 0
# Leaf node — it's a number
if node.left is None and node.right is None:
return int(node.value)
# Internal node — it's an operator
left_val = evaluate(node.left)
right_val = evaluate(node.right)
if node.value == "+":
return left_val + right_val
if node.value == "*":
return left_val * right_val
if node.value == "-":
return left_val - right_val
if node.value == "/":
return left_val // right_val
print("2 + 3 * 4 =", evaluate(expr_tree)) # 14
Real-world: Huffman encoding
When you zip a file, the compression algorithm (often Huffman coding) builds a binary tree where:
- Frequent characters get short codes (close to the root).
- Rare characters get long codes (deep in the tree).
A character’s binary code is the path from root to its leaf: go left → write 0, go right → write 1. The letter e (most frequent in English) might be encoded as just 10, while z might be 110011. This is why text files compress so well — common letters use fewer bits.
The binary tree is the data structure that makes this possible.
Binary Search Tree
A binary tree with a superpower: left < node < right, always. This one rule makes search O(log n).
Think of a dictionary. You do not start at page 1 and flip forward one page at a time to find “umbrella”. You open to the middle — if your word comes alphabetically before the middle word, you go left; if after, you go right. Each step cuts the remaining pages in half. A Binary Search Tree works exactly this way, but built from pointers instead of pages.
The BST property
For every node in the tree:
- All values in its left subtree are strictly less than the node’s value.
- All values in its right subtree are strictly greater than the node’s value.
This must hold not just for immediate children but for the entire subtree below each node.
flowchart TD
Root["8"]
Root -->|"all < 8"| Left["3"]
Root -->|"all > 8"| Right["10"]
Left -->|"all < 3"| LL["1"]
Left -->|"3 < x < 8"| LR["6"]
LR --> LRL["4"]
LR --> LRR["7"]
Right --> RR["14"]
RR -->|"left"| RRL["13"]
Check any node and the rule holds: node 6 has left child 4 (which is < 6) and right child 7 (which is > 6). Node 8 has an entire left subtree of values 1, 3, 4, 6, 7 — all less than 8 — and a right subtree of 10, 13, 14 — all greater than 8.
Building a BST manually
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
# Build the BST from the diagram above
root = TreeNode(8)
root.left = TreeNode(3)
root.right = TreeNode(10)
root.left.left = TreeNode(1)
root.left.right = TreeNode(6)
root.left.right.left = TreeNode(4)
root.left.right.right = TreeNode(7)
root.right.right = TreeNode(14)
root.right.right.left = TreeNode(13)
# Verify the property holds for the root
print("Root:", root.value)
print("Left child (must be < 8):", root.left.value)
print("Right child (must be > 8):", root.right.value)
print("Left subtree's right child (must be > 3 and < 8):", root.left.right.value)
Searching: narrowing down at every step
Search in a BST follows the same logic as flipping to the middle of a dictionary. At each node you ask one question: is my target smaller, larger, or equal to this node’s value?
flowchart TD
Root["8 ← start here: is 4 < 8? Yes → go left"]
Root --> Left["3 ← is 4 < 3? No. is 4 > 3? Yes → go right"]
Root --> Right["10 (skipped)"]
Left --> LL["1 (skipped)"]
Left --> LR["6 ← is 4 < 6? Yes → go left"]
LR --> LRL["4 ← found it!"]
LR --> LRR["7 (skipped)"]
We searched a tree of 9 nodes but only visited 4 of them: 8 → 3 → 6 → 4. At each step we discarded half (roughly) of the remaining tree. That is why search is O(log n) — the same reason binary search on a sorted array is O(log n).
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def search(node, target):
"""
Returns the node if found, otherwise None.
At each step we go left or right, never both.
"""
if node is None:
return None # ran off the bottom — not in tree
if target == node.value:
return node # found it
if target < node.value:
return search(node.left, target) # must be in left subtree
else:
return search(node.right, target) # must be in right subtree
# Build a small BST
root = TreeNode(8)
root.left = TreeNode(3)
root.right = TreeNode(10)
root.left.left = TreeNode(1)
root.left.right = TreeNode(6)
root.left.right.left = TreeNode(4)
root.left.right.right = TreeNode(7)
result = search(root, 4)
print("Search for 4:", result.value if result else "not found")
result = search(root, 5)
print("Search for 5:", result.value if result else "not found")
result = search(root, 1)
print("Search for 1:", result.value if result else "not found")
Iterative search (no recursion)
The recursive version is clean, but it is equally natural as a loop — and avoids stack frames for very deep trees:
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def search_iterative(root, target):
"""Walk down the tree until we find the value or fall off the edge."""
node = root
steps = 0
while node is not None:
steps += 1
if target == node.value:
print(f"Found {target} in {steps} step(s)")
return node
elif target < node.value:
node = node.left
else:
node = node.right
print(f"{target} not found after {steps} step(s)")
return None
root = TreeNode(8)
root.left = TreeNode(3)
root.right = TreeNode(10)
root.left.left = TreeNode(1)
root.left.right = TreeNode(6)
root.left.right.left = TreeNode(4)
root.left.right.right = TreeNode(7)
root.right.right = TreeNode(14)
root.right.right.left = TreeNode(13)
search_iterative(root, 13) # should take 4 steps: 8 → 10 → 14 → 13
search_iterative(root, 5) # not found
Why it can go wrong: unbalanced trees
The O(log n) guarantee depends on the tree being roughly balanced — each step halving what remains. If you insert values in sorted order (1, 2, 3, 4, 5, ...) the BST degrades into a linked list and search becomes O(n):
flowchart TD
N1["1"] --> N2["2"]
N2 --> N3["3"]
N3 --> N4["4"]
N4 --> N5["5 (leaf)"]
This is why self-balancing variants like AVL trees and Red-Black trees exist — they automatically restructure the tree after insertions to keep it balanced. Python’s sortedcontainers.SortedList uses a similar technique internally.
Real-world uses
Database indexes — When you run SELECT * FROM users WHERE age > 30, the database engine does not scan every row. If there is a BST-based index on age, it walks directly to the first node where age = 31 and reads forward from there.
Auto-complete — A BST (or its cousin, a trie) stores every known word. When you type “pre”, the search engine walks to the subtree of words that start with “pre” and returns them instantly.
Symbol tables in compilers — When the compiler processes your code it needs to store every variable name and look it up fast. A BST keyed on the variable name gives O(log n) lookups and also supports printing all variables in alphabetical order (via in-order traversal).
BST Insert and Remove
A BST is only useful if you can add and remove values while keeping it sorted. Insert is straightforward — follow the search path until you find an empty slot. Remove is trickier, because pulling a node out must not break the left < node < right property for every other node.
Insert: walk until you find the gap
Inserting a value is like searching for it, except instead of returning “not found” when you fall off the edge, you create a new node there.
Before inserting 5
flowchart TD
A["8"] --> B["3"]
A --> C["10"]
B --> D["1"]
B --> E["6"]
E --> F["4"]
E --> G["7"]
After inserting 5
The path: 5 < 8 → go left. 5 > 3 → go right. 5 < 6 → go left. 5 > 4 → go right. Empty slot — place 5 here.
flowchart TD
A["8"] --> B["3"]
A --> C["10"]
B --> D["1"]
B --> E["6"]
E --> F["4"]
E --> G["7"]
F -->|"right"| NEW["5 ← new node"]
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def insert(node, value):
"""
Returns the root of the (possibly new) subtree.
If node is None we've found the empty slot — create the node here.
"""
if node is None:
return TreeNode(value)
if value < node.value:
node.left = insert(node.left, value)
elif value > node.value:
node.right = insert(node.right, value)
# value == node.value: duplicate — do nothing (BSTs store unique values)
return node
def inorder(node):
"""In-order traversal of a BST returns values in sorted order."""
if node is None:
return []
return inorder(node.left) + [node.value] + inorder(node.right)
# Build a BST by inserting values one at a time
root = None
for val in [8, 3, 10, 1, 6, 4, 7]:
root = insert(root, val)
print("Before inserting 5:", inorder(root))
root = insert(root, 5)
print("After inserting 5: ", inorder(root)) # 5 appears in sorted position
Remove: the three cases
Removing a node is more involved because the node might have children that need to stay in the tree. There are exactly three situations:
Case 1 — The node is a leaf (no children)
Just delete it. Nothing else needs to move.
flowchart TD
A["8"] --> B["3"]
A --> C["10"]
B --> D["1 ← remove this"]
B --> E["6"]
After removing 1, node 3 simply has no left child.
Case 2 — The node has one child
Replace the node with its only child. The subtree slides up one level.
flowchart TD
A["8"] --> B["3"]
A --> C["10 ← remove this (has one child: 14)"]
C --> D["14"]
After removing 10, node 8’s right child becomes 14.
Case 3 — The node has two children
This is the tricky case. We cannot simply delete the node because it has two subtrees that need a parent. The solution: replace the node’s value with its in-order successor (the smallest value in its right subtree), then delete that successor from the right subtree.
Why the in-order successor? It is the smallest value that is still larger than every value in the left subtree — so it is the perfect replacement to maintain the BST property.
flowchart TD
A["8"] --> B["3 ← remove this"]
A --> C["10"]
B --> D["1"]
B --> E["6"]
E --> F["4"]
E --> G["7"]
The in-order successor of 3 is 4 (smallest value in 3’s right subtree). Replace 3 with 4, then remove 4 from its original location:
flowchart TD
A["8"] --> B["4 ← replaced 3 with its successor"]
A --> C["10"]
B --> D["1"]
B --> E["6"]
E --> EMPTY["(4 removed from here)"]
E --> G["7"]
Full implementation
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def insert(node, value):
if node is None:
return TreeNode(value)
if value < node.value:
node.left = insert(node.left, value)
elif value > node.value:
node.right = insert(node.right, value)
return node
def find_min(node):
"""The in-order successor is the leftmost node in a subtree."""
while node.left is not None:
node = node.left
return node
def remove(node, value):
"""
Returns the root of the subtree after removing the value.
The BST property is preserved for every remaining node.
"""
if node is None:
return None # value not in tree — nothing to do
if value < node.value:
node.left = remove(node.left, value) # it's somewhere in the left subtree
elif value > node.value:
node.right = remove(node.right, value) # it's somewhere in the right subtree
else:
# We found the node to delete.
# Case 1: leaf node
if node.left is None and node.right is None:
return None
# Case 2: one child — return the child, skipping this node
if node.left is None:
return node.right
if node.right is None:
return node.left
# Case 3: two children — replace with in-order successor
successor = find_min(node.right)
node.value = successor.value # overwrite this node's value
node.right = remove(node.right, successor.value) # delete the successor
return node
def inorder(node):
if node is None:
return []
return inorder(node.left) + [node.value] + inorder(node.right)
# Build the tree
root = None
for val in [8, 3, 10, 1, 6, 4, 7, 14]:
root = insert(root, val)
print("Initial tree (sorted):", inorder(root))
# Case 1: remove a leaf
root = remove(root, 1)
print("After removing 1 (leaf): ", inorder(root))
# Case 2: remove a node with one child
root = remove(root, 10)
print("After removing 10 (one child): ", inorder(root))
# Case 3: remove a node with two children
root = remove(root, 3)
print("After removing 3 (two children): ", inorder(root))
The tree stays sorted after every operation
Notice that in-order traversal always produces a sorted list, even after multiple insertions and removals. This is the BST invariant: every operation either preserves or explicitly restores the left < node < right property at every node.
Real-world: dynamic sorted sets and ordered maps
Python’s sortedcontainers.SortedList uses a variant of this technique to give you a list that stays sorted automatically as you add and remove items — useful when you need both fast lookup and sorted iteration.
Java’s TreeMap and TreeSet are backed by a self-balancing BST (Red-Black tree). Every put() is an insert, every remove() is our three-case deletion, and firstKey() / lastKey() exploit the fact that in-order traversal is sorted.
Database ordered indexes use B-Trees (a generalisation of BSTs with many children per node), and the same three-case deletion logic applies to keeping them consistent.
Depth-First Search
Go as deep as possible before backtracking — like exploring a cave system.
Imagine you are in a cave with branching tunnels. Depth-first search means: always take the next available tunnel, go to the very end, then backtrack to the last junction and try the next tunnel. You explore all the way down one path before trying a different branch. On a tree this is natural to implement with recursion — the call stack is the backtracking.
There are three classic DFS orders for binary trees, each with distinct uses.
The three DFS orders
The difference between the three orders is only when you visit the current node relative to its children:
| Order | Rule | Mnemonic |
|---|---|---|
| Pre-order | Visit node, then left subtree, then right subtree | Pre = current node comes first |
| In-order | Visit left subtree, then node, then right subtree | Node goes in the middle |
| Post-order | Visit left subtree, then right subtree, then node | Post = current node comes last |
Traversal sequence on the same tree
flowchart TD
A["1"] --> B["2"]
A --> C["3"]
B --> D["4"]
B --> E["5"]
C --> F["6"]
C --> G["7"]
Let’s trace each order by hand.
Pre-order (node → left → right):
Visit 1, go left → visit 2, go left → visit 4 (leaf, back up), go right → visit 5 (leaf, back up, back up), go right → visit 3, go left → visit 6 (leaf, back up), go right → visit 7 (leaf).
Sequence: 1, 2, 4, 5, 3, 6, 7
In-order (left → node → right):
Go all the way left → visit 4, back up → visit 2, go right → visit 5, back up to root → visit 1, go right → go left → visit 6, back up → visit 3, go right → visit 7.
Sequence: 4, 2, 5, 1, 6, 3, 7
Post-order (left → right → node):
Go all the way left → visit 4, go to sibling → visit 5, back up → visit 2, go to right subtree → visit 6, visit 7, back up → visit 3, back up → visit 1.
Sequence: 4, 5, 2, 6, 7, 3, 1
Full implementation of all three
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
# Build the tree from the diagram above
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
def preorder(node):
"""node → left → right"""
if node is None:
return []
return [node.value] + preorder(node.left) + preorder(node.right)
def inorder(node):
"""left → node → right"""
if node is None:
return []
return inorder(node.left) + [node.value] + inorder(node.right)
def postorder(node):
"""left → right → node"""
if node is None:
return []
return postorder(node.left) + postorder(node.right) + [node.value]
print("Pre-order: ", preorder(root)) # [1, 2, 4, 5, 3, 6, 7]
print("In-order: ", inorder(root)) # [4, 2, 5, 1, 6, 3, 7]
print("Post-order:", postorder(root)) # [4, 5, 2, 6, 7, 3, 1]
In-order gives sorted output for a BST
This is one of the most important properties you will use in practice. When you run in-order traversal on a Binary Search Tree, the values come out in ascending sorted order — because in-order visits left (smaller values) before the node before right (larger values).
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def insert(node, value):
if node is None:
return TreeNode(value)
if value < node.value:
node.left = insert(node.left, value)
elif value > node.value:
node.right = insert(node.right, value)
return node
def inorder(node):
if node is None:
return []
return inorder(node.left) + [node.value] + inorder(node.right)
# Insert values in random order
root = None
for val in [5, 2, 8, 1, 4, 7, 9, 3, 6]:
root = insert(root, val)
print("Inserted in random order:", [5, 2, 8, 1, 4, 7, 9, 3, 6])
print("In-order traversal: ", inorder(root)) # always sorted!
Iterative DFS with an explicit stack
The recursive versions above implicitly use Python’s call stack. For very deep trees you can hit Python’s recursion limit. An explicit stack avoids this and also makes the backtracking mechanism visible:
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def preorder_iterative(root):
"""
Pre-order using an explicit stack.
Push right child first so left child is processed first (LIFO).
"""
if root is None:
return []
stack = [root]
result = []
while stack:
node = stack.pop()
result.append(node.value)
# Push right first — it will be processed after left (stack is LIFO)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return result
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
print("Pre-order (iterative):", preorder_iterative(root)) # [1, 2, 4, 5, 3, 6, 7]
Real-world uses of each traversal order
Pre-order — copying a tree To clone a tree you must create each node before its children. Pre-order does exactly that: process the node first, then recurse into children.
Post-order — deleting a tree / computing folder sizes When your OS calculates the total size of a folder, it must know the sizes of all subfolders before it can sum them up. Post-order processes children before the parent — perfect. Same logic applies to deleting a tree: delete children before parent so you do not lose the pointers.
In-order — sorted output from a BST As shown above, in-order on a BST gives sorted output. Database engines use this when scanning an indexed range query.
Expression evaluation Post-order traversal of an expression tree evaluates it bottom-up: compute child subexpressions first, then combine at the operator node. We demonstrated this fully in the Binary Tree chapter.
Breadth-First Search
Explore level by level — like water flooding a maze.
Pour water into the root of a tree and it spreads outward in rings: first the root, then all nodes at depth 1, then all at depth 2, and so on. That is Breadth-First Search. Instead of diving deep down one branch like DFS, BFS finishes each entire level before moving to the next. This property makes it the natural choice whenever you care about distance from the root.
BFS visits nodes level by level
flowchart TD
A["1 (visited 1st)"]
B["2 (visited 2nd)"]
C["3 (visited 3rd)"]
D["4 (visited 4th)"]
E["5 (visited 5th)"]
F["6 (visited 6th)"]
G["7 (visited 7th)"]
A --> B
A --> C
B --> D
B --> E
C --> F
C --> G
Level 0: 1
Level 1: 2, 3
Level 2: 4, 5, 6, 7
BFS visit order: 1, 2, 3, 4, 5, 6, 7
The queue is the secret
DFS uses a stack (either the call stack via recursion, or an explicit one). BFS uses a queue — a First-In-First-Out (FIFO) structure. This is why BFS processes nodes in the order they were discovered, which is exactly level order.
The algorithm:
- Enqueue the root.
- Dequeue the front node, record it, enqueue its children.
- Repeat until the queue is empty.
from collections import deque
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def bfs(root):
"""
Level-order traversal using a queue.
Returns all values in BFS (level-by-level) order.
"""
if root is None:
return []
queue = deque([root])
result = []
while queue:
node = queue.popleft() # FIFO: take from the front
result.append(node.value)
if node.left:
queue.append(node.left) # enqueue left child
if node.right:
queue.append(node.right) # enqueue right child
return result
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
print("BFS order:", bfs(root)) # [1, 2, 3, 4, 5, 6, 7]
BFS with level tracking
Sometimes you need to know which level each node is on — for example, to find the minimum depth, or to print the tree level by level. Wrap each group of nodes in an inner loop:
from collections import deque
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def bfs_by_level(root):
"""
Returns a list of lists, one inner list per level.
e.g. [[1], [2, 3], [4, 5, 6, 7]]
"""
if root is None:
return []
queue = deque([root])
levels = []
while queue:
level_size = len(queue) # snapshot: how many nodes are on this level
level_values = []
for _ in range(level_size):
node = queue.popleft()
level_values.append(node.value)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
levels.append(level_values)
return levels
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
for level_num, level in enumerate(bfs_by_level(root)):
print(f"Level {level_num}: {level}")
DFS vs BFS — when to use each
| Question | Best choice | Why |
|---|---|---|
| Does a path from root to leaf exist? | Either | Both find all reachable nodes |
| What is the shortest path from root to a target? | BFS | BFS finds the nearest match first |
| Is there any path at all in a deep tree? | DFS | Less memory for very deep, narrow trees |
| Visit nodes in level order / by distance | BFS | That is exactly what BFS does |
| Copy, delete, or evaluate a tree | DFS | Recursive structure fits naturally |
| Find all ancestors of a node | DFS | Follow the call stack back up |
The key insight: BFS always finds the shortest path first because it expands nodes in distance order. DFS may find a path quickly but it might not be the shortest.
from collections import deque
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def find_level_bfs(root, target):
"""Return the level (depth) of the first node with the target value."""
if root is None:
return -1
queue = deque([(root, 0)]) # store (node, depth) pairs
while queue:
node, depth = queue.popleft()
if node.value == target:
return depth
if node.left:
queue.append((node.left, depth + 1))
if node.right:
queue.append((node.right, depth + 1))
return -1 # not found
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
print("Level of node 1:", find_level_bfs(root, 1)) # 0
print("Level of node 3:", find_level_bfs(root, 3)) # 1
print("Level of node 6:", find_level_bfs(root, 6)) # 2
print("Level of node 9:", find_level_bfs(root, 9)) # -1
Real-world uses
Finding the shortest path — GPS navigation, network routing, and game pathfinding all rely on BFS (or Dijkstra’s algorithm, which is BFS with weighted edges) to find the fewest-hops route between two points.
Web crawlers — A crawler starts at a seed URL and discovers links level by level: first all links on the seed page, then all links on those pages, and so on. BFS prevents it from going infinitely deep into one site before visiting others.
Social network “degrees of separation” — “How many friends-of-friends does it take to reach this person?” BFS from your profile, level by level, counts degrees exactly.
Level-order tree serialisation — When you need to store a tree as a flat array (the way binary heaps work) or send it over a network, you write nodes in BFS order. The position of each node in the array encodes its parent-child relationships without needing pointers.
BST Sets and Maps
Python’s set and dict operations — under the hood, this is often a tree.
When you call my_set.add(x) or my_dict[key] = value in Python, the built-in types use hash tables internally. Hash tables are fast, but they cannot tell you the smallest key, the largest key, or all keys in a given range — they have no concept of order. A BST-backed set or map is slightly slower per operation, but it buys you something invaluable: everything stays sorted, always.
A BST as a sorted set
A set stores unique values and supports three core operations: insert, contains, and remove. A BST handles all three in O(log n) and also gives you sorted iteration for free.
flowchart TD
Root["8 (root)"]
Root --> L3["3"]
Root --> R10["10"]
L3 --> LL1["1"]
L3 --> LR6["6"]
LR6 --> LRL4["4"]
LR6 --> LRR7["7"]
R10 --> RR14["14"]
In-order traversal reads this tree as: 1, 3, 4, 6, 7, 8, 10, 14 — a sorted set.
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
class BSTSet:
"""A sorted set backed by a Binary Search Tree."""
def __init__(self):
self._root = None
def add(self, value):
self._root = self._insert(self._root, value)
def contains(self, value):
return self._search(self._root, value)
def remove(self, value):
self._root = self._delete(self._root, value)
def to_sorted_list(self):
"""In-order traversal — always returns values in ascending order."""
result = []
self._inorder(self._root, result)
return result
def minimum(self):
node = self._root
while node and node.left:
node = node.left
return node.value if node else None
def maximum(self):
node = self._root
while node and node.right:
node = node.right
return node.value if node else None
def range_query(self, low, high):
"""Return all values where low <= value <= high, in sorted order."""
result = []
self._range(self._root, low, high, result)
return result
# ---- internal helpers ----
def _insert(self, node, value):
if node is None:
return TreeNode(value)
if value < node.value:
node.left = self._insert(node.left, value)
elif value > node.value:
node.right = self._insert(node.right, value)
return node
def _search(self, node, value):
if node is None:
return False
if value == node.value:
return True
if value < node.value:
return self._search(node.left, value)
return self._search(node.right, value)
def _find_min(self, node):
while node.left:
node = node.left
return node
def _delete(self, node, value):
if node is None:
return None
if value < node.value:
node.left = self._delete(node.left, value)
elif value > node.value:
node.right = self._delete(node.right, value)
else:
if node.left is None:
return node.right
if node.right is None:
return node.left
successor = self._find_min(node.right)
node.value = successor.value
node.right = self._delete(node.right, successor.value)
return node
def _inorder(self, node, result):
if node:
self._inorder(node.left, result)
result.append(node.value)
self._inorder(node.right, result)
def _range(self, node, low, high, result):
if node is None:
return
if node.value > low: # there might be values >= low in left subtree
self._range(node.left, low, high, result)
if low <= node.value <= high:
result.append(node.value)
if node.value < high: # there might be values <= high in right subtree
self._range(node.right, low, high, result)
s = BSTSet()
for val in [8, 3, 10, 1, 6, 4, 7, 14]:
s.add(val)
print("Sorted contents: ", s.to_sorted_list())
print("Contains 6: ", s.contains(6))
print("Contains 5: ", s.contains(5))
print("Minimum: ", s.minimum())
print("Maximum: ", s.maximum())
print("Range [4, 8]: ", s.range_query(4, 8))
s.remove(6)
print("After removing 6: ", s.to_sorted_list())
A BST as an ordered map (dictionary)
A map stores key-value pairs and supports put(key, value) and get(key). The BST is ordered by key, and each node carries an associated value as cargo.
class MapNode:
def __init__(self, key, value):
self.key = key
self.value = value
self.left = None
self.right = None
class BSTMap:
"""
An ordered map (dictionary) backed by a BST.
Keys are kept sorted, so you get sorted iteration and range queries
that a plain Python dict cannot provide.
"""
def __init__(self):
self._root = None
def put(self, key, value):
"""Insert or update a key-value pair."""
self._root = self._insert(self._root, key, value)
def get(self, key, default=None):
"""Return the value for key, or default if not found."""
node = self._search(self._root, key)
return node.value if node else default
def keys_sorted(self):
"""Return all keys in ascending order."""
result = []
self._inorder(self._root, result)
return result
def items_in_range(self, low_key, high_key):
"""Return (key, value) pairs where low_key <= key <= high_key, sorted."""
result = []
self._range(self._root, low_key, high_key, result)
return result
# ---- internal helpers ----
def _insert(self, node, key, value):
if node is None:
return MapNode(key, value)
if key < node.key:
node.left = self._insert(node.left, key, value)
elif key > node.key:
node.right = self._insert(node.right, key, value)
else:
node.value = value # update existing key
return node
def _search(self, node, key):
if node is None:
return None
if key == node.key:
return node
if key < node.key:
return self._search(node.left, key)
return self._search(node.right, key)
def _inorder(self, node, result):
if node:
self._inorder(node.left, result)
result.append(node.key)
self._inorder(node.right, result)
def _range(self, node, low, high, result):
if node is None:
return
if node.key > low:
self._range(node.left, low, high, result)
if low <= node.key <= high:
result.append((node.key, node.value))
if node.key < high:
self._range(node.right, low, high, result)
# Store word frequencies
freq_map = BSTMap()
words = ["banana", "apple", "cherry", "apple", "banana", "banana", "date"]
for word in words:
count = freq_map.get(word, 0)
freq_map.put(word, count + 1)
print("Keys in alphabetical order:", freq_map.keys_sorted())
print("Frequency of 'banana': ", freq_map.get("banana"))
print("Frequency of 'grape': ", freq_map.get("grape", 0))
print("Words a–c: ", freq_map.items_in_range("a", "c"))
Hash table vs BST-backed map — a direct comparison
# Python's built-in dict is hash-based — fast, but unordered
plain_dict = {"banana": 3, "apple": 2, "cherry": 1, "date": 1}
print("dict keys (arbitrary order):", list(plain_dict.keys()))
# To get sorted keys from a plain dict you must sort explicitly — O(n log n)
print("dict sorted keys: ", sorted(plain_dict.keys()))
# A BST map keeps keys sorted automatically — each put() maintains order
# (Using the BSTMap from the cell above — paste both cells together to run)
class MapNode:
def __init__(self, key, value):
self.key = key
self.value = value
self.left = None
self.right = None
class BSTMap:
def __init__(self):
self._root = None
def put(self, key, value):
self._root = self._ins(self._root, key, value)
def _ins(self, node, key, value):
if node is None:
return MapNode(key, value)
if key < node.key:
node.left = self._ins(node.left, key, value)
elif key > node.key:
node.right = self._ins(node.right, key, value)
else:
node.value = value
return node
def keys_sorted(self):
r = []
self._io(self._root, r)
return r
def _io(self, node, r):
if node:
self._io(node.left, r)
r.append(node.key)
self._io(node.right, r)
bst_map = BSTMap()
for k, v in plain_dict.items():
bst_map.put(k, v)
print("BSTMap keys (always sorted):", bst_map.keys_sorted())
| Feature | Hash dict (dict) | BST map (BSTMap) |
|---|---|---|
| Average lookup | O(1) | O(log n) |
| Sorted iteration | Not supported | O(n) — free via in-order |
| Range query | Not supported | O(log n + k) |
| Min / Max key | Not supported | O(log n) |
| Worst-case lookup | O(n) — hash collision | O(n) — unbalanced tree |
Real-world implementations
Python’s sortedcontainers.SortedList / SortedDict — A third-party library that gives you a sorted list and sorted dictionary with BST-like ordered operations. Widely used when you need range queries or sorted iteration alongside fast lookups.
Java’s TreeMap and TreeSet — Backed by a Red-Black tree (a self-balancing BST), these are the standard Java ordered-map and ordered-set. firstKey(), lastKey(), subMap(from, to) are all powered by the BST structure.
Database ordered indexes — A database CREATE INDEX ON orders(order_date) builds a B-Tree (a disk-friendly generalisation of a BST). Queries like WHERE order_date BETWEEN '2024-01-01' AND '2024-06-30' use the tree to jump directly to the start of the range instead of scanning the whole table.
C++ std::map and std::set — Also backed by Red-Black trees, giving O(log n) insert, lookup, and delete with full ordered iteration.
Trie
Every time you type the first two letters of a search query and a dropdown of suggestions appears instantly — that is a Trie at work. Not a binary tree, not a hash map, but a structure purpose-built for strings: a tree where every path from root to a marked node spells out a complete word.
The name comes from the word “retrieval”, though it is universally pronounced “try” to avoid confusion with “tree”. By the end of this chapter you will have built one from scratch, complete with autocomplete.
The Core Idea
A Trie stores strings character by character. Each node represents a single character, and each edge represents “this character follows that one”. Words that share a prefix share nodes.
flowchart TD
ROOT(( root ))
ROOT --> C([c])
ROOT --> B([b])
C --> CA([a])
CA --> CAT([t *])
CA --> CAR([r *])
CAR --> CARD([d *])
CAR --> CARE([e *])
B --> BA([a])
BA --> BAT([t *])
style ROOT fill:#e8e8e8,stroke:#999
style CAT fill:#c8f7c5,stroke:#27ae60
style CAR fill:#c8f7c5,stroke:#27ae60
style CARD fill:#c8f7c5,stroke:#27ae60
style CARE fill:#c8f7c5,stroke:#27ae60
style BAT fill:#c8f7c5,stroke:#27ae60
The * marks nodes where a complete word ends. Notice that “cat”, “car”, “card”, and “care” all share the path root → c → a. This prefix sharing is what makes Tries efficient — inserting a thousand words that all start with “pre” costs nothing extra for those shared characters.
Why Not Just Use a Hash Map?
A dict can tell you in O(1) whether a word exists, but it cannot:
- List all words with a given prefix efficiently
- Enumerate words in alphabetical order without sorting
- Count how many stored words share a prefix
Tries do all three naturally.
Implementation
The Node
Each node needs:
- A map from character → child node
- A boolean marking whether this node is the end of a word
class TrieNode:
def __init__(self):
self.children = {} # char -> TrieNode
self.is_end = False # True if a word ends here
# Quick sanity check
node = TrieNode()
node.children['a'] = TrieNode()
node.children['a'].is_end = True
print("Node has child 'a':", 'a' in node.children)
print("'a' is end of word:", node.children['a'].is_end)
Building the Full Trie
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
# ------------------------------------------------------------------ insert
def insert(self, word: str) -> None:
"""Add a word to the Trie. O(m) where m = len(word)."""
current = self.root
for char in word:
if char not in current.children:
current.children[char] = TrieNode()
current = current.children[char]
current.is_end = True
# ------------------------------------------------------------------ search
def search(self, word: str) -> bool:
"""Return True if word is in the Trie (exact match). O(m)."""
current = self.root
for char in word:
if char not in current.children:
return False
current = current.children[char]
return current.is_end # must be marked as a complete word
# --------------------------------------------------------------- starts_with
def starts_with(self, prefix: str) -> bool:
"""Return True if any word in the Trie starts with prefix. O(m)."""
current = self.root
for char in prefix:
if char not in current.children:
return False
current = current.children[char]
return True # reached the end of prefix — at least one word continues here
# ------------------------------------------------------------ autocomplete
def autocomplete(self, prefix: str) -> list:
"""Return all words in the Trie that start with prefix. O(m + k)
where k = total characters across all matching words."""
# Step 1: walk to the end of the prefix
current = self.root
for char in prefix:
if char not in current.children:
return [] # prefix not found at all
current = current.children[char]
# Step 2: DFS from that node to collect all complete words
results = []
self._dfs(current, prefix, results)
return sorted(results)
def _dfs(self, node: TrieNode, current_word: str, results: list) -> None:
if node.is_end:
results.append(current_word)
for char, child in node.children.items():
self._dfs(child, current_word + char, results)
# ============================================================= demonstration
trie = Trie()
words = ["cat", "car", "card", "care", "bat", "ball", "band", "ban"]
for w in words:
trie.insert(w)
print("=== Exact Search ===")
print(f"search('car') : {trie.search('car')}") # True
print(f"search('ca') : {trie.search('ca')}") # False (not a full word)
print(f"search('card') : {trie.search('card')}") # True
print(f"search('cart') : {trie.search('cart')}") # False
print()
print("=== Prefix Check ===")
print(f"starts_with('ca') : {trie.starts_with('ca')}") # True
print(f"starts_with('car') : {trie.starts_with('car')}") # True
print(f"starts_with('xyz') : {trie.starts_with('xyz')}") # False
print()
print("=== Autocomplete ===")
print(f"autocomplete('ca') : {trie.autocomplete('ca')}") # car, card, care, cat
print(f"autocomplete('ba') : {trie.autocomplete('ba')}") # ball, ban, band, bat
print(f"autocomplete('car') : {trie.autocomplete('car')}") # car, card, care
print(f"autocomplete('xyz') : {trie.autocomplete('xyz')}") # []
Why O(m) Search?
The key insight: search time depends only on the length of the query, not the size of the dictionary.
Searching for “care” in a Trie with 1,000,000 words takes exactly 4 steps — one per character. A hash map lookup is O(m) too (it must hash the string), but cannot do prefix queries. A sorted list can do prefix queries but needs O(log n) steps to binary search plus O(m) to compare.
flowchart LR
A[Query: care] --> B[Visit 'c' node]
B --> C[Visit 'a' node]
C --> D[Visit 'r' node]
D --> E[Visit 'e' node]
E --> F{is_end = True?}
F -- Yes --> G[Found!]
F -- No --> H[Not a complete word]
style G fill:#c8f7c5,stroke:#27ae60
style H fill:#ffd5d5,stroke:#e74c3c
| Operation | Trie | Hash Map | Sorted Array |
|---|---|---|---|
| Insert | O(m) | O(m) | O(n) |
| Search | O(m) | O(m) | O(m log n) |
| Prefix check | O(m) | O(n·m) | O(m log n) |
| Autocomplete | O(m + k) | O(n·m) | O(m log n + k) |
Where m = word length, n = dictionary size, k = total characters in results.
Extended Example: Word Count in a Trie
Sometimes you want to count how many times each word was inserted — useful for building a word frequency map.
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
self.count = 0 # how many times this word was inserted
class FrequencyTrie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
current = self.root
for char in word:
if char not in current.children:
current.children[char] = TrieNode()
current = current.children[char]
current.is_end = True
current.count += 1
def frequency(self, word: str) -> int:
current = self.root
for char in word:
if char not in current.children:
return 0
current = current.children[char]
return current.count if current.is_end else 0
def top_words_with_prefix(self, prefix: str, n: int) -> list:
"""Return the n most frequent words starting with prefix."""
current = self.root
for char in prefix:
if char not in current.children:
return []
current = current.children[char]
results = []
self._collect(current, prefix, results)
results.sort(key=lambda x: -x[1])
return results[:n]
def _collect(self, node, word, results):
if node.is_end:
results.append((word, node.count))
for char, child in node.children.items():
self._collect(child, word + char, results)
# Simulate a search log
search_log = [
"python", "python", "python", "pytorch", "pytorch",
"java", "javascript", "javascript", "javascript", "javascript",
"java", "typescript"
]
trie = FrequencyTrie()
for term in search_log:
trie.insert(term)
print("=== Search Frequencies ===")
for word in ["python", "pytorch", "java", "javascript", "typescript"]:
print(f" {word:12s}: {trie.frequency(word)} searches")
print()
print("=== Top 3 searches starting with 'java' ===")
for word, freq in trie.top_words_with_prefix("java", 3):
print(f" {word:15s}: {freq} searches")
print()
print("=== Top 2 searches starting with 'py' ===")
for word, freq in trie.top_words_with_prefix("py", 2):
print(f" {word:15s}: {freq} searches")
Complexity Summary
| Operation | Time | Space |
|---|---|---|
| Insert word of length m | O(m) | O(m) worst case (all new nodes) |
| Search word of length m | O(m) | O(1) |
| Prefix check of length m | O(m) | O(1) |
| Autocomplete prefix m, k result chars | O(m + k) | O(k) for output |
| Total space for n words, avg length m | O(n · m) | — |
In practice, space is much better than O(n · m) because shared prefixes share nodes. A dictionary of 100,000 English words fits comfortably.
Real-World Applications
- Search autocomplete — Google, DuckDuckGo, and every IDE (VS Code, IntelliJ) use Trie-like structures to surface completions as you type. The
autocompletefunction above is literally what they do. - Spell checkers — walk the Trie; if you fall off at any character, the word is misspelled. Edit distance on Tries powers “Did you mean?” suggestions.
- IP routing tables — routers store IP prefixes in a Patricia Trie (compressed Trie) and do longest-prefix-match in O(32) steps for IPv4, regardless of routing table size.
- Word games — Boggle and Scrabble AI solvers use Tries to prune the search space: if no word starts with the current letter sequence, stop exploring that path immediately.
- DNA sequence matching — bioinformatics tools index genomic sequences in Tries (or suffix trees, a related structure) for pattern matching.
Trie Problems
Practice problems for prefix trees, wildcard search, and dictionary-driven backtracking.
Implement Trie (Prefix Tree)
Difficulty: Medium Source: NeetCode
Problem
A trie (pronounced as “try”) or prefix tree is a tree data structure used to efficiently store and retrieve keys in a dataset of strings. Implement the
Trieclass:
Trie()Initializes the trie object.void insert(String word)Inserts the stringwordinto the trie.boolean search(String word)Returnstrueif the stringwordis in the trie (i.e., was inserted before), andfalseotherwise.boolean startsWith(String prefix)Returnstrueif there is a previously inserted string that has the prefixprefix, andfalseotherwise.Example 1: Input:
["Trie","insert","search","search","startsWith","insert","search"]with args[[],["apple"],["apple"],["app"],["app"],["app"],["app"]]Output:[null,null,true,false,true,null,true]Constraints:
1 <= word.length, prefix.length <= 2000wordandprefixconsist only of lowercase English letters- At most
3 * 10^4calls total toinsert,search, andstartsWith
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — used as the children mapping at each trie node
- Trees — a trie is a tree where each node represents a character
- String traversal — processing a string character by character
1. Brute Force
Intuition
The simplest approach is to just store every inserted word in a Python set. Then search is an O(1) set lookup, and startsWith scans every stored word to check if any begins with the given prefix. This is easy to implement but startsWith can get slow as the word list grows.
Algorithm
- Keep a
setof inserted words. insert(word): addwordto the set.search(word): returnword in self.words.startsWith(prefix): iterate through all stored words and returnTrueif any starts withprefix.
Solution
class Trie:
def __init__(self):
self.words = set()
def insert(self, word: str) -> None:
self.words.add(word)
def search(self, word: str) -> bool:
return word in self.words
def startsWith(self, prefix: str) -> bool:
return any(w.startswith(prefix) for w in self.words)
trie = Trie()
trie.insert("apple")
print(trie.search("apple")) # True
print(trie.search("app")) # False
print(trie.startsWith("app")) # True
trie.insert("app")
print(trie.search("app")) # True
Complexity
- Time:
O(n)for insert/search,O(W * n)for startsWith where W = number of words - Space:
O(W * L)where L = average word length
2. Trie with TrieNode
Intuition
A real trie stores characters along edges of a tree. Each node holds a dictionary of its children (one entry per character) and a boolean flag is_end that marks whether a complete word ends at that node. To insert, walk the tree creating nodes as needed. To search or check a prefix, walk the tree following existing nodes — the only difference between search and startsWith is whether you also check is_end at the final node.
Algorithm
- Define
TrieNodewithchildren = {}andis_end = False. insert(word): start at root, for each char create a child node if missing, then move to it. Markis_end = Trueon the last node.search(word): walk the trie for each char; if a char is missing returnFalse. After the last char, returnnode.is_end.startsWith(prefix): same walk but returnTrueregardless ofis_endif all chars were found.
Solution
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def search(self, word: str) -> bool:
node = self.root
for ch in word:
if ch not in node.children:
return False
node = node.children[ch]
return node.is_end
def startsWith(self, prefix: str) -> bool:
node = self.root
for ch in prefix:
if ch not in node.children:
return False
node = node.children[ch]
return True
trie = Trie()
trie.insert("apple")
print(trie.search("apple")) # True
print(trie.search("app")) # False
print(trie.startsWith("app")) # True
trie.insert("app")
print(trie.search("app")) # True
print(trie.startsWith("xyz")) # False
Complexity
- Time:
O(L)per operation where L = length of word/prefix - Space:
O(N * L)where N = number of inserted words
Common Pitfalls
Forgetting is_end on search. Walking to the last node of “app” succeeds even if only “apple” was inserted — you must check is_end to distinguish a prefix from a full stored word.
Mutating children between nodes. Each TrieNode must have its own children dict. If you write children = {} as a class variable instead of in __init__, all nodes share the same dict and everything breaks.
startsWith vs search confusion. startsWith returns True as long as the walk completes without a missing character — it does not care about is_end at all.
Design Add and Search Words Data Structure
Difficulty: Medium Source: NeetCode
Problem
Design a data structure that supports adding new words and searching for whether a string matches any previously added string.
Implement the
WordDictionaryclass:
WordDictionary()Initializes the object.void addWord(word)Addswordto the data structure.bool search(word)Returnstrueif there is any string in the data structure that matcheswordorfalseotherwise.wordmay contain dots'.'where dots can be matched with any letter.Example 1: Input:
["WordDictionary","addWord","addWord","addWord","search","search","search","search"]with args[[],["bad"],["dad"],["mad"],["pad"],["bad"],[".ad"],["b.."]]Output:[null,null,null,null,false,true,true,true]Constraints:
1 <= word.length <= 25wordinaddWordconsists of lowercase English letterswordinsearchconsists of'.'or lowercase English letters- At most
10^4calls toaddWordandsearch
Prerequisites
Before attempting this problem, you should be comfortable with:
- Trie (Prefix Tree) — the core data structure used here; review LeetCode 208 first
- DFS / Recursion — needed to handle the wildcard
'.'by branching into all children - Backtracking — the DFS explores and backtracks when a branch fails
1. Brute Force
Intuition
Store every added word in a list. For search, iterate through all stored words and check each one character by character, treating '.' as a match for anything. This is simple but search is O(W * L) where W is the number of stored words.
Algorithm
- Keep a list of added words.
addWord(word): append to the list.search(word): for each stored word of matching length, compare char by char —'.'always matches, letters must match exactly.
Solution
class WordDictionary:
def __init__(self):
self.words = []
def addWord(self, word: str) -> None:
self.words.append(word)
def search(self, word: str) -> bool:
for stored in self.words:
if len(stored) != len(word):
continue
match = True
for a, b in zip(stored, word):
if b != '.' and a != b:
match = False
break
if match:
return True
return False
wd = WordDictionary()
wd.addWord("bad")
wd.addWord("dad")
wd.addWord("mad")
print(wd.search("pad")) # False
print(wd.search("bad")) # True
print(wd.search(".ad")) # True
print(wd.search("b..")) # True
Complexity
- Time:
O(W * L)per search where W = number of words, L = word length - Space:
O(W * L)
2. Trie with DFS for Wildcards
Intuition
Build a standard trie for addWord. For search, walk the trie character by character. When the character is a normal letter, follow the single matching child (or return False if it doesn’t exist). When it’s a '.', try every child and recursively search the rest of the word from each one — if any branch succeeds, return True. This is effectively DFS through the trie, branching only at wildcard positions.
Algorithm
- Build a trie with
TrieNode(children={}, is_end=False). addWord(word): standard trie insert.search(word): define a recursive helperdfs(node, index):- If
index == len(word), returnnode.is_end. - Let
ch = word[index]. - If
ch == '.': calldfs(child, index+1)for every child; returnTrueif any succeeds. - Otherwise: if
chnot innode.childrenreturnFalse; else recurse onnode.children[ch].
- If
Solution
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class WordDictionary:
def __init__(self):
self.root = TrieNode()
def addWord(self, word: str) -> None:
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def search(self, word: str) -> bool:
def dfs(node, i):
if i == len(word):
return node.is_end
ch = word[i]
if ch == '.':
for child in node.children.values():
if dfs(child, i + 1):
return True
return False
else:
if ch not in node.children:
return False
return dfs(node.children[ch], i + 1)
return dfs(self.root, 0)
wd = WordDictionary()
wd.addWord("bad")
wd.addWord("dad")
wd.addWord("mad")
print(wd.search("pad")) # False
print(wd.search("bad")) # True
print(wd.search(".ad")) # True
print(wd.search("b..")) # True
print(wd.search("...")) # True (matches bad/dad/mad)
print(wd.search("....")) # False (no 4-letter words)
Complexity
- Time:
O(L)for addWord;O(26^L)worst case for search (all dots), but typically much better - Space:
O(N * L)for the trie where N = number of words
Common Pitfalls
Forgetting to return False after the dot loop. After trying all children of a '.' node and none returning True, you must explicitly return False. Falling through without a return gives None, which is falsy but can cause subtle bugs.
Using is_end instead of recursing at the base case. At the base case i == len(word), you’re checking if a complete word ends here — don’t return True blindly; always check node.is_end.
Length mismatch with wildcards. A '.' matches exactly one character, not zero or many. Your recursion naturally enforces this since you always advance i by 1, but it’s easy to confuse '.' with a regex wildcard like .*.
Extra Characters in a String
Difficulty: Medium Source: NeetCode
Problem
You are given a 0-indexed string
sand a dictionary of wordsdictionary. You have to breaksinto one or more non-overlapping substrings such that each substring is present indictionary. There may be some extra characters inswhich are not present in any substring.Return the minimum number of extra characters left over if you break up
soptimally.Example 1: Input:
s = "leetscode",dictionary = ["leet","code","leetcode"]Output:1Example 2: Input:
s = "sayhelloworld",dictionary = ["hello","world"]Output:3Constraints:
1 <= s.length <= 501 <= dictionary.length <= 501 <= dictionary[i].length <= 50sanddictionary[i]consist of lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dynamic Programming (1D) — building up a dp array where each entry depends on previously computed entries
- Trie (Prefix Tree) — used in the optimal solution to efficiently test all substrings starting at position
i - String slicing — checking substrings
s[i:j]against a set or trie
1. Brute Force (DP + Set)
Intuition
Think of it as: starting at index i, we can either skip s[i] (costing 1 extra character) or consume a substring s[i:j] that exists in the dictionary (costing 0 extra characters). We want the minimum cost. Define dp[i] = minimum extra characters in s[i:]. We fill dp from right to left.
For each position i, we check every possible end j from i+1 to n+1. If s[i:j] is in the dictionary, we can transition to dp[j] with no cost. The base case is dp[n] = 0 (empty suffix has zero extras).
Algorithm
- Build a set from
dictionaryfor O(1) lookup. - Create
dparray of sizen+1, initialize to0. Setdp[n] = 0. - For
ifromn-1down to0:- Option 1: skip
s[i], sodp[i] = 1 + dp[i+1]. - Option 2: for each
jfromi+1ton+1, ifs[i:j]in dict, considerdp[j]. - Take the minimum.
- Option 1: skip
- Return
dp[0].
Solution
def minExtraChar(s: str, dictionary: list[str]) -> int:
word_set = set(dictionary)
n = len(s)
dp = [0] * (n + 1)
for i in range(n - 1, -1, -1):
dp[i] = 1 + dp[i + 1] # skip s[i]
for j in range(i + 1, n + 1):
if s[i:j] in word_set:
dp[i] = min(dp[i], dp[j])
return dp[0]
print(minExtraChar("leetscode", ["leet", "code", "leetcode"])) # 1
print(minExtraChar("sayhelloworld", ["hello", "world"])) # 3
print(minExtraChar("abc", ["a", "b"])) # 1
Complexity
- Time:
O(n³)— O(n²) state/transition pairs, each doing O(n) string slice + hash - Space:
O(n + W)for dp array and word set
2. Trie + DP
Intuition
The bottleneck in the brute force is that for each starting index i, we’re re-hashing substrings s[i:j] for every j. A trie lets us walk all possible substrings starting at i in a single sweep — we just follow the trie one character at a time and check is_end at each step. This avoids repeated string slicing and hashing.
Algorithm
- Build a trie from all words in
dictionary. - Fill
dpfrom right to left exactly as before. - For position
i, walk the trie starting atroot, followings[i], s[i+1], ...until a character isn’t found. At each positionjwheretrie_node.is_endisTrue, updatedp[i] = min(dp[i], dp[j]).
Solution
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
def minExtraChar(s: str, dictionary: list[str]) -> int:
# Build trie
root = TrieNode()
for word in dictionary:
node = root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
n = len(s)
dp = [0] * (n + 1)
for i in range(n - 1, -1, -1):
dp[i] = 1 + dp[i + 1] # skip s[i]
node = root
for j in range(i, n):
ch = s[j]
if ch not in node.children:
break
node = node.children[ch]
if node.is_end:
dp[i] = min(dp[i], dp[j + 1])
return dp[0]
print(minExtraChar("leetscode", ["leet", "code", "leetcode"])) # 1
print(minExtraChar("sayhelloworld", ["hello", "world"])) # 3
print(minExtraChar("abc", ["a", "b"])) # 1
print(minExtraChar("abcd", ["ab", "bc", "cd"])) # 0
Complexity
- Time:
O(n² + W * L)— O(n²) for dp transitions, O(W * L) to build the trie - Space:
O(W * L + n)for the trie and dp array
Common Pitfalls
Off-by-one in the dp indices. dp[i] represents the minimum extras in s[i:], so dp[n] = 0 (empty string). When a substring s[i:j] matches, you transition to dp[j] (not dp[j-1]).
Breaking too early in the trie walk. When a character isn’t in the trie node’s children, you should break out of the inner loop — there’s no point continuing since the trie can’t extend that path. But do collect is_end results before breaking.
Forgetting to initialize skip cost. The line dp[i] = 1 + dp[i+1] must run before checking dictionary matches — it sets the “worst case” that matches can only improve upon.
Word Search II
Difficulty: Hard Source: NeetCode
Problem
Given an
m x nboard of characters and a list of stringswords, return all words on the board.Each word must be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once in a word.
Example 1: Input:
board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]],words = ["oath","pea","eat","rain"]Output:["eat","oath"]Constraints:
m == board.length,n == board[i].length1 <= m, n <= 12board[i][j]is a lowercase English letter1 <= words.length <= 3 * 10^41 <= words[i].length <= 10- All words in
wordsare unique
Prerequisites
Before attempting this problem, you should be comfortable with:
- Trie (Prefix Tree) — central to the optimal solution; building and traversing a trie
- DFS / Backtracking — exploring the board path recursively while unmarking visited cells on backtrack
- Word Search I (LeetCode 79) — this problem is a natural extension of it
1. Brute Force (Word Search I for each word)
Intuition
Run the Word Search I algorithm independently for each word in words. For each word, try starting a DFS from every cell on the board. This is straightforward but scales poorly — if you have thousands of words, you do thousands of full board sweeps.
Algorithm
- For each
wordinwords:- For each cell
(r, c)on the board, run DFS to check ifwordcan be formed starting here. - If yes, add
wordto results.
- For each cell
- Return results.
Solution
def findWords(board: list[list[str]], words: list[str]) -> list[str]:
rows, cols = len(board), len(board[0])
result = []
def dfs(r, c, i, word):
if i == len(word):
return True
if r < 0 or r >= rows or c < 0 or c >= cols:
return False
if board[r][c] != word[i]:
return False
tmp = board[r][c]
board[r][c] = '#' # mark visited
found = (dfs(r+1, c, i+1, word) or dfs(r-1, c, i+1, word) or
dfs(r, c+1, i+1, word) or dfs(r, c-1, i+1, word))
board[r][c] = tmp # restore
return found
for word in words:
for r in range(rows):
for c in range(cols):
if dfs(r, c, 0, word):
result.append(word)
break
else:
continue
break
return result
board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]]
print(findWords(board, ["oath","pea","eat","rain"])) # ['oath', 'eat']
Complexity
- Time:
O(W * M * N * 4^L)where W = number of words, L = max word length - Space:
O(L)recursion stack per word
2. Trie + Single Board DFS
Intuition
Instead of searching for each word separately, build a trie from all words and run a single DFS over the board. At each cell, follow the trie character by character. If the current cell’s letter doesn’t exist as a child in the current trie node, prune immediately — we know no word starts that way. When we reach a node with is_end = True, we’ve found a word; record it and mark is_end = False to avoid duplicates. This shares prefix work across all words and prunes dead ends early.
Algorithm
- Build a trie from
words. Each node also stores thewordstring atis_endnodes (easier than reconstructing). - DFS over every board cell
(r, c)as a potential starting point. - In the DFS, given
node(current trie node) and position(r, c):- If
board[r][c]not innode.children, return. - Move to
next_node = node.children[board[r][c]]. - If
next_node.word, add it to results and clearnext_node.word(dedup). - Mark cell visited, recurse into 4 neighbors, unmark on backtrack.
- If
- Optional: prune empty trie nodes after finding words (pruning optimization).
Solution
class TrieNode:
def __init__(self):
self.children = {}
self.word = None # stores the complete word if this is a terminal node
def findWords(board: list[list[str]], words: list[str]) -> list[str]:
# Build trie
root = TrieNode()
for word in words:
node = root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.word = word
rows, cols = len(board), len(board[0])
result = []
def dfs(r, c, node):
if r < 0 or r >= rows or c < 0 or c >= cols:
return
ch = board[r][c]
if ch == '#' or ch not in node.children:
return
next_node = node.children[ch]
if next_node.word:
result.append(next_node.word)
next_node.word = None # prevent duplicate results
board[r][c] = '#' # mark visited
dfs(r + 1, c, next_node)
dfs(r - 1, c, next_node)
dfs(r, c + 1, next_node)
dfs(r, c - 1, next_node)
board[r][c] = ch # restore
# Prune: remove leaf nodes that are done
if not next_node.children:
del node.children[ch]
for r in range(rows):
for c in range(cols):
dfs(r, c, root)
return result
board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]]
print(sorted(findWords(board, ["oath","pea","eat","rain"]))) # ['eat', 'oath']
board2 = [["a","b"],["c","d"]]
print(findWords(board2, ["abdc","abcd","adcb"])) # ['abdc', 'abcd']
Complexity
- Time:
O(M * N * 4^L + W * L)— board DFS with pruning + trie build - Space:
O(W * L)for trie,O(L)recursion depth
Common Pitfalls
Not deduplicating found words. The same word could be found via multiple paths on the board. Setting next_node.word = None after finding it ensures you only add it once.
Forgetting to restore board cells. After DFS backtracks, you must undo the '#' marker. If you forget, subsequent DFS calls will see cells as already visited and miss valid paths.
The pruning optimization. Removing empty leaf nodes (del node.children[ch]) is optional but significantly speeds up the solution on large inputs, because it prevents re-entering exhausted trie branches. Don’t skip it in a real interview.
Starting the search from every cell. Don’t only start from cells that match the first character of some word — let the trie check handle that, since the condition ch not in node.children handles the pruning at the root level naturally.
Union-Find (Disjoint Set Union)
Are these two computers on the same network? Did this friendship connection just merge two separate social circles? Did adding this road create a cycle in the transport graph? These are all the same question in disguise: do two elements belong to the same group? Union-Find answers in near-O(1) time, no matter how many elements or operations you have.
Union-Find (also called Disjoint Set Union, or DSU) is one of those data structures that looks almost too simple — an array and two functions — yet it powers Kruskal’s minimum spanning tree algorithm, social network analysis, image segmentation, and more.
The Core Idea
Union-Find tracks a collection of elements partitioned into disjoint (non-overlapping) groups. Two operations:
find(x)— which group doesxbelong to? Returns a “representative” (root) for the group.union(x, y)— merge the groups containingxandyinto one.
flowchart LR
subgraph Start["Initial: 5 separate elements"]
A0((0))
B0((1))
C0((2))
D0((3))
E0((4))
end
subgraph After1["After union(0,1) and union(2,3)"]
A1((0)) --> R1(( 0 ))
B1((1)) --> R1
C1((2)) --> R2(( 2 ))
D1((3)) --> R2
E1((4))
end
subgraph After2["After union(1,2)"]
A2((0)) --> R3(( 0 ))
B2((1)) --> R3
C2((2)) --> R3
D2((3)) --> R3
E2((4))
end
style R1 fill:#ffd700,stroke:#e6b800
style R2 fill:#87ceeb,stroke:#4682b4
style R3 fill:#ffd700,stroke:#e6b800
Each group is represented as a tree. The root of the tree is the group’s representative. find(x) walks up the tree to the root.
Naive Implementation (to understand the problem)
The simplest version: each element’s “parent” is stored in an array. Initially everyone is their own parent.
class UnionFindNaive:
def __init__(self, n):
# Each element starts as its own group (parent = itself)
self.parent = list(range(n))
def find(self, x):
# Walk up to the root
while self.parent[x] != x:
x = self.parent[x]
return x
def union(self, x, y):
root_x = self.find(x)
root_y = self.find(y)
if root_x != root_y:
self.parent[root_x] = root_y # attach x's tree under y's root
def connected(self, x, y):
return self.find(x) == self.find(y)
uf = UnionFindNaive(5)
print("Initial groups (each element is its own root):")
print([uf.find(i) for i in range(5)]) # [0, 1, 2, 3, 4]
uf.union(0, 1)
uf.union(2, 3)
print("\nAfter union(0,1) and union(2,3):")
print([uf.find(i) for i in range(5)])
print(f"\nconnected(0, 1): {uf.connected(0, 1)}") # True
print(f"connected(0, 2): {uf.connected(0, 2)}") # False
uf.union(1, 2)
print(f"\nAfter union(1,2): connected(0, 3): {uf.connected(0, 3)}") # True
The problem: If we always attach the first tree under the second, we can create a long chain. In the worst case, find takes O(n) steps — walking up a linked-list-shaped tree.
Optimisation 1: Union by Rank
Instead of blindly attaching one tree under another, attach the smaller tree under the larger one. This keeps trees shallow.
A tree’s “rank” is an upper bound on its height. When two equal-rank trees merge, the new root gets rank + 1. Otherwise rank stays the same.
flowchart TD
subgraph Bad["Bad: chain forms, height = 4"]
N0a((0)) --> N1a((1)) --> N2a((2)) --> N3a((3))
end
subgraph Good["Good: union by rank, height = 2"]
N0b((0))
N1b((1)) --> N0b
N2b((2)) --> N0b
N3b((3)) --> N0b
end
style Bad fill:#ffd5d5,stroke:#e74c3c
style Good fill:#c8f7c5,stroke:#27ae60
Union by rank alone guarantees O(log n) per operation. That is already a huge improvement over the naive O(n) worst case.
Optimisation 2: Path Compression
Every time we call find(x), we walk up a chain of parent pointers. After we find the root, we can flatten the entire chain by pointing every visited node directly to the root. Future find calls on the same elements become O(1).
flowchart TD
subgraph Before["Before find(3)"]
B3((3)) --> B2((2)) --> B1((1)) --> B0((0 root))
end
subgraph After["After find(3) with path compression"]
A3((3)) --> A0((0 root))
A2((2)) --> A0
A1((1)) --> A0
end
style B0 fill:#ffd700,stroke:#e6b800
style A0 fill:#ffd700,stroke:#e6b800
Full Implementation with Both Optimisations
class UnionFind:
def __init__(self, n):
self.parent = list(range(n)) # parent[i] = i means i is a root
self.rank = [0] * n # upper bound on tree height
self.num_components = n # track connected component count
def find(self, x):
# Path compression: make every node on the path point directly to root
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # recursive compression
return self.parent[x]
def union(self, x, y):
root_x = self.find(x)
root_y = self.find(y)
if root_x == root_y:
return False # already in the same component — no merge needed
# Union by rank: attach smaller tree under larger
if self.rank[root_x] < self.rank[root_y]:
self.parent[root_x] = root_y
elif self.rank[root_x] > self.rank[root_y]:
self.parent[root_y] = root_x
else:
# Equal rank: pick one as root and increment its rank
self.parent[root_y] = root_x
self.rank[root_x] += 1
self.num_components -= 1
return True # a merge happened
def connected(self, x, y):
return self.find(x) == self.find(y)
def component_count(self):
return self.num_components
# ============================================================= demo
uf = UnionFind(6)
print(f"Components: {uf.component_count()}") # 6
uf.union(0, 1)
uf.union(2, 3)
uf.union(4, 5)
print(f"After 3 unions — Components: {uf.component_count()}") # 3
print(f"connected(0,1): {uf.connected(0, 1)}") # True
print(f"connected(0,2): {uf.connected(0, 2)}") # False
uf.union(1, 3)
print(f"\nAfter union(1,3) — Components: {uf.component_count()}") # 2
print(f"connected(0,2): {uf.connected(0, 2)}") # True (0-1-3-2 now same group)
print(f"connected(0,4): {uf.connected(0, 4)}") # False
# Show path compression in action
print(f"\nParent array before any find: {uf.parent}")
_ = uf.find(0) # trigger path compression
print(f"Parent array after find(0): {uf.parent}")
Application 1: Detecting Cycles in a Graph
Adding an edge between two nodes that are already connected (same component) creates a cycle. This is the foundation of Kruskal’s Minimum Spanning Tree algorithm.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False # cycle detected
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
return True
def has_cycle(num_nodes, edges):
uf = UnionFind(num_nodes)
for u, v in edges:
if not uf.union(u, v):
return True, (u, v) # this edge created the cycle
return False, None
# Graph without a cycle: 0-1-2-3 (a path)
edges_no_cycle = [(0, 1), (1, 2), (2, 3)]
cycle, edge = has_cycle(4, edges_no_cycle)
print(f"Path 0-1-2-3 has cycle: {cycle}") # False
# Graph with a cycle: triangle 0-1-2-0
edges_with_cycle = [(0, 1), (1, 2), (2, 0)]
cycle, edge = has_cycle(3, edges_with_cycle)
print(f"Triangle 0-1-2-0 has cycle: {cycle}, formed by edge {edge}") # True, (2, 0)
# More complex: 5 nodes, edges including a back-edge
edges_complex = [(0,1), (0,2), (1,3), (2,4), (3,4)]
cycle, edge = has_cycle(5, edges_complex)
print(f"Complex graph has cycle: {cycle}, formed by edge {edge}") # True
Application 2: Number of Connected Components
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.components = n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
self.components -= 1
def count_components(n, edges):
uf = UnionFind(n)
for u, v in edges:
uf.union(u, v)
return uf.components
# 6 computers, some connected by network cables
n = 6
connections = [(0,1), (0,2), (3,4)]
# Results in 3 components: {0,1,2}, {3,4}, {5}
print(f"Connected components: {count_components(n, connections)}") # 3
# Add a cable connecting the two clusters
connections.append((2, 3))
print(f"After adding (2,3): {count_components(n, connections)}") # 2
# Connect the isolated node
connections.append((5, 0))
print(f"After adding (5,0): {count_components(n, connections)}") # 1
Application 3: Accounts Merge
A classic interview problem: given a list of accounts where each account is [name, email1, email2, ...], merge accounts that share at least one email address (same person). Union-Find makes this elegant.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
def accounts_merge(accounts):
uf = UnionFind(len(accounts))
email_to_account = {} # email -> first account index that owns it
# Union accounts that share an email
for i, account in enumerate(accounts):
for email in account[1:]: # skip the name at index 0
if email in email_to_account:
uf.union(i, email_to_account[email])
else:
email_to_account[email] = i
# Group emails by root account
from collections import defaultdict
groups = defaultdict(set)
for email, acc_idx in email_to_account.items():
root = uf.find(acc_idx)
groups[root].add(email)
# Build result
result = []
for root, emails in groups.items():
name = accounts[root][0]
result.append([name] + sorted(emails))
return sorted(result)
accounts = [
["Alice", "alice@work.com", "alice@home.com"],
["Bob", "bob@work.com"],
["Alice", "alice@home.com", "alice@phone.com"], # shares alice@home.com with account 0
["Bob", "bob@work.com", "bob@personal.com"], # shares bob@work.com with account 1
]
merged = accounts_merge(accounts)
print("Merged accounts:")
for account in merged:
name = account[0]
emails = account[1:]
print(f" {name}: {emails}")
Complexity Analysis
With both union by rank and path compression, the amortised time per operation is O(α(n)) where α is the inverse Ackermann function. For any conceivable input size in the real world, α(n) ≤ 4. It is effectively constant.
| Implementation | find | union |
|---|---|---|
| Naive (no optimisation) | O(n) worst case | O(n) worst case |
| Union by rank only | O(log n) | O(log n) |
| Path compression only | O(log n) amortised | O(log n) |
| Both (full DSU) | O(α(n)) ≈ O(1) | O(α(n)) ≈ O(1) |
Space: O(n) for the parent and rank arrays.
Real-World Applications
- Network connectivity — “Is server A reachable from server B?” in a dynamic network where links are added over time. Union-Find handles each new link in near-O(1).
- Social network friend groups — detecting communities: if Alice and Bob are friends, and Bob and Carol are friends, they are all in the same component. Adding new friendships is O(α(n)).
- Kruskal’s Minimum Spanning Tree — sort edges by weight, add each edge if it connects two different components (checked with Union-Find). This builds the MST in O(E log E) time.
- Image segmentation — each pixel is a node; adjacent pixels with similar colour are unioned. Connected components become segments. Used in medical imaging and computer vision.
- Percolation theory — physics simulations of fluids through porous materials model each open site as a node; Union-Find determines if the top and bottom are connected (percolation occurs).
- Duplicate detection — grouping database records that refer to the same real-world entity (same person across multiple accounts, duplicate product listings, etc.).
Segment Tree
Your weather app shows the min/max temperature for any date range instantly. You tap “last 30 days” and the answer appears before the animation finishes. Behind that response is a data structure that answers range queries in O(log n) — the segment tree.
The problem with the simple approaches
Suppose you have an array of daily temperatures for a year. Two common queries come up constantly: “what was the total/min/max between day L and day R?” and “update day I’s reading to a new value.”
Two naive approaches:
| Approach | Query time | Update time |
|---|---|---|
| Loop over the range | O(n) | O(1) |
| Prefix sum array | O(1) | O(n) |
Neither is great when both queries and updates are frequent. A segment tree gives you O(log n) for both — the sweet spot for dynamic range problems.
How a segment tree works
A segment tree is a binary tree where:
- Each leaf stores one element of the original array.
- Each internal node stores the combined result (sum, min, max, etc.) of its children’s ranges.
- The root holds the result for the entire array.
Here is the tree built from [1, 3, 5, 7, 9, 11] (storing range sums):
flowchart TD
A["36\n[0..5]"] --> B["9\n[0..2]"]
A --> C["27\n[3..5]"]
B --> D["4\n[0..1]"]
B --> E["5\n[2..2]"]
C --> F["16\n[3..4]"]
C --> G["11\n[5..5]"]
D --> H["1\n[0..0]"]
D --> I["3\n[1..1]"]
F --> J["7\n[3..3]"]
F --> K["9\n[4..4]"]
Every node answers the question “what is the sum of elements in my range?” instantly. To answer a query over any sub-range, you combine at most O(log n) nodes.
Flat array storage
Just like a heap, a segment tree is stored in a flat array — no node objects, no pointers. The index arithmetic is slightly different because the tree is 1-indexed here (index 0 is unused, which simplifies the math):
| Relationship | Formula |
|---|---|
Left child of node i | 2 * i |
Right child of node i | 2 * i + 1 |
Parent of node i | i // 2 |
For an array of length n, the flat tree array needs 4 * n slots to be safe (the tree can have up to 4n nodes including padding for non-powers of two).
Original array: [1, 3, 5, 7, 9, 11]
Flat tree (1-indexed, sum tree):
index: 1 2 3 4 5 6 7 8 9 10 11
value: [36, 9, 27, 4, 5, 16, 11, 1, 3, 7, 9]
(11 is at index 13, sparse)
Implementation
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
if self.n > 0:
self._build(arr, 1, 0, self.n - 1)
def _build(self, arr, node, start, end):
"""Recursively build the tree bottom-up."""
if start == end:
# Leaf node: store the element directly
self.tree[node] = arr[start]
else:
mid = (start + end) // 2
self._build(arr, 2 * node, start, mid)
self._build(arr, 2 * node + 1, mid + 1, end)
# Internal node: store combined result of children
self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1]
def query(self, l, r):
"""Return the sum of arr[l..r] (inclusive)."""
return self._query(1, 0, self.n - 1, l, r)
def _query(self, node, start, end, l, r):
if r < start or end < l:
# Current segment is completely outside the query range
return 0
if l <= start and end <= r:
# Current segment is completely inside the query range
return self.tree[node]
# Partial overlap: recurse into both children
mid = (start + end) // 2
left_sum = self._query(2 * node, start, mid, l, r)
right_sum = self._query(2 * node + 1, mid + 1, end, l, r)
return left_sum + right_sum
def update(self, i, val):
"""Set arr[i] = val and update all affected nodes."""
self._update(1, 0, self.n - 1, i, val)
def _update(self, node, start, end, i, val):
if start == end:
# Leaf: apply the new value
self.tree[node] = val
else:
mid = (start + end) // 2
if i <= mid:
self._update(2 * node, start, mid, i, val)
else:
self._update(2 * node + 1, mid + 1, end, i, val)
# Recalculate this node from updated children
self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1]
arr = [1, 3, 5, 7, 9, 11]
st = SegmentTree(arr)
print("Array:", arr)
print()
print("sum(0..5) =", st.query(0, 5)) # 36 — full array
print("sum(1..3) =", st.query(1, 3)) # 15 — indices 1,2,3 → 3+5+7
print("sum(2..4) =", st.query(2, 4)) # 21 — indices 2,3,4 → 5+7+9
print()
# Update index 2 from 5 to 10
print("Updating index 2: 5 → 10")
st.update(2, 10)
print("sum(0..5) =", st.query(0, 5)) # 41 — was 36, now +5
print("sum(1..3) =", st.query(1, 3)) # 20 — was 15, now +5
Walking through a query
Let’s trace query(1, 3) on the original tree (sum of indices 1, 2, 3 = 3+5+7 = 15).
flowchart TD
A["36 [0..5]\nPartial overlap → recurse"] --> B["9 [0..2]\nPartial overlap → recurse"]
A --> C["27 [3..5]\nPartial overlap → recurse"]
B --> D["4 [0..1]\nPartial overlap → recurse"]
B --> E["5 [2..2]\nFully inside → return 5"]
C --> F["16 [3..4]\nFully inside → return 16"]
C --> G["11 [5..5]\nOutside → return 0"]
D --> H["1 [0..0]\nOutside → return 0"]
D --> I["3 [1..1]\nFully inside → return 3"]
style E fill:#4ade80,color:#000
style F fill:#4ade80,color:#000
style I fill:#4ade80,color:#000
style H fill:#f87171,color:#000
style G fill:#f87171,color:#000
Only three nodes contribute to the answer (green). Two are skipped entirely (red). The final answer is 3 + 5 + 16 — wait, that is 24, not 15. The issue is the [3..4] node covers indices 3 and 4, but our query is only [1..3]. The recursion splits correctly: [3..5] is partially overlapping, so we recurse left to [3..4] which is fully inside [1..3]… Actually [3..4] covers indices 3 and 4, and our query ends at 3, so this is a partial overlap too. Let’s re-trace in code:
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
self._build(arr, 1, 0, self.n - 1)
def _build(self, arr, node, start, end):
if start == end:
self.tree[node] = arr[start]
else:
mid = (start + end) // 2
self._build(arr, 2 * node, start, mid)
self._build(arr, 2 * node + 1, mid + 1, end)
self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1]
def query_verbose(self, l, r):
return self._query(1, 0, self.n - 1, l, r)
def _query(self, node, start, end, l, r):
if r < start or end < l:
print(f" node {node} covers [{start}..{end}] — outside [{l}..{r}], return 0")
return 0
if l <= start and end <= r:
print(f" node {node} covers [{start}..{end}] — fully inside [{l}..{r}], return {self.tree[node]}")
return self.tree[node]
mid = (start + end) // 2
print(f" node {node} covers [{start}..{end}] — partial, splitting at mid={mid}")
left = self._query(2 * node, start, mid, l, r)
right = self._query(2 * node + 1, mid + 1, end, l, r)
return left + right
st = SegmentTree([1, 3, 5, 7, 9, 11])
print("Tracing query(1, 3):")
result = st.query_verbose(1, 3)
print(f"Result: {result}")
Complexity analysis
| Operation | Time | Space |
|---|---|---|
| Build | O(n) | O(n) |
| Range query | O(log n) | O(log n) stack |
| Point update | O(log n) | O(log n) stack |
The O(log n) query bound comes from the key observation: at any level of the tree, a query visits at most 4 nodes. With O(log n) levels, the total nodes visited is O(4 log n) = O(log n).
Extending beyond sum
The same structure works for any operation that can be combined: min, max, GCD, product, bitwise OR/AND. The only change is the line that combines children:
# Min segment tree — only the combine line changes
class MinSegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [float('inf')] * (4 * self.n)
self._build(arr, 1, 0, self.n - 1)
def _build(self, arr, node, start, end):
if start == end:
self.tree[node] = arr[start]
else:
mid = (start + end) // 2
self._build(arr, 2 * node, start, mid)
self._build(arr, 2 * node + 1, mid + 1, end)
self.tree[node] = min(self.tree[2 * node], self.tree[2 * node + 1]) # changed
def query(self, l, r):
return self._query(1, 0, self.n - 1, l, r)
def _query(self, node, start, end, l, r):
if r < start or end < l:
return float('inf') # identity for min
if l <= start and end <= r:
return self.tree[node]
mid = (start + end) // 2
return min(
self._query(2 * node, start, mid, l, r),
self._query(2 * node + 1, mid + 1, end, l, r)
)
def update(self, i, val):
self._update(1, 0, self.n - 1, i, val)
def _update(self, node, start, end, i, val):
if start == end:
self.tree[node] = val
else:
mid = (start + end) // 2
if i <= mid:
self._update(2 * node, start, mid, i, val)
else:
self._update(2 * node + 1, mid + 1, end, i, val)
self.tree[node] = min(self.tree[2 * node], self.tree[2 * node + 1])
# Daily temperatures (°C) for 6 days
temps = [22, 19, 25, 17, 30, 21]
mst = MinSegmentTree(temps)
print("Temperatures:", temps)
print("Min temp days 0–5:", mst.query(0, 5)) # 17
print("Min temp days 1–4:", mst.query(1, 4)) # 17
print("Min temp days 0–2:", mst.query(0, 2)) # 19
# Day 1 got a corrected reading
mst.update(1, 14)
print()
print("After correcting day 1 to 14°C:")
print("Min temp days 0–5:", mst.query(0, 5)) # 14
print("Min temp days 1–4:", mst.query(1, 4)) # 14
print("Min temp days 3–5:", mst.query(3, 5)) # 17 — unaffected
Real-world uses
Weather and sensor dashboards — serve min/max/average for arbitrary date ranges while accepting live sensor updates without rebuilding any index.
Database range queries — database engines use segment-tree-like structures in their query planners to efficiently evaluate WHERE date BETWEEN x AND y on indexed columns with frequent writes.
Stock price analytics — trading platforms query the highest/lowest price over any rolling window while the price feed continuously pushes updates.
Game leaderboards — real-time leaderboards with range statistics (“rank 100–200 players by score”) use segment trees to handle simultaneous score updates and ranking queries.
Competitive programming — segment trees are one of the most frequently tested data structures in algorithmic contests because they appear in a huge class of range-update, range-query problems.
Iterative DFS
Recursion is elegant but has a limit. Every recursive call consumes a frame on Python’s call stack. A tree with 10,000 levels deep triggers RecursionError: maximum recursion depth exceeded. Iterative DFS gives you the same traversal with full control — and makes explicit something that recursion hides: the stack.
The key insight
When you write a recursive DFS, Python is secretly managing a stack for you — it is called the call stack. Each function call pushes a frame; each return pops it. The frame holds the local variables and the “where to resume” information for that call.
Iterative DFS swaps Python’s implicit call stack for an explicit one you manage yourself. The logic is identical; only the bookkeeping changes.
flowchart LR
A["Recursive DFS"] -- "call stack managed by Python runtime" --> B["Program runs"]
C["Iterative DFS"] -- "stack managed by your code" --> B
The tree used in all examples
flowchart TD
A["1"] --> B["2"]
A --> C["3"]
B --> D["4"]
B --> E["5"]
C --> F["6"]
C --> G["7"]
Expected traversal results (same as recursive DFS):
- Pre-order:
[1, 2, 4, 5, 3, 6, 7] - In-order:
[4, 2, 5, 1, 6, 3, 7] - Post-order:
[4, 5, 2, 6, 7, 3, 1]
Pre-order: the simplest case
Pre-order visits the node before its children (root → left → right). The stack mirrors this naturally: push root, then in the loop pop a node, record it, push its right child first, then left. Because a stack is LIFO, the left child will be processed before the right — exactly what we want.
flowchart LR
S0["Stack: [1]"] --> |"pop 1, push 3 then 2"| S1["Stack: [3, 2]"]
S1 --> |"pop 2, push 5 then 4"| S2["Stack: [3, 5, 4]"]
S2 --> |"pop 4 (leaf)"| S3["Stack: [3, 5]"]
S3 --> |"pop 5 (leaf)"| S4["Stack: [3]"]
S4 --> |"pop 3, push 7 then 6"| S5["Stack: [7, 6]"]
S5 --> |"pop 6 (leaf)"| S6["Stack: [7]"]
S6 --> |"pop 7 (leaf)"| S7["Stack: []"]
Result collected in order: 1, 2, 4, 5, 3, 6, 7.
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
def preorder_recursive(node):
if node is None:
return []
return [node.value] + preorder_recursive(node.left) + preorder_recursive(node.right)
def preorder_iterative(root):
if root is None:
return []
stack = [root]
result = []
while stack:
node = stack.pop()
result.append(node.value)
# Push right first: left will be popped (and visited) first due to LIFO
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return result
print("Recursive pre-order: ", preorder_recursive(root))
print("Iterative pre-order: ", preorder_iterative(root))
In-order: the tricky case
In-order is (left → node → right). The challenge is that you cannot record a node immediately when you first encounter it — you must descend all the way to the leftmost node first.
The pattern uses a current pointer alongside the stack. Drill left pushing onto the stack until you hit None, then pop, record, and switch to the right child.
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
def inorder_recursive(node):
if node is None:
return []
return inorder_recursive(node.left) + [node.value] + inorder_recursive(node.right)
def inorder_iterative(root):
stack = []
result = []
current = root
while current is not None or stack:
# Phase 1: go as far left as possible, pushing each node
while current is not None:
stack.append(current)
current = current.left
# Phase 2: we have hit None — the top of the stack is the leftmost unvisited node
current = stack.pop()
result.append(current.value)
# Phase 3: now explore the right subtree
current = current.right
return result
print("Recursive in-order: ", inorder_recursive(root))
print("Iterative in-order: ", inorder_iterative(root))
Post-order: the reverse trick
Post-order (left → right → node) is the hardest to do iteratively because a node must be recorded only after both its children are fully processed.
The clever trick: notice that post-order is the reverse of a modified pre-order that goes (node → right → left). Run that modified traversal, collect results, then reverse at the end.
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
def postorder_recursive(node):
if node is None:
return []
return postorder_recursive(node.left) + postorder_recursive(node.right) + [node.value]
def postorder_iterative(root):
if root is None:
return []
stack = [root]
result = []
while stack:
node = stack.pop()
result.append(node.value)
# Push LEFT first (opposite of pre-order)
# so right is processed before left when reversed
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
# Reverse: node→right→left becomes left→right→node
return result[::-1]
print("Recursive post-order: ", postorder_recursive(root))
print("Iterative post-order: ", postorder_iterative(root))
Side-by-side comparison
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
root = TreeNode(
1,
left=TreeNode(2, left=TreeNode(4), right=TreeNode(5)),
right=TreeNode(3, left=TreeNode(6), right=TreeNode(7)),
)
# ── Recursive ────────────────────────────────────────────────────────────────
def pre_r(node):
if not node: return []
return [node.value] + pre_r(node.left) + pre_r(node.right)
def in_r(node):
if not node: return []
return in_r(node.left) + [node.value] + in_r(node.right)
def post_r(node):
if not node: return []
return post_r(node.left) + post_r(node.right) + [node.value]
# ── Iterative ────────────────────────────────────────────────────────────────
def pre_i(root):
if not root: return []
stack, result = [root], []
while stack:
node = stack.pop()
result.append(node.value)
if node.right: stack.append(node.right)
if node.left: stack.append(node.left)
return result
def in_i(root):
stack, result, cur = [], [], root
while cur or stack:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
result.append(cur.value)
cur = cur.right
return result
def post_i(root):
if not root: return []
stack, result = [root], []
while stack:
node = stack.pop()
result.append(node.value)
if node.left: stack.append(node.left)
if node.right: stack.append(node.right)
return result[::-1]
# ── Results ───────────────────────────────────────────────────────────────────
print(f"{'Traversal':<12} {'Recursive':<30} {'Iterative':<30} {'Match?'}")
print("-" * 80)
for name, rf, itf in [("Pre-order", pre_r, pre_i), ("In-order", in_r, in_i), ("Post-order", post_r, post_i)]:
r = rf(root)
i = itf(root)
print(f"{name:<12} {str(r):<30} {str(i):<30} {r == i}")
Iterative DFS on a graph
Trees have no cycles, so no bookkeeping is needed. Graphs do have cycles, so you add a visited set. The stack-based pattern is identical.
def dfs_graph(graph, start):
"""
Iterative DFS on an adjacency-list graph.
Returns nodes in the order they are first visited.
"""
visited = set()
stack = [start]
result = []
while stack:
node = stack.pop()
if node in visited:
continue
visited.add(node)
result.append(node)
# Push neighbours (reverse to get left-to-right visit order)
for neighbour in reversed(graph[node]):
if neighbour not in visited:
stack.append(neighbour)
return result
# Graph as adjacency list
#
# A --- B --- D
# | |
# C --- E
#
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'E'],
'D': ['B'],
'E': ['B', 'C'],
}
print("DFS from A:", dfs_graph(graph, 'A'))
print("DFS from D:", dfs_graph(graph, 'D'))
Complexity
| Recursive DFS | Iterative DFS | |
|---|---|---|
| Time | O(n) | O(n) |
| Space (balanced tree) | O(log n) call stack | O(log n) explicit stack |
| Space (skewed tree) | O(n) — may crash | O(n) — safe |
| Max depth | ~1000 (Python default) | Unlimited |
The time complexity is the same. The space complexity is also the same in terms of Big-O — but the iterative version does not hit Python’s recursion limit, which is 1000 frames by default.
Real-world uses
File system traversal — os.walk() in Python’s standard library is implemented iteratively. A directory with deeply nested folders would exhaust the call stack if implemented recursively.
Web crawlers — a crawler maintains an explicit queue/stack of URLs to visit and a visited set of already-seen URLs. The structure is exactly iterative DFS (or BFS depending on discovery order desired).
Compilers and interpreters — parsing deeply nested expressions like f(g(h(i(j(...))))) with a recursive descent parser can overflow the stack. Production compilers use iterative approaches or manually manage a parse stack.
Graph algorithms in production — Tarjan’s strongly connected components algorithm and iterative deepening depth-first search (IDDFS) both rely on explicit stack management to handle large real-world graphs.
Tree Problems
Practice problems for traversals, binary search trees, recursive state, and tree-based dynamic programming.
Binary Tree Inorder Traversal
Difficulty: Easy Source: NeetCode
Problem
Given the
rootof a binary tree, return the inorder traversal of its nodes’ values.Example 1: Input: root = [1,null,2,3] Output: [1,3,2]
Example 2: Input: root = [] Output: []
Constraints:
- The number of nodes in the tree is in the range
[0, 100]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Trees — Hierarchical data structure where each node has at most two children
- Recursion — Calling a function from within itself to solve subproblems
- Stack — LIFO data structure used to simulate the call stack iteratively
1. Recursive DFS
Intuition
Inorder means left → root → right. The recursive approach naturally mirrors this definition — recurse into the left subtree, visit the current node, then recurse into the right subtree. The call stack handles backtracking for free.
Algorithm
- If the current node is null, return
- Recurse on the left child
- Append the current node’s value to the result
- Recurse on the right child
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def inorder_recursive(root):
result = []
def dfs(node):
if not node:
return
dfs(node.left)
result.append(node.val)
dfs(node.right)
dfs(root)
return result
# --- helpers ---
def build_tree(values):
"""Build a tree from a level-order list. None means missing node."""
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, None, 2, 3])
print(inorder_recursive(root1)) # [1, 3, 2]
root2 = build_tree([])
print(inorder_recursive(root2)) # []
root3 = build_tree([1, 2, 3, 4, 5])
print(inorder_recursive(root3)) # [4, 2, 5, 1, 3]
Complexity
- Time:
O(n)— visit every node once - Space:
O(h)— call stack depth equals tree height (O(n) worst case for skewed trees)
2. Iterative (Stack-Based)
Intuition
We simulate the recursive call stack explicitly. The idea is to keep going left, pushing nodes onto the stack. When we can’t go left anymore, pop a node (that’s the “visit” step), then pivot to its right child.
Algorithm
- Initialize an empty stack and set
curr = root - While
curris not null or the stack is not empty:- Push
currand go left until null - Pop from stack, record its value
- Move
currto the popped node’s right child
- Push
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def inorder_iterative(root):
result = []
stack = []
curr = root
while curr or stack:
# Drill all the way left
while curr:
stack.append(curr)
curr = curr.left
# Visit the node
curr = stack.pop()
result.append(curr.val)
# Pivot right
curr = curr.right
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, None, 2, 3])
print(inorder_iterative(root1)) # [1, 3, 2]
root2 = build_tree([])
print(inorder_iterative(root2)) # []
root3 = build_tree([1, 2, 3, 4, 5])
print(inorder_iterative(root3)) # [4, 2, 5, 1, 3]
Complexity
- Time:
O(n)— every node is pushed and popped exactly once - Space:
O(h)— stack holds at most one path from root to a leaf
Common Pitfalls
Confusing traversal orders. Inorder is left→root→right. A common mistake is visiting the root before recursing left (that would be preorder). Keep the mnemonic: “in” = in the middle.
Forgetting the null base case. If you don’t guard against node is None in the recursive version, you’ll hit an AttributeError trying to access .left on None.
Binary Tree Preorder Traversal
Difficulty: Easy Source: NeetCode
Problem
Given the
rootof a binary tree, return the preorder traversal of its nodes’ values.Example 1: Input: root = [1,null,2,3] Output: [1,2,3]
Example 2: Input: root = [] Output: []
Constraints:
- The number of nodes in the tree is in the range
[0, 100]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Trees — Hierarchical data structure where each node has at most two children
- Recursion — Solving a problem by breaking it into the same problem on smaller inputs
- Stack — LIFO structure that lets us control the visit order iteratively
1. Recursive DFS
Intuition
Preorder means root → left → right. You visit the current node first, then explore left, then right. This is the most natural traversal for printing or copying a tree because you process a node before its subtrees.
Algorithm
- If the current node is null, return
- Append the current node’s value to the result
- Recurse on the left child
- Recurse on the right child
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def preorder_recursive(root):
result = []
def dfs(node):
if not node:
return
result.append(node.val) # Visit root first
dfs(node.left)
dfs(node.right)
dfs(root)
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, None, 2, 3])
print(preorder_recursive(root1)) # [1, 2, 3]
root2 = build_tree([])
print(preorder_recursive(root2)) # []
root3 = build_tree([1, 2, 3, 4, 5])
print(preorder_recursive(root3)) # [1, 2, 4, 5, 3]
Complexity
- Time:
O(n)— every node is visited exactly once - Space:
O(h)— recursion depth equals tree height
2. Iterative (Stack-Based)
Intuition
For preorder, we want to visit root first, then left before right. With a stack, we can achieve this by pushing the right child before the left child — since the stack is LIFO, left gets processed first.
Algorithm
- Initialize a stack with the root node
- While the stack is not empty:
- Pop a node and record its value
- Push the right child first (so left is processed first)
- Push the left child
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def preorder_iterative(root):
if not root:
return []
result = []
stack = [root]
while stack:
node = stack.pop()
result.append(node.val)
# Push right first so left is processed first (LIFO)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, None, 2, 3])
print(preorder_iterative(root1)) # [1, 2, 3]
root2 = build_tree([])
print(preorder_iterative(root2)) # []
root3 = build_tree([1, 2, 3, 4, 5])
print(preorder_iterative(root3)) # [1, 2, 4, 5, 3]
Complexity
- Time:
O(n)— every node is pushed and popped once - Space:
O(h)— stack holds at most one path worth of nodes at a time
Common Pitfalls
Pushing children in the wrong order. In the iterative approach, you must push right before left. It feels backwards, but since the stack is LIFO, left will be popped (and processed) first — which is exactly what preorder needs.
Off-by-one with null checks. Only push a child if it actually exists. Pushing None onto the stack will cause a crash when you try to access .val on it.
Binary Tree Postorder Traversal
Difficulty: Easy Source: NeetCode
Problem
Given the
rootof a binary tree, return the postorder traversal of its nodes’ values.Example 1: Input: root = [1,null,2,3] Output: [3,2,1]
Example 2: Input: root = [] Output: []
Constraints:
- The number of nodes in the tree is in the range
[0, 100]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Trees — Hierarchical data structure where each node has at most two children
- Recursion — Solving subproblems by calling the function on smaller inputs
- Stack — LIFO structure useful for reversing traversal order iteratively
1. Recursive DFS
Intuition
Postorder means left → right → root. You process both subtrees fully before visiting the current node. This is the natural order for deletion (delete children before parent) or for bottom-up computations.
Algorithm
- If the current node is null, return
- Recurse on the left child
- Recurse on the right child
- Append the current node’s value to the result
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def postorder_recursive(root):
result = []
def dfs(node):
if not node:
return
dfs(node.left)
dfs(node.right)
result.append(node.val) # Visit root last
dfs(root)
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, None, 2, 3])
print(postorder_recursive(root1)) # [3, 2, 1]
root2 = build_tree([])
print(postorder_recursive(root2)) # []
root3 = build_tree([1, 2, 3, 4, 5])
print(postorder_recursive(root3)) # [4, 5, 2, 3, 1]
Complexity
- Time:
O(n)— every node is visited exactly once - Space:
O(h)— call stack depth equals tree height
2. Iterative (Reverse Preorder Trick)
Intuition
Postorder (left → right → root) is the exact reverse of a modified preorder (root → right → left). So we do a preorder variant — pushing left before right — collect the result, and then reverse it at the end. It’s a neat trick that avoids the complexity of the “true” iterative postorder with two stacks.
Algorithm
- Use a stack starting with root
- While the stack is not empty:
- Pop a node and prepend (or collect then reverse) its value
- Push the left child first, then the right child
- Reverse the collected values and return
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def postorder_iterative(root):
if not root:
return []
result = []
stack = [root]
while stack:
node = stack.pop()
result.append(node.val)
# Push left first, then right
# (right will be processed next → root, right, left order)
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
# Reversing gives left, right, root = postorder
return result[::-1]
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, None, 2, 3])
print(postorder_iterative(root1)) # [3, 2, 1]
root2 = build_tree([])
print(postorder_iterative(root2)) # []
root3 = build_tree([1, 2, 3, 4, 5])
print(postorder_iterative(root3)) # [4, 5, 2, 3, 1]
Complexity
- Time:
O(n)— every node processed once, plus O(n) for the reverse - Space:
O(n)— result list stores all values before reversing
Common Pitfalls
Forgetting the reversal. The iterative trick only works if you remember to reverse the collected list at the end. Without it you get a root→right→left traversal, not postorder.
Confusing with inorder. Inorder and postorder both visit the root last-ish, but the difference is that inorder visits root between the two subtrees. Keep the mnemonic: “post” = root comes after everything.
Invert Binary Tree
Difficulty: Easy Source: NeetCode
Problem
Given the
rootof a binary tree, invert the tree, and return its root.Example 1: Input: root = [4,2,7,1,3,6,9] Output: [4,7,2,9,6,3,1]
Example 2: Input: root = [2,1,3] Output: [2,3,1]
Constraints:
- The number of nodes in the tree is in the range
[0, 100]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Trees — Understanding the parent-child relationship between nodes
- Recursion — Applying the same operation to smaller subproblems
- BFS / Queue — Level-by-level traversal used in the iterative approach
1. Recursive DFS
Intuition
To invert a tree, swap the left and right children at each node, then recursively invert both subtrees. The order of the recursion vs. the swap doesn’t matter — swap first or recurse first both work.
Algorithm
- If the current node is null, return null
- Swap
node.leftandnode.right - Recursively invert
node.left - Recursively invert
node.right - Return the node
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def invert_tree_recursive(root):
if not root:
return None
# Swap children
root.left, root.right = root.right, root.left
# Recurse into each subtree
invert_tree_recursive(root.left)
invert_tree_recursive(root.right)
return root
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def level_order(root):
"""Return level-order list for easy visualization."""
if not root:
return []
result, queue = [], [root]
while queue:
node = queue.pop(0)
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
# --- tests ---
root1 = build_tree([4, 2, 7, 1, 3, 6, 9])
invert_tree_recursive(root1)
print(level_order(root1)) # [4, 7, 2, 9, 6, 3, 1]
root2 = build_tree([2, 1, 3])
invert_tree_recursive(root2)
print(level_order(root2)) # [2, 3, 1]
Complexity
- Time:
O(n)— every node is visited once - Space:
O(h)— recursion depth proportional to tree height
2. Iterative (BFS with Queue)
Intuition
We can do the same thing level by level using a queue. For each node we dequeue, swap its children and enqueue those children to process later.
Algorithm
- Initialize a queue with the root
- While the queue is not empty:
- Dequeue a node
- Swap its left and right children
- Enqueue non-null children
- Return the root
Solution
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def invert_tree_iterative(root):
if not root:
return None
queue = deque([root])
while queue:
node = queue.popleft()
# Swap children
node.left, node.right = node.right, node.left
# Enqueue children for processing
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return root
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def level_order(root):
if not root:
return []
result, queue = [], [root]
while queue:
node = queue.pop(0)
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
# --- tests ---
root1 = build_tree([4, 2, 7, 1, 3, 6, 9])
invert_tree_iterative(root1)
print(level_order(root1)) # [4, 7, 2, 9, 6, 3, 1]
root2 = build_tree([2, 1, 3])
invert_tree_iterative(root2)
print(level_order(root2)) # [2, 3, 1]
Complexity
- Time:
O(n)— every node is processed once - Space:
O(w)— queue holds at most the widest level of the tree
Common Pitfalls
Forgetting to return the root. The problem asks you to return the root of the inverted tree. After all the swaps, the root is the same node — just return it.
Confusing inversion with reversal. Inverting a binary tree swaps left and right children at every level — it’s a mirror image. It’s not the same as reversing the values in the tree.
Maximum Depth of Binary Tree
Difficulty: Easy Source: NeetCode
Problem
Given the
rootof a binary tree, return its maximum depth. A binary tree’s maximum depth is the number of nodes along the longest path from the root node down to the farthest leaf node.Example 1: Input: root = [3,9,20,null,null,15,7] Output: 3
Example 2: Input: root = [1,null,2] Output: 2
Constraints:
- The number of nodes in the tree is in the range
[0, 10^4]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Trees — Understanding tree height vs depth distinction
- Recursion — Building answers bottom-up from leaf to root
- BFS — Level-by-level traversal where counting levels gives depth
1. Recursive DFS
Intuition
The depth of any tree is 1 (for the root) plus the maximum depth of either subtree. The base case is when the node is null — that contributes 0. This naturally gives a bottom-up computation.
Algorithm
- If the node is null, return 0
- Recursively get the depth of the left subtree
- Recursively get the depth of the right subtree
- Return
1 + max(left_depth, right_depth)
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def max_depth_recursive(root):
if not root:
return 0
left_depth = max_depth_recursive(root.left)
right_depth = max_depth_recursive(root.right)
return 1 + max(left_depth, right_depth)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 9, 20, None, None, 15, 7])
print(max_depth_recursive(root1)) # 3
root2 = build_tree([1, None, 2])
print(max_depth_recursive(root2)) # 2
root3 = build_tree([])
print(max_depth_recursive(root3)) # 0
root4 = build_tree([1])
print(max_depth_recursive(root4)) # 1
Complexity
- Time:
O(n)— every node is visited once - Space:
O(h)— recursion stack depth equals tree height
2. Iterative BFS (Level Counting)
Intuition
BFS processes nodes level by level. If we count how many levels we process before the queue is empty, that count is the maximum depth.
Algorithm
- If root is null, return 0
- Initialize a queue with root and a depth counter at 0
- For each level:
- Increment depth
- Process all nodes at this level, enqueuing their children
- Return depth when queue is empty
Solution
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def max_depth_bfs(root):
if not root:
return 0
queue = deque([root])
depth = 0
while queue:
depth += 1
# Process all nodes at the current level
for _ in range(len(queue)):
node = queue.popleft()
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return depth
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 9, 20, None, None, 15, 7])
print(max_depth_bfs(root1)) # 3
root2 = build_tree([1, None, 2])
print(max_depth_bfs(root2)) # 2
root3 = build_tree([])
print(max_depth_bfs(root3)) # 0
root4 = build_tree([1])
print(max_depth_bfs(root4)) # 1
Complexity
- Time:
O(n)— every node is enqueued and dequeued once - Space:
O(w)— queue holds at most the widest level of the tree (up to n/2 nodes)
Common Pitfalls
Returning height instead of depth. In this problem, depth and height of the whole tree are the same thing, but make sure you understand: depth is measured from root down, height is measured from the node up to the deepest leaf.
Off-by-one with the BFS counter. Increment depth at the start of processing each level, not at the end. The for _ in range(len(queue)) pattern is key — it snapshots the current level size so you process exactly those nodes.
Diameter of Binary Tree
Difficulty: Easy Source: NeetCode
Problem
Given the
rootof a binary tree, return the length of the diameter of the tree. The diameter of a binary tree is the length of the longest path between any two nodes in a tree. This path may or may not pass through the root. The length of a path between two nodes is represented by the number of edges between them.Example 1: Input: root = [1,2,3,4,5] Output: 3 Explanation: 3 is the length of the path [4,2,1,3] or [5,2,1,3].
Example 2: Input: root = [1,2] Output: 1
Constraints:
- The number of nodes in the tree is in the range
[1, 10^4]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Tree Height — The number of edges on the longest downward path from a node to a leaf
- DFS (Post-order) — Computing answers bottom-up from leaves to root
- Global State in Recursion — Using a variable outside the recursive call to track the answer
1. DFS with Global Max (Optimal)
Intuition
The diameter at any node is the number of edges in its longest path through that node: left_height + right_height. We don’t know which node will give the global maximum, so we compute this at every node and track the best seen so far. Crucially, the DFS function returns the height (not the diameter) because that’s what the parent needs to compute its own diameter.
Algorithm
- Initialize
max_diameter = 0 - Define a DFS function that returns the height of the subtree rooted at
node:- Base case: null node returns height 0
- Compute
left_heightandright_heightrecursively - Update
max_diameter = max(max_diameter, left_height + right_height) - Return
1 + max(left_height, right_height)— the height of this subtree
- Call DFS on root
- Return
max_diameter
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def diameter_of_binary_tree(root):
max_diameter = 0
def dfs(node):
nonlocal max_diameter
if not node:
return 0 # Height of null subtree
left_height = dfs(node.left)
right_height = dfs(node.right)
# Diameter through this node = edges going left + edges going right
max_diameter = max(max_diameter, left_height + right_height)
# Return height of this subtree to the parent
return 1 + max(left_height, right_height)
dfs(root)
return max_diameter
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, 2, 3, 4, 5])
print(diameter_of_binary_tree(root1)) # 3
root2 = build_tree([1, 2])
print(diameter_of_binary_tree(root2)) # 1
root3 = build_tree([1])
print(diameter_of_binary_tree(root3)) # 0
# Skewed tree: 1 -> 2 -> 3 -> 4 -> 5
skewed = build_tree([1, 2, None, 3, None, 4, None, 5])
print(diameter_of_binary_tree(skewed)) # 4
Complexity
- Time:
O(n)— each node is visited exactly once - Space:
O(h)— recursion stack depth equals tree height
Common Pitfalls
Thinking the diameter always passes through the root. It doesn’t. In a tree like [1,2,3,4,5,6,7], the diameter might be entirely within one subtree. That’s why we track the global max at every node.
Returning diameter instead of height from the DFS. The DFS helper must return height so the parent can compute its own diameter. If you return the diameter from DFS, you lose the information needed to propagate upward correctly.
Confusing edges vs nodes. The diameter is the number of edges, not nodes. A path with 4 nodes has 3 edges. Since we return 1 + max(left, right) from DFS (height in edges), and add left_height + right_height (both in edges), the math works out correctly without any adjustment.
Balanced Binary Tree
Difficulty: Easy Source: NeetCode
Problem
Given a binary tree, determine if it is height-balanced. A height-balanced binary tree is a binary tree in which the depth of the two subtrees of every node never differs by more than one.
Example 1: Input: root = [3,9,20,null,null,15,7] Output: true
Example 2: Input: root = [1,2,2,3,3,null,null,4,4] Output: false
Constraints:
- The number of nodes in the tree is in the range
[0, 5000]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Tree Height — Recursive computation of the longest path from a node to a leaf
- DFS Post-order — Computing results bottom-up
- Sentinel Values — Using -1 to signal an error/invalid state up the call stack
1. Naive Approach (Two Passes)
Intuition
The straightforward reading: for every node, check if abs(height(left) - height(right)) <= 1, then recurse on children. This is correct but slow — height is called repeatedly on the same nodes.
Algorithm
- Define
height(node)that returns the max depth of a subtree - Define
is_balanced(node):- If null, return true
- Check
abs(height(left) - height(right)) <= 1 - Recursively check both subtrees
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_balanced_naive(root):
def height(node):
if not node:
return 0
return 1 + max(height(node.left), height(node.right))
def check(node):
if not node:
return True
left_h = height(node.left)
right_h = height(node.right)
if abs(left_h - right_h) > 1:
return False
return check(node.left) and check(node.right)
return check(root)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 9, 20, None, None, 15, 7])
print(is_balanced_naive(root1)) # True
root2 = build_tree([1, 2, 2, 3, 3, None, None, 4, 4])
print(is_balanced_naive(root2)) # False
root3 = build_tree([])
print(is_balanced_naive(root3)) # True
Complexity
- Time:
O(n²)— height is called O(n) times, each taking O(n) in the worst case - Space:
O(h)— recursion depth
2. Optimal DFS (Single Pass)
Intuition
Instead of computing height separately, we combine the height computation with the balance check in one DFS pass. The function returns the height if the subtree is balanced, or -1 as a sentinel value to signal “this subtree is already unbalanced — stop early.”
Algorithm
- Define
dfs(node)returning height or -1 if unbalanced:- Null node returns height 0
- Compute left and right heights
- If either is -1, propagate -1 upward (early exit)
- If
abs(left - right) > 1, return -1 - Otherwise return
1 + max(left, right)
- Return
dfs(root) != -1
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_balanced(root):
def dfs(node):
if not node:
return 0 # Height of empty subtree
left_h = dfs(node.left)
if left_h == -1:
return -1 # Short-circuit: left is already unbalanced
right_h = dfs(node.right)
if right_h == -1:
return -1 # Short-circuit: right is already unbalanced
if abs(left_h - right_h) > 1:
return -1 # This node violates balance
return 1 + max(left_h, right_h)
return dfs(root) != -1
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 9, 20, None, None, 15, 7])
print(is_balanced(root1)) # True
root2 = build_tree([1, 2, 2, 3, 3, None, None, 4, 4])
print(is_balanced(root2)) # False
root3 = build_tree([])
print(is_balanced(root3)) # True
root4 = build_tree([1, 2, 2, 3, None, None, 3, 4, None, None, 4])
print(is_balanced(root4)) # False
Complexity
- Time:
O(n)— each node is visited exactly once - Space:
O(h)— recursion stack
Common Pitfalls
Checking only the root. A tree where the root’s children have heights 2 and 3 can still be unbalanced deeper down. The check must happen at every node.
Using -1 as both a valid return and a sentinel. Height is always >= 0 for existing nodes, so -1 is safe as a sentinel. If the problem involved negative values that could become heights, you’d need a different signal (like None or a named exception).
Same Tree
Difficulty: Easy Source: NeetCode
Problem
Given the roots of two binary trees
pandq, write a function to check if they are the same or not. Two binary trees are considered the same if they are structurally identical, and the nodes have the same value.Example 1: Input: p = [1,2,3], q = [1,2,3] Output: true
Example 2: Input: p = [1,2], q = [1,null,2] Output: false
Example 3: Input: p = [1,2,1], q = [1,1,2] Output: false
Constraints:
- The number of nodes in both trees is in the range
[0, 100]-10^4 <= Node.val <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary Trees — Structure and traversal of tree nodes
- Recursion — Comparing two trees by comparing their subproblems
- Short-circuit Logic — Returning early when a mismatch is found
1. Recursive DFS
Intuition
Two trees are the same if: their roots have the same value, their left subtrees are the same, and their right subtrees are the same. This decomposes perfectly into recursion. The base cases handle structural differences: if both are null they match, if only one is null they don’t.
Algorithm
- If both nodes are null → return true (both trees “end” here)
- If exactly one is null → return false (structural mismatch)
- If
p.val != q.val→ return false (value mismatch) - Return
same_tree(p.left, q.left) and same_tree(p.right, q.right)
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_same_tree(p, q):
# Both null: they match at this position
if not p and not q:
return True
# One null, one not: structure differs
if not p or not q:
return False
# Values differ
if p.val != q.val:
return False
# Recurse on both subtrees
return is_same_tree(p.left, q.left) and is_same_tree(p.right, q.right)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
p1 = build_tree([1, 2, 3])
q1 = build_tree([1, 2, 3])
print(is_same_tree(p1, q1)) # True
p2 = build_tree([1, 2])
q2 = build_tree([1, None, 2])
print(is_same_tree(p2, q2)) # False
p3 = build_tree([1, 2, 1])
q3 = build_tree([1, 1, 2])
print(is_same_tree(p3, q3)) # False
p4 = build_tree([])
q4 = build_tree([])
print(is_same_tree(p4, q4)) # True
Complexity
- Time:
O(n)— at most n nodes visited where n = min(size(p), size(q)) - Space:
O(h)— recursion stack depth proportional to tree height
2. Iterative (Stack-Based)
Intuition
We can do the same comparison without recursion by using a stack of node pairs. At each step, pop a pair and apply the same three checks as the recursive version.
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_same_tree_iterative(p, q):
stack = [(p, q)]
while stack:
node1, node2 = stack.pop()
if not node1 and not node2:
continue
if not node1 or not node2:
return False
if node1.val != node2.val:
return False
stack.append((node1.left, node2.left))
stack.append((node1.right, node2.right))
return True
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
p1 = build_tree([1, 2, 3])
q1 = build_tree([1, 2, 3])
print(is_same_tree_iterative(p1, q1)) # True
p2 = build_tree([1, 2])
q2 = build_tree([1, None, 2])
print(is_same_tree_iterative(p2, q2)) # False
Complexity
- Time:
O(n)— each node pair is processed once - Space:
O(h)— stack size proportional to tree height
Common Pitfalls
Checking only values, not structure. [1,2] and [1,null,2] have the same values but different structures. The null checks for structural equality must come before value comparison.
Short-circuiting too eagerly. The and in same_tree(left) and same_tree(right) already short-circuits — if the left subtrees differ, Python won’t even evaluate the right. This is efficient by default.
Subtree of Another Tree
Difficulty: Easy Source: NeetCode
Problem
Given the roots of two binary trees
rootandsubRoot, returntrueif there is a subtree ofrootwith the same structure and node values ofsubRootandfalseotherwise. A subtree of a binary tree is a tree that consists of a node in the tree and all of this node’s descendants. The tree itself is a subtree of itself.Example 1: Input: root = [3,4,5,1,2], subRoot = [4,1,2] Output: true
Example 2: Input: root = [3,4,5,1,2,null,null,null,null,0], subRoot = [4,1,2] Output: false
Constraints:
- The number of nodes in the
roottree is in the range[1, 2000]- The number of nodes in the
subRoottree is in the range[1, 1000]-10^4 <= root.val, subRoot.val <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Same Tree Problem — The core check used at every node
- DFS — Traversing every node of the main tree as a candidate root
- Tree Serialization — Used in the advanced O(m+n) approach
1. Naive DFS + Same Tree Check
Intuition
At each node of the main tree, check if the subtree rooted at that node is identical to subRoot. We reuse the is_same_tree function from the Same Tree problem. If it matches anywhere, return true.
Algorithm
- If
rootis null, return false (subRoot can’t match nothing) - If
is_same_tree(root, subRoot), return true - Otherwise, check
is_subtree(root.left, subRoot)oris_subtree(root.right, subRoot)
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_same_tree(p, q):
if not p and not q:
return True
if not p or not q:
return False
if p.val != q.val:
return False
return is_same_tree(p.left, q.left) and is_same_tree(p.right, q.right)
def is_subtree(root, sub_root):
if not root:
return False
if is_same_tree(root, sub_root):
return True
return is_subtree(root.left, sub_root) or is_subtree(root.right, sub_root)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 4, 5, 1, 2])
sub1 = build_tree([4, 1, 2])
print(is_subtree(root1, sub1)) # True
root2 = build_tree([3, 4, 5, 1, 2, None, None, None, None, 0])
sub2 = build_tree([4, 1, 2])
print(is_subtree(root2, sub2)) # False
root3 = build_tree([1, 1])
sub3 = build_tree([1])
print(is_subtree(root3, sub3)) # True
Complexity
- Time:
O(m * n)— for each of m nodes in root, we may call same_tree which takes O(n) - Space:
O(h)— recursion depth
2. Serialization Approach (O(m + n))
Intuition
Serialize both trees into strings using a preorder DFS with special null markers. Then check if the serialized subRoot string is a substring of the serialized root string. We use markers like #null and # prefixes to avoid false matches (e.g., value 1 matching part of value 12).
Algorithm
- Serialize
rootandsubRootto strings via preorder DFS - Return
serialize(subRoot) in serialize(root)
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_subtree_fast(root, sub_root):
def serialize(node):
if not node:
return "#null"
# Prefix each value with '#' to avoid partial-number matches
return f"#{node.val}{serialize(node.left)}{serialize(node.right)}"
return serialize(sub_root) in serialize(root)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 4, 5, 1, 2])
sub1 = build_tree([4, 1, 2])
print(is_subtree_fast(root1, sub1)) # True
root2 = build_tree([3, 4, 5, 1, 2, None, None, None, None, 0])
sub2 = build_tree([4, 1, 2])
print(is_subtree_fast(root2, sub2)) # False
Complexity
- Time:
O(m + n)for serialization; substring search adds O(m * n) in worst case with naive search, but can be O(m + n) with KMP - Space:
O(m + n)— storing the serialized strings
Common Pitfalls
Missing the # prefix on node values. Without it, a tree with value 12 would falsely match a subtree with value 1 followed by 2. The prefix ensures each value is a distinct token.
Checking null incorrectly. is_subtree(null, non-null-subRoot) should return false. If subRoot is null, some definitions return true (empty tree is a subtree of everything) — check the problem statement carefully.
Lowest Common Ancestor of a BST
Difficulty: Medium Source: NeetCode
Problem
Given a binary search tree (BST), find the lowest common ancestor (LCA) node of two given nodes in the BST. The lowest common ancestor is defined as “the lowest node in T that has both p and q as descendants (where we allow a node to be a descendant of itself).”
Example 1: Input: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8 Output: 6
Example 2: Input: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4 Output: 2
Constraints:
- The number of nodes in the tree is in the range
[2, 10^5]-10^9 <= Node.val <= 10^9- All
Node.valare uniquep != qpandqwill exist in the BST
Prerequisites
Before attempting this problem, you should be comfortable with:
- BST Property — Left subtree values < node value < right subtree values
- LCA Concept — The deepest node that has both target nodes in its subtree
- Binary Search — Using the BST property to eliminate half the tree at each step
1. Recursive (Using BST Property)
Intuition
The BST property lets us decide which direction to go without exploring both sides. If both p and q are smaller than the current node, the LCA must be in the left subtree. If both are larger, it’s in the right subtree. If they’re on opposite sides (or one equals the current node), the current node is the LCA.
Algorithm
- If both
p.valandq.valare less thanroot.val, recurse left - If both are greater than
root.val, recurse right - Otherwise,
rootis the LCA — return it
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def lowest_common_ancestor_recursive(root, p, q):
# Both smaller → LCA is in left subtree
if p.val < root.val and q.val < root.val:
return lowest_common_ancestor_recursive(root.left, p, q)
# Both larger → LCA is in right subtree
if p.val > root.val and q.val > root.val:
return lowest_common_ancestor_recursive(root.right, p, q)
# Split point: one is left, one is right (or one equals root)
return root
# --- helpers ---
def build_bst_from_sorted(values):
"""Build BST from level-order list."""
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def find_node(root, val):
"""Find a node by value in the BST."""
while root:
if val == root.val:
return root
elif val < root.val:
root = root.left
else:
root = root.right
return None
# --- tests ---
# BST: [6,2,8,0,4,7,9,null,null,3,5]
root = build_bst_from_sorted([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5])
p = find_node(root, 2)
q = find_node(root, 8)
lca = lowest_common_ancestor_recursive(root, p, q)
print(lca.val) # 6
p = find_node(root, 2)
q = find_node(root, 4)
lca = lowest_common_ancestor_recursive(root, p, q)
print(lca.val) # 2
Complexity
- Time:
O(h)— at each level we go either left or right; O(log n) for balanced BST, O(n) worst case - Space:
O(h)— recursion stack depth
2. Iterative (O(h) Time, O(1) Space)
Intuition
The recursive version has O(h) stack overhead. We can eliminate that by iterating with a pointer, making this the most space-efficient approach.
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def lowest_common_ancestor_iterative(root, p, q):
curr = root
while curr:
if p.val < curr.val and q.val < curr.val:
curr = curr.left
elif p.val > curr.val and q.val > curr.val:
curr = curr.right
else:
return curr # Split point found
return None # Should never reach here given the constraints
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def find_node(root, val):
while root:
if val == root.val:
return root
root = root.left if val < root.val else root.right
return None
# --- tests ---
root = build_tree([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5])
p = find_node(root, 2)
q = find_node(root, 8)
print(lowest_common_ancestor_iterative(root, p, q).val) # 6
p = find_node(root, 2)
q = find_node(root, 4)
print(lowest_common_ancestor_iterative(root, p, q).val) # 2
Complexity
- Time:
O(h)— same as recursive - Space:
O(1)— no recursion stack
Common Pitfalls
Using a general tree LCA algorithm on a BST. The BST property lets you solve this in O(h) without exploring both subtrees. A general LCA algorithm that ignores this property runs in O(n) — less efficient.
Not handling the case where p or q equals root. If p.val == root.val or q.val == root.val, neither the left nor right condition triggers, so we fall through to return root — which is correct because a node is a descendant of itself.
Insert into a BST
Difficulty: Medium Source: NeetCode
Problem
You are given the
rootnode of a binary search tree (BST) and avalueto insert into the tree. Return the root node of the BST after the insertion. It is guaranteed that the new value does not exist in the original BST. Notice that there may exist multiple valid ways for the insertion, as long as the tree remains a BST after insertion. You can return any of them.Example 1: Input: root = [4,2,7,1,3], val = 5 Output: [4,2,7,1,3,5]
Example 2: Input: root = [40,20,60,10,30,50,70], val = 25 Output: [40,20,60,10,30,50,70,null,null,25]
Constraints:
- The number of nodes in the tree is in the range
[0, 10^4]-10^8 <= Node.val <= 10^8- All the values
Node.valare unique-10^8 <= val <= 10^8- It’s guaranteed that
valdoes not exist in the original BST
Prerequisites
Before attempting this problem, you should be comfortable with:
- BST Property — Values smaller than a node go left, larger go right
- Recursion — Building the new tree by returning updated subtrees
- Pointer Manipulation — Attaching a new node at the correct leaf position
1. Recursive Insertion
Intuition
Follow the BST property to find where the new value belongs. When you reach a null position, that’s where the new node goes. The recursive version elegantly handles this by returning a new node when at null, which gets assigned to the parent’s left or right pointer.
Algorithm
- If
rootis null, create and return a new node withval - If
val < root.val, recurse left and assign the result toroot.left - If
val > root.val, recurse right and assign the result toroot.right - Return
root
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def insert_into_bst_recursive(root, val):
if not root:
return TreeNode(val) # Found the insertion spot
if val < root.val:
root.left = insert_into_bst_recursive(root.left, val)
else:
root.right = insert_into_bst_recursive(root.right, val)
return root
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def inorder(root):
"""Inorder of a BST gives sorted order."""
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# --- tests ---
root1 = build_tree([4, 2, 7, 1, 3])
root1 = insert_into_bst_recursive(root1, 5)
print(inorder(root1)) # [1, 2, 3, 4, 5, 7]
root2 = build_tree([40, 20, 60, 10, 30, 50, 70])
root2 = insert_into_bst_recursive(root2, 25)
print(inorder(root2)) # [10, 20, 25, 30, 40, 50, 60, 70]
root3 = build_tree([])
root3 = insert_into_bst_recursive(root3, 5)
print(inorder(root3)) # [5]
Complexity
- Time:
O(h)— traverse one path from root to insertion point - Space:
O(h)— recursion stack depth
2. Iterative Insertion
Intuition
Walk down the tree keeping track of the current node and its parent. When you fall off the tree (reach null), attach the new node to the parent at the correct side.
Algorithm
- Handle empty tree edge case
- Walk with
currpointer, comparingvalto navigate left or right - When
curris null, we’ve found the insertion spot — attach toparent
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def insert_into_bst_iterative(root, val):
new_node = TreeNode(val)
if not root:
return new_node
curr = root
while True:
if val < curr.val:
if not curr.left:
curr.left = new_node
break
curr = curr.left
else:
if not curr.right:
curr.right = new_node
break
curr = curr.right
return root
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# --- tests ---
root1 = build_tree([4, 2, 7, 1, 3])
root1 = insert_into_bst_iterative(root1, 5)
print(inorder(root1)) # [1, 2, 3, 4, 5, 7]
root2 = build_tree([40, 20, 60, 10, 30, 50, 70])
root2 = insert_into_bst_iterative(root2, 25)
print(inorder(root2)) # [10, 20, 25, 30, 40, 50, 60, 70]
Complexity
- Time:
O(h)— one path from root to leaf - Space:
O(1)— no recursion stack
Common Pitfalls
Forgetting to return root in the recursive version. The recursive function must return root at the end so the tree structure is preserved when you assign root.left = recurse(root.left, val).
Not handling the empty tree case. If root is null, just return the new node. Forgetting this causes a null pointer error.
Delete Node in a BST
Difficulty: Medium Source: NeetCode
Problem
Given a root node reference of a BST and a key, delete the node with the given key in the BST. Return the root node reference (possibly updated) of the BST. The deletion can be divided into two stages: search for a node to remove, and if the node is found, delete the node.
Example 1: Input: root = [5,3,6,2,4,null,7], key = 3 Output: [5,4,6,2,null,null,7]
Example 2: Input: root = [5,3,6,2,4,null,7], key = 0 Output: [5,3,6,2,4,null,7]
Constraints:
- The number of nodes in the tree is in the range
[0, 10^4]-10^5 <= Node.val <= 10^5- Each node has a unique value
rootis a valid BST-10^5 <= key <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- BST Property — Used to locate the node to delete
- Inorder Successor — The smallest value in the right subtree; used to replace a deleted node with two children
- Recursive Tree Modification — Returning updated subtree roots to rewire the tree
1. Recursive DFS
Intuition
Deletion has three distinct cases depending on how many children the target node has:
- No children (leaf): Just remove it — return null to the parent
- One child: Replace the node with its only child
- Two children: Can’t just remove it. Replace its value with the inorder successor (smallest in the right subtree), then delete the successor from the right subtree
Algorithm
- If
rootis null, return null (key not found) - If
key < root.val, recurse left:root.left = delete(root.left, key) - If
key > root.val, recurse right:root.right = delete(root.right, key) - Otherwise,
rootis the node to delete:- No left child → return
root.right - No right child → return
root.left - Two children → find min in right subtree, set
root.val = min.val, delete min from right subtree
- No left child → return
- Return
root
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def delete_node(root, key):
if not root:
return None # Key not found
if key < root.val:
root.left = delete_node(root.left, key)
elif key > root.val:
root.right = delete_node(root.right, key)
else:
# Found the node to delete
if not root.left:
return root.right # No left child: replace with right
if not root.right:
return root.left # No right child: replace with left
# Two children: find inorder successor (min in right subtree)
successor = root.right
while successor.left:
successor = successor.left
# Replace value with successor's value
root.val = successor.val
# Delete the successor from the right subtree
root.right = delete_node(root.right, successor.val)
return root
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# --- tests ---
root1 = build_tree([5, 3, 6, 2, 4, None, 7])
print("Before:", inorder(root1)) # [2, 3, 4, 5, 6, 7]
root1 = delete_node(root1, 3)
print("After deleting 3:", inorder(root1)) # [2, 4, 5, 6, 7]
root2 = build_tree([5, 3, 6, 2, 4, None, 7])
root2 = delete_node(root2, 0) # Key not in tree
print("After deleting 0:", inorder(root2)) # [2, 3, 4, 5, 6, 7]
root3 = build_tree([5, 3, 6, 2, 4, None, 7])
root3 = delete_node(root3, 5) # Delete root
print("After deleting root 5:", inorder(root3)) # [2, 3, 4, 6, 7]
# Delete a leaf
root4 = build_tree([5, 3, 6, 2, 4, None, 7])
root4 = delete_node(root4, 2)
print("After deleting leaf 2:", inorder(root4)) # [3, 4, 5, 6, 7]
Complexity
- Time:
O(h)— search takes O(h), finding successor takes O(h), deletion takes O(h) - Space:
O(h)— recursion stack
Common Pitfalls
Forgetting to reassign root.left and root.right. The recursive call returns the updated subtree root. If you don’t assign root.left = delete(root.left, key), the tree won’t actually be modified.
Handling the two-children case incorrectly. A common mistake is trying to physically remove the successor node before updating the current node’s value, causing issues. The correct order is: copy the successor’s value into the current node, then delete the successor from the right subtree.
Confusing inorder successor with inorder predecessor. You can use either (min of right subtree OR max of left subtree) — both are valid. Just be consistent.
Binary Tree Level Order Traversal
Difficulty: Medium Source: NeetCode
Problem
Given the
rootof a binary tree, return the level order traversal of its nodes’ values (i.e., from left to right, level by level).Example 1: Input: root = [3,9,20,null,null,15,7] Output: [[3],[9,20],[15,7]]
Example 2: Input: root = [1] Output: [[1]]
Example 3: Input: root = [] Output: []
Constraints:
- The number of nodes in the tree is in the range
[0, 2000]-1000 <= Node.val <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Queue (FIFO) — The data structure that naturally gives us left-to-right level-by-level processing
- BFS — Breadth-first search explores nodes in the exact order we need
- Level Snapshots — The trick of snapshotting the queue size to separate levels
1. BFS with Queue
Intuition
BFS is perfect for level-order traversal. The key insight is that at any point in the BFS, all nodes currently in the queue belong to the same level. By snapshotting len(queue) at the start of each “round,” we can process exactly one level at a time, collect its values, and enqueue the next level’s nodes.
Algorithm
- If root is null, return
[] - Initialize a queue with root, and an empty result list
- While the queue is not empty:
- Snapshot
level_size = len(queue) - Process exactly
level_sizenodes, collecting their values intocurrent_level - Enqueue each node’s left and right children (if they exist)
- Append
current_levelto result
- Snapshot
- Return result
Solution
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def level_order(root):
if not root:
return []
result = []
queue = deque([root])
while queue:
level_size = len(queue) # Number of nodes at this level
current_level = []
for _ in range(level_size):
node = queue.popleft()
current_level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(current_level)
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 9, 20, None, None, 15, 7])
print(level_order(root1)) # [[3], [9, 20], [15, 7]]
root2 = build_tree([1])
print(level_order(root2)) # [[1]]
root3 = build_tree([])
print(level_order(root3)) # []
root4 = build_tree([1, 2, 3, 4, 5, 6, 7])
print(level_order(root4)) # [[1], [2, 3], [4, 5, 6, 7]]
Complexity
- Time:
O(n)— every node is enqueued and dequeued exactly once - Space:
O(w)— queue holds at most the widest level (up to n/2 nodes for a complete tree)
2. DFS Alternative
Intuition
Level order can also be done with DFS by passing the current level depth. We use a list of lists where index i holds all values at depth i. As DFS explores nodes, it appends each value to the correct list based on depth.
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def level_order_dfs(root):
result = []
def dfs(node, depth):
if not node:
return
if depth == len(result):
result.append([]) # Start a new level
result[depth].append(node.val)
dfs(node.left, depth + 1)
dfs(node.right, depth + 1)
dfs(root, 0)
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 9, 20, None, None, 15, 7])
print(level_order_dfs(root1)) # [[3], [9, 20], [15, 7]]
root2 = build_tree([1, 2, 3, 4, 5, 6, 7])
print(level_order_dfs(root2)) # [[1], [2, 3], [4, 5, 6, 7]]
Complexity
- Time:
O(n)— each node visited once - Space:
O(h)for the call stack +O(n)for the result
Common Pitfalls
Not snapshotting the queue length. If you just loop while queue, you’ll mix multiple levels together. The for _ in range(len(queue)) loop is the key mechanism that processes one level at a time.
Using a list instead of deque. list.pop(0) is O(n); deque.popleft() is O(1). For large trees, always use collections.deque for BFS queues.
Binary Tree Right Side View
Difficulty: Medium Source: NeetCode
Problem
Given the
rootof a binary tree, imagine yourself standing on the right side of it, return the values of the nodes you can see ordered from top to bottom.Example 1: Input: root = [1,2,3,null,5,null,4] Output: [1,3,4]
Example 2: Input: root = [1,null,3] Output: [1,3]
Example 3: Input: root = [] Output: []
Constraints:
- The number of nodes in the tree is in the range
[0, 100]-100 <= Node.val <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- BFS / Level Order Traversal — Processing nodes level by level
- DFS with Depth Tracking — Visiting the right side first to see the first node at each depth
- Right Side View Intuition — The rightmost node visible at each level
1. BFS (Last Node Per Level)
Intuition
Level order traversal naturally gives us all nodes at each level in left-to-right order. The rightmost visible node is simply the last node at each level. We collect level order results and pick the last element of each level list.
Algorithm
- Use BFS with a queue
- For each level, process all nodes in the queue snapshot
- Record the last node’s value of each level
- Return the collected values
Solution
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def right_side_view_bfs(root):
if not root:
return []
result = []
queue = deque([root])
while queue:
level_size = len(queue)
for i in range(level_size):
node = queue.popleft()
if i == level_size - 1: # Last node in this level
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, 2, 3, None, 5, None, 4])
print(right_side_view_bfs(root1)) # [1, 3, 4]
root2 = build_tree([1, None, 3])
print(right_side_view_bfs(root2)) # [1, 3]
root3 = build_tree([])
print(right_side_view_bfs(root3)) # []
root4 = build_tree([1, 2])
print(right_side_view_bfs(root4)) # [1, 2] (only left child exists)
Complexity
- Time:
O(n)— every node processed once - Space:
O(w)— queue holds at most the widest level
2. DFS (Right-First, Track Depth)
Intuition
In DFS, if we visit the right child before the left child, then the first time we reach a new depth, that node is the rightmost node visible at that depth. We track which depths we’ve already added to the result.
Algorithm
- DFS with
depthparameter, starting at 0 - If
depth == len(result), this is the first (rightmost) node at this depth — add it - Recurse right first, then left
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def right_side_view_dfs(root):
result = []
def dfs(node, depth):
if not node:
return
if depth == len(result):
result.append(node.val) # First node at this depth = rightmost
dfs(node.right, depth + 1) # Visit right first!
dfs(node.left, depth + 1)
dfs(root, 0)
return result
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, 2, 3, None, 5, None, 4])
print(right_side_view_dfs(root1)) # [1, 3, 4]
root2 = build_tree([1, 2])
print(right_side_view_dfs(root2)) # [1, 2]
root3 = build_tree([1, None, 3])
print(right_side_view_dfs(root3)) # [1, 3]
Complexity
- Time:
O(n)— every node visited once - Space:
O(h)— recursion stack depth
Common Pitfalls
Assuming rightmost always means the right child. A node on the left subtree can be the rightmost visible node at its depth if the right subtree doesn’t extend that far. The example [1,2,3,null,5,null,4] shows this — node 4 is a right child of node 3, while node 5 is left child of node 2, both at depth 2, but 4 wins.
Visiting left before right in the DFS approach. If you visit left first, the first node at each depth will be the leftmost, not the rightmost. For right side view, always go right first in DFS.
Construct Quad Tree
Difficulty: Medium Source: NeetCode
Problem
Given a
n * nmatrixgridof0s and1s only, we want to representgridwith a Quad-Tree. Return the root of the Quad-Tree representinggrid.A Quad-Tree is a tree data structure in which each internal node has exactly four children. Each node has two attributes:
val(True if leaf has 1s, or for internal nodes any value) andisLeaf(True if this node is a leaf representing a uniform region).Example 1: Input: grid = [[0,1],[1,0]] Output: [[0,1],[1,0],[0,1],[1,1],[1,0]]
Example 2: Input: grid = [[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0]] Output: [[0,1],[1,1],[0,1],[1,1],[1,0],null,null,null,null,[1,0],[1,0],[1,1],[1,1]]
Constraints:
n == grid.length == grid[i].lengthn == 2^xwhere0 <= x <= 61 <= n <= 64grid[i][j]is either0or1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Divide and Conquer — Splitting a problem into four equal quadrants
- Recursion — Solving the same problem on each quadrant
- Grid Subregions — Working with row/column offsets and sizes
1. Recursive Divide and Conquer
Intuition
Build the quad tree by recursively dividing the grid into four equal quadrants. At each step, check if the current region is uniform (all 0s or all 1s). If it is, create a leaf node. If not, split into four quadrants (top-left, top-right, bottom-left, bottom-right) and recurse on each.
Algorithm
- Check if all values in the current
(row, col, size)region are the same - If uniform → create a leaf node with that value
- If not uniform → split into 4 quadrants, recurse on each, create internal node
Solution
class Node:
def __init__(self, val, isLeaf, topLeft=None, topRight=None,
bottomLeft=None, bottomRight=None):
self.val = val
self.isLeaf = isLeaf
self.topLeft = topLeft
self.topRight = topRight
self.bottomLeft = bottomLeft
self.bottomRight = bottomRight
def construct(grid):
def is_uniform(row, col, size):
"""Check if all cells in the subgrid have the same value."""
val = grid[row][col]
for r in range(row, row + size):
for c in range(col, col + size):
if grid[r][c] != val:
return False
return True
def build(row, col, size):
if is_uniform(row, col, size):
# Leaf node: entire region has the same value
return Node(val=bool(grid[row][col]), isLeaf=True)
half = size // 2
# Split into 4 quadrants
return Node(
val=True, # val for internal nodes can be anything
isLeaf=False,
topLeft=build(row, col, half),
topRight=build(row, col + half, half),
bottomLeft=build(row + half, col, half),
bottomRight=build(row + half, col + half, half),
)
return build(0, 0, len(grid))
# --- tests ---
grid1 = [[0, 1], [1, 0]]
root1 = construct(grid1)
print(root1.isLeaf) # False (not uniform)
print(root1.topLeft.val) # False (0)
print(root1.topRight.val) # True (1)
grid2 = [[1, 1], [1, 1]]
root2 = construct(grid2)
print(root2.isLeaf) # True (uniform)
print(root2.val) # True (all 1s)
grid3 = [
[1, 1, 1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0, 0],
]
root3 = construct(grid3)
print(root3.isLeaf) # False
# Verify structure
def print_tree(node, indent=0):
if not node:
return
prefix = " " * indent
if node.isLeaf:
print(f"{prefix}Leaf({int(node.val)})")
else:
print(f"{prefix}Internal")
print_tree(node.topLeft, indent + 1)
print_tree(node.topRight, indent + 1)
print_tree(node.bottomLeft, indent + 1)
print_tree(node.bottomRight, indent + 1)
print_tree(root1)
Complexity
- Time:
O(n² log n)— at each of the log n recursion levels we scan O(n²) cells for uniformity - Space:
O(log n)— recursion depth equals number of times we can halve n
Optimization: Prefix Sums for O(1) Uniformity Check
Intuition
Instead of scanning the region every time to check uniformity, precompute a 2D prefix sum. Then checking if a region is uniform is just checking if the sum equals size * size (all 1s) or 0 (all 0s).
Solution
class Node:
def __init__(self, val, isLeaf, topLeft=None, topRight=None,
bottomLeft=None, bottomRight=None):
self.val = val
self.isLeaf = isLeaf
self.topLeft = topLeft
self.topRight = topRight
self.bottomLeft = bottomLeft
self.bottomRight = bottomRight
def construct_optimized(grid):
n = len(grid)
# Build 2D prefix sums
prefix = [[0] * (n + 1) for _ in range(n + 1)]
for r in range(n):
for c in range(n):
prefix[r + 1][c + 1] = (grid[r][c] + prefix[r][c + 1] +
prefix[r + 1][c] - prefix[r][c])
def region_sum(row, col, size):
r1, c1 = row, col
r2, c2 = row + size, col + size
return prefix[r2][c2] - prefix[r1][c2] - prefix[r2][c1] + prefix[r1][c1]
def build(row, col, size):
total = region_sum(row, col, size)
if total == 0:
return Node(val=False, isLeaf=True)
if total == size * size:
return Node(val=True, isLeaf=True)
half = size // 2
return Node(
val=True,
isLeaf=False,
topLeft=build(row, col, half),
topRight=build(row, col + half, half),
bottomLeft=build(row + half, col, half),
bottomRight=build(row + half, col + half, half),
)
return build(0, 0, n)
# --- test ---
grid = [[0, 1], [1, 0]]
root = construct_optimized(grid)
print(root.isLeaf) # False
print(root.topLeft.val) # False
print(root.topRight.val) # True
Complexity
- Time:
O(n²)— prefix sum build is O(n²); each region check is O(1); we have O(n²/size²) regions at each level summing to O(n²) total - Space:
O(n²)— prefix sum table
Common Pitfalls
Splitting incorrectly. Each quadrant must be exactly size // 2 x size // 2. Off-by-one errors in row/col offsets will corrupt the structure.
Internal node val. For internal nodes, val doesn’t carry a meaningful value per the problem definition — it can be anything. Don’t worry about setting it “correctly” for internal nodes.
Count Good Nodes in Binary Tree
Difficulty: Medium Source: NeetCode
Problem
Given a binary tree
root, a node X in the tree is named good if in the path from root to X there are no nodes with a value greater than X’s value. Return the number of good nodes in the binary tree.Example 1: Input: root = [3,1,4,3,null,1,5] Output: 4 Explanation: Nodes 3 (root), 4, 3 (left-left), and 5 are good.
Example 2: Input: root = [3,3,null,4,2] Output: 3
Constraints:
- The number of nodes in the binary tree is in the range
[1, 10^5]- Each node’s value is between
[-10^4, 10^4]
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS with State — Passing extra information (max seen so far) down the recursion
- Path Properties — Understanding that a condition depends on the entire root-to-node path
- Preorder DFS — Processing the current node before its children
1. DFS (Preorder, Passing Max)
Intuition
A node is “good” if its value is greater than or equal to every value on the path from the root to it. Equivalently, it’s good if its value >= the maximum value seen so far on the path. We can track this max as we go down the tree. At each node, check if node.val >= max_so_far, then update the max and recurse.
Algorithm
- Define DFS with parameters
(node, max_so_far) - If node is null, return 0
- Count this node as good if
node.val >= max_so_far - Update
new_max = max(max_so_far, node.val) - Return
is_good + dfs(left, new_max) + dfs(right, new_max)
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def good_nodes(root):
def dfs(node, max_so_far):
if not node:
return 0
# This node is good if its value >= max value on the path from root
is_good = 1 if node.val >= max_so_far else 0
# Update the running max for children
new_max = max(max_so_far, node.val)
return is_good + dfs(node.left, new_max) + dfs(node.right, new_max)
# Start with -infinity so the root always counts as good
return dfs(root, float('-inf'))
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
# Tree: [3,1,4,3,null,1,5]
# 3
# / \
# 1 4
# / / \
# 3 1 5
root1 = build_tree([3, 1, 4, 3, None, 1, 5])
print(good_nodes(root1)) # 4 (nodes: 3, 4, 3, 5)
# Tree: [3,3,null,4,2]
# 3
# /
# 3
# / \
# 4 2
root2 = build_tree([3, 3, None, 4, 2])
print(good_nodes(root2)) # 3 (nodes: 3, 3, 4)
root3 = build_tree([1])
print(good_nodes(root3)) # 1
# All negative values
root4 = build_tree([-1, -2, -3])
print(good_nodes(root4)) # 1 (only root is good: -1 >= -inf; -2 < -1; -3 < -1)
Complexity
- Time:
O(n)— every node is visited exactly once - Space:
O(h)— recursion stack depth equals tree height
2. Iterative DFS (Stack with State)
Intuition
Same idea as the recursive version but we manage the stack explicitly. Push tuples of (node, max_so_far) onto the stack.
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def good_nodes_iterative(root):
if not root:
return 0
count = 0
stack = [(root, float('-inf'))]
while stack:
node, max_so_far = stack.pop()
if node.val >= max_so_far:
count += 1
new_max = max(max_so_far, node.val)
if node.left:
stack.append((node.left, new_max))
if node.right:
stack.append((node.right, new_max))
return count
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 1, 4, 3, None, 1, 5])
print(good_nodes_iterative(root1)) # 4
root2 = build_tree([3, 3, None, 4, 2])
print(good_nodes_iterative(root2)) # 3
Complexity
- Time:
O(n)— every node processed once - Space:
O(h)— stack size
Common Pitfalls
Starting max_so_far at 0. If tree values are all negative, initializing to 0 would incorrectly mark the root as not good. Always initialize to float('-inf') so the root is always counted.
Checking node.val > max_so_far instead of >=. The problem says “no node with a value strictly greater than X” on the path — so a node is good if its value is >= the running max. A tie (equal value) still counts as good.
Validate Binary Search Tree
Difficulty: Medium Source: NeetCode
Problem
Given the
rootof a binary tree, determine if it is a valid binary search tree (BST). A valid BST is defined as follows:
- The left subtree of a node contains only nodes with keys strictly less than the node’s key.
- The right subtree of a node contains only nodes with keys strictly greater than the node’s key.
- Both the left and right subtrees must also be valid BSTs.
Example 1: Input: root = [2,1,3] Output: true
Example 2: Input: root = [5,1,4,null,null,3,6] Output: false Explanation: The root node’s value is 5 but its right child’s value is 4.
Constraints:
- The number of nodes in the tree is in the range
[1, 10^4]-2^31 <= Node.val <= 2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- BST Property — Every node must satisfy constraints relative to ALL ancestors, not just its parent
- DFS with Bounds — Passing min/max constraints down the tree
- Inorder Traversal — Inorder of a valid BST produces a strictly increasing sequence
1. Brute Force: Inorder Traversal
Intuition
Inorder traversal of a valid BST always produces values in strictly increasing order. So we can do an inorder traversal, collect values, and check that each value is strictly greater than the previous one.
Algorithm
- Perform inorder traversal to get values in order
- Check that the list is strictly increasing
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_valid_bst_inorder(root):
values = []
def inorder(node):
if not node:
return
inorder(node.left)
values.append(node.val)
inorder(node.right)
inorder(root)
for i in range(1, len(values)):
if values[i] <= values[i - 1]: # Must be strictly increasing
return False
return True
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([2, 1, 3])
print(is_valid_bst_inorder(root1)) # True
root2 = build_tree([5, 1, 4, None, None, 3, 6])
print(is_valid_bst_inorder(root2)) # False
root3 = build_tree([5, 4, 6, None, None, 3, 7])
print(is_valid_bst_inorder(root3)) # False (3 < 5 but it's in right subtree)
Complexity
- Time:
O(n)— full traversal - Space:
O(n)— storing all node values
2. Optimal: DFS with Min/Max Bounds
Intuition
At each node, instead of checking only against its parent, we track the valid range (min, max) that the node’s value must fall within. When going left, the upper bound tightens to the current node’s value. When going right, the lower bound tightens. This catches the case where a value in the right subtree of an ancestor is less than that ancestor’s value.
Algorithm
- Define
dfs(node, min_val, max_val)returning bool - Base case: null node is valid
- If
node.val <= min_valornode.val >= max_val, return false - Recurse: left with
(min_val, node.val), right with(node.val, max_val) - Start with
dfs(root, -inf, +inf)
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_valid_bst(root):
def dfs(node, min_val, max_val):
if not node:
return True
if node.val <= min_val or node.val >= max_val:
return False
# Left subtree: all values must be < node.val (tighten upper bound)
# Right subtree: all values must be > node.val (tighten lower bound)
return (dfs(node.left, min_val, node.val) and
dfs(node.right, node.val, max_val))
return dfs(root, float('-inf'), float('inf'))
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([2, 1, 3])
print(is_valid_bst(root1)) # True
root2 = build_tree([5, 1, 4, None, None, 3, 6])
print(is_valid_bst(root2)) # False
# Tricky: [5,4,6,null,null,3,7] — node 3 is in right subtree of 5, violates BST
root3 = build_tree([5, 4, 6, None, None, 3, 7])
print(is_valid_bst(root3)) # False
root4 = build_tree([1, 1]) # Duplicate values — not a valid BST
print(is_valid_bst(root4)) # False
Complexity
- Time:
O(n)— each node visited once - Space:
O(h)— recursion stack
Common Pitfalls
Only comparing against the parent node. A node [5, 4, 6, null, null, 3, 7] fails because 3 is in the right subtree of 5 but less than 5. Checking only 3 > 6 (parent) passes, but we also need 3 > 5 (grandparent). The min/max bounds approach handles this automatically.
Not handling duplicates. BSTs require strictly less than and strictly greater than. If node.val == parent.val, that’s invalid. Make sure to use <= and >= in the bounds check.
Using integer limits instead of infinity. The problem says values can be up to 2^31 - 1, so if you initialize bounds to INT_MAX/INT_MIN, you’ll get false results for those edge values. Use float('inf') to be safe.
Kth Smallest Element in a BST
Difficulty: Medium Source: NeetCode
Problem
Given the
rootof a binary search tree, and an integerk, return thekth smallest value (1-indexed) of all the values of the nodes in the tree.Example 1: Input: root = [3,1,4,null,2], k = 1 Output: 1
Example 2: Input: root = [5,3,6,2,4,null,null,1], k = 3 Output: 3
Constraints:
- The number of nodes in the tree is
n1 <= k <= n <= 10^40 <= Node.val <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Inorder Traversal — Inorder of a BST gives values in sorted ascending order
- BST Property — Left subtree values are always smaller than root
- Early Exit — Stopping traversal once we’ve found the kth element
1. Inorder Traversal (Collect All)
Intuition
Inorder traversal of a BST visits nodes in ascending order. So the kth element in an inorder traversal is the kth smallest element. The simplest version collects all values in order, then returns index k-1.
Algorithm
- Do inorder traversal, collect all values into a list
- Return
values[k-1]
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def kth_smallest_naive(root, k):
values = []
def inorder(node):
if not node:
return
inorder(node.left)
values.append(node.val)
inorder(node.right)
inorder(root)
return values[k - 1]
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 1, 4, None, 2])
print(kth_smallest_naive(root1, 1)) # 1
root2 = build_tree([5, 3, 6, 2, 4, None, None, 1])
print(kth_smallest_naive(root2, 3)) # 3
root3 = build_tree([3, 1, 4, None, 2])
print(kth_smallest_naive(root3, 3)) # 3
Complexity
- Time:
O(n)— full traversal - Space:
O(n)— storing all values
2. Iterative Inorder with Early Exit (Optimal)
Intuition
The naive approach traverses the whole tree even after finding the answer. We can stop early using an iterative inorder traversal. Each time we pop a node, that’s the next smallest value. After k pops, we have our answer.
Algorithm
- Use a stack and
curr = root - Drill left as far as possible, pushing nodes onto the stack
- Pop a node — this is the next smallest
- Decrement k; if k == 0, return this node’s value
- Move to the right child and repeat
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def kth_smallest(root, k):
stack = []
curr = root
while curr or stack:
# Go as far left as possible
while curr:
stack.append(curr)
curr = curr.left
# Pop = visit the next smallest node
curr = stack.pop()
k -= 1
if k == 0:
return curr.val
# Move to right child for next iteration
curr = curr.right
return -1 # Should never reach here given k <= n
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 1, 4, None, 2])
print(kth_smallest(root1, 1)) # 1
print(kth_smallest(root1, 2)) # 2
print(kth_smallest(root1, 3)) # 3
root2 = build_tree([5, 3, 6, 2, 4, None, None, 1])
print(kth_smallest(root2, 3)) # 3
# Edge case: single node
root3 = build_tree([1])
print(kth_smallest(root3, 1)) # 1
Complexity
- Time:
O(H + k)— H to reach the leftmost leaf, then k steps from there - Space:
O(H)— stack holds at most one root-to-leaf path
Common Pitfalls
Using k as a global counter carelessly. In the recursive approach, you need to be careful about when to decrement k and how to signal early termination. The iterative approach makes this cleaner since you can just return directly.
Forgetting that BST inorder is ascending. The kth smallest is at index k-1 in a 0-indexed list, or the kth node popped in an iterative inorder. Don’t confuse “kth smallest” with “kth node in some other order.”
Construct Binary Tree from Preorder and Inorder Traversal
Difficulty: Medium Source: NeetCode
Problem
Given two integer arrays
preorderandinorderwherepreorderis the preorder traversal of a binary tree andinorderis the inorder traversal of the same tree, construct and return the binary tree.Example 1: Input: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7] Output: [3,9,20,null,null,15,7]
Example 2: Input: preorder = [-1], inorder = [-1] Output: [-1]
Constraints:
1 <= preorder.length <= 3000inorder.length == preorder.length-3000 <= preorder[i], inorder[i] <= 3000preorderandinorderconsist of unique values- Each value of
inorderalso appears inpreorderpreorderis guaranteed to be the preorder traversal of the treeinorderis guaranteed to be the inorder traversal of the tree
Prerequisites
Before attempting this problem, you should be comfortable with:
- Preorder Traversal — Root is always the first element
- Inorder Traversal — Left subtree values come before root, right subtree values come after
- Divide and Conquer — Using the root to split the problem into left and right subproblems
- Hash Map — O(1) lookup for the root’s position in inorder array
1. Recursive with Hash Map (Optimal)
Intuition
The first element of preorder is always the root. Once we know the root, we find it in inorder — everything to its left belongs to the left subtree, everything to its right belongs to the right subtree. We can recursively apply this to build the whole tree. We use a hash map for O(1) lookups in the inorder array.
Algorithm
- Build a
{value: index}map for the inorder array - Use a pointer into preorder (or index) to track the next root
build(in_left, in_right)function:- The next preorder value is the root
- Find root’s index in inorder using the hash map
left_size = root_idx - in_left- Recursively build left subtree:
build(in_left, root_idx - 1) - Recursively build right subtree:
build(root_idx + 1, in_right)
- Return root node
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def build_tree(preorder, inorder):
# Map each value to its index in inorder for O(1) lookup
inorder_idx = {val: i for i, val in enumerate(inorder)}
pre_ptr = [0] # Use list so we can mutate inside nested function
def build(in_left, in_right):
if in_left > in_right:
return None
# Next root is the current preorder element
root_val = preorder[pre_ptr[0]]
pre_ptr[0] += 1
root = TreeNode(root_val)
# Find root in inorder to split left/right
mid = inorder_idx[root_val]
# Build left subtree first (preorder visits left before right)
root.left = build(in_left, mid - 1)
root.right = build(mid + 1, in_right)
return root
return build(0, len(inorder) - 1)
# --- helpers ---
def level_order(root):
if not root:
return []
result, queue = [], [root]
while queue:
node = queue.pop(0)
result.append(node.val if node else None)
if node:
queue.append(node.left)
queue.append(node.right)
# Trim trailing Nones
while result and result[-1] is None:
result.pop()
return result
def inorder_traversal(root):
if not root:
return []
return inorder_traversal(root.left) + [root.val] + inorder_traversal(root.right)
def preorder_traversal(root):
if not root:
return []
return [root.val] + preorder_traversal(root.left) + preorder_traversal(root.right)
# --- tests ---
pre1 = [3, 9, 20, 15, 7]
ino1 = [9, 3, 15, 20, 7]
root1 = build_tree(pre1, ino1)
print("Level order:", level_order(root1)) # [3, 9, 20, 15, 7]
print("Preorder:", preorder_traversal(root1)) # [3, 9, 20, 15, 7]
print("Inorder:", inorder_traversal(root1)) # [9, 3, 15, 20, 7]
pre2 = [-1]
ino2 = [-1]
root2 = build_tree(pre2, ino2)
print("Single node:", root2.val) # -1
# More complex
pre3 = [1, 2, 4, 5, 3, 6, 7]
ino3 = [4, 2, 5, 1, 6, 3, 7]
root3 = build_tree(pre3, ino3)
print("Preorder check:", preorder_traversal(root3)) # [1, 2, 4, 5, 3, 6, 7]
print("Inorder check:", inorder_traversal(root3)) # [4, 2, 5, 1, 6, 3, 7]
Complexity
- Time:
O(n)— each node created once; hash map gives O(1) lookup - Space:
O(n)— hash map + recursion stack
2. Naive (Without Hash Map)
Intuition
Same approach but we do a linear search in inorder each time instead of using a hash map. Simpler to write but much slower.
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def build_tree_naive(preorder, inorder):
if not preorder or not inorder:
return None
root_val = preorder[0]
root = TreeNode(root_val)
mid = inorder.index(root_val) # O(n) linear search
root.left = build_tree_naive(preorder[1:mid + 1], inorder[:mid])
root.right = build_tree_naive(preorder[mid + 1:], inorder[mid + 1:])
return root
# --- test ---
pre = [3, 9, 20, 15, 7]
ino = [9, 3, 15, 20, 7]
root = build_tree_naive(pre, ino)
def inorder_traversal(root):
if not root:
return []
return inorder_traversal(root.left) + [root.val] + inorder_traversal(root.right)
print(inorder_traversal(root)) # [9, 3, 15, 20, 7]
Complexity
- Time:
O(n²)— linear search in inorder at each recursion level - Space:
O(n²)— list slicing creates new arrays at each level
Common Pitfalls
Building the right subtree before the left. Preorder visits left before right, so you must consume preorder elements for the left subtree first. If you build right first, you’ll assign wrong values to nodes.
Slicing arrays at each recursion level. Array slicing creates new lists — O(n) per call leading to O(n²) total. Use index bounds instead and a hash map for the inorder lookup to get O(n).
House Robber III
Difficulty: Medium Source: NeetCode
Problem
The thief has found himself a new place for his thievery again. There is only one entrance to this area, called
root. Besides the root, each house has one and only one parent house. After a tour, the smart thief realized that all houses in this place form a binary tree. It will automatically contact the police if two directly-linked houses were broken into on the same night. Given therootof the binary tree, return the maximum amount of money the thief can rob without alerting the police.Example 1: Input: root = [3,2,3,null,3,null,1] Output: 7 Explanation: Maximum amount of money the thief can rob = 3 + 3 + 1 = 7.
Example 2: Input: root = [3,4,5,1,3,null,1] Output: 9 Explanation: Maximum amount of money the thief can rob = 4 + 5 = 9.
Constraints:
- The number of nodes in the tree is in the range
[1, 10^4]0 <= Node.val <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- House Robber I/II — The base DP problem on arrays
- Post-order DFS — Computing answers bottom-up from leaves to root
- State Pairs — Returning two values from each DFS call (rob vs skip)
1. Naive Recursive with Memoization
Intuition
At each node, we have two choices: rob it (and skip both children) or skip it (and take the best from children). This creates overlapping subproblems — the same node’s subtree gets evaluated multiple times. Memoization fixes this.
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def rob_memo(root):
memo = {}
def dfs(node):
if not node:
return 0
if node in memo:
return memo[node]
# Option 1: Rob this node → skip children, can rob grandchildren
rob_this = node.val
if node.left:
rob_this += dfs(node.left.left) + dfs(node.left.right)
if node.right:
rob_this += dfs(node.right.left) + dfs(node.right.right)
# Option 2: Skip this node → take best from each child
skip_this = dfs(node.left) + dfs(node.right)
memo[node] = max(rob_this, skip_this)
return memo[node]
return dfs(root)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 2, 3, None, 3, None, 1])
print(rob_memo(root1)) # 7
root2 = build_tree([3, 4, 5, 1, 3, None, 1])
print(rob_memo(root2)) # 9
Complexity
- Time:
O(n)— each node computed once - Space:
O(n)— memo dict + call stack
2. Optimal DFS Returning State Pair
Intuition
Instead of memoizing, we can avoid redundant computation entirely by having the DFS return a pair: (rob, skip) where:
rob= max money if we rob this nodeskip= max money if we skip this node
The parent can then compute both its own rob/skip values directly from the children’s pairs — no repeated work.
Algorithm
- Define
dfs(node)→(rob, skip)tuple - Base case: null node returns
(0, 0) - For each node:
rob = node.val + left_skip + right_skip(rob current → must skip children)skip = max(left_rob, left_skip) + max(right_rob, right_skip)(skip current → take best from each child)
- Return
(rob, skip) - Answer is
max(dfs(root))
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def rob(root):
def dfs(node):
if not node:
return (0, 0) # (rob, skip)
left_rob, left_skip = dfs(node.left)
right_rob, right_skip = dfs(node.right)
# Rob current: must skip both children
rob_current = node.val + left_skip + right_skip
# Skip current: take best option from each child independently
skip_current = max(left_rob, left_skip) + max(right_rob, right_skip)
return (rob_current, skip_current)
rob_root, skip_root = dfs(root)
return max(rob_root, skip_root)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([3, 2, 3, None, 3, None, 1])
print(rob(root1)) # 7
root2 = build_tree([3, 4, 5, 1, 3, None, 1])
print(rob(root2)) # 9
root3 = build_tree([1])
print(rob(root3)) # 1
# Walk through Example 1 manually:
# Node 1 (leaf, right of 3-right): rob=1, skip=0
# Node 3 (leaf, right of 2): rob=3, skip=0
# Node 3 (root): rob=3+0+0=3, skip=max(3,0)+max(1,0)=3+1=4 → wait
# Actually let's trace Example 1: [3,2,3,null,3,null,1]
# 3
# / \
# 2 3
# \ \
# 3 1
# Leaf 3 (under 2): rob=3, skip=0
# Leaf 1 (under 3): rob=1, skip=0
# Node 2: rob=2+0=2, skip=max(3,0)=3
# Node 3 (right): rob=3+0=3, skip=max(1,0)=1
# Root 3: rob=3+skip(2)+skip(3)=3+3+1=7, skip=max(rob2,skip2)+max(rob3,skip3)=max(2,3)+max(3,1)=3+3=6
# Answer: max(7,6) = 7 ✓
Complexity
- Time:
O(n)— each node visited exactly once - Space:
O(h)— recursion stack
Common Pitfalls
Confusing “skip child” with “rob grandchild”. When you rob the current node, you must skip the direct children. But you don’t automatically rob the grandchildren — you take the best option from the grandchildren (which is already encoded in left_skip from the children’s DFS return).
Returning a single value from DFS. If DFS only returns the max money, you lose information about whether the current node was robbed or not. The parent needs to know this to decide its own options. Always return both (rob, skip).
Delete Leaves With a Given Value
Difficulty: Medium Source: NeetCode
Problem
Given a binary tree
rootand an integertarget, delete all the leaf nodes with valuetarget. Note that once you delete a leaf node with valuetarget, if its parent node becomes a leaf node and has the value oftarget, it should also be deleted (you need to continue doing this until you can’t).Example 1: Input: root = [1,2,3,2,null,2,4], target = 2 Output: [1,null,3,null,4]
Example 2: Input: root = [1,3,3,3,2], target = 3 Output: [1,3,null,null,2]
Constraints:
- The number of nodes in the tree is in the range
[1, 200]1 <= Node.val, target <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Post-order DFS — Process children before parents (bottom-up) so we can clean up leaf chains naturally
- Recursive Tree Modification — Returning updated node references from DFS to rewire the tree
- Leaf Node Definition — A node with no left and no right child
1. Post-order DFS
Intuition
The cascading deletion (a parent becoming a leaf after its child is deleted) is naturally handled by post-order traversal. We process children first, then check the current node. By the time we examine a node, its children have already been cleaned up. If after cleaning up children, the current node has no children AND has the target value, it too becomes a deletion candidate — we return null to the parent.
Algorithm
- DFS in post-order: recurse left, recurse right, then process current node
- Assign
node.left = dfs(node.left)andnode.right = dfs(node.right) - If the current node is now a leaf (
no left, no right) andnode.val == target, return null (delete it) - Otherwise return the node
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def remove_leaf_nodes(root, target):
def dfs(node):
if not node:
return None
# Post-order: process children first
node.left = dfs(node.left)
node.right = dfs(node.right)
# Now check if this node has become a deletable leaf
if not node.left and not node.right and node.val == target:
return None # Delete this node
return node # Keep this node
return dfs(root)
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
def level_order(root):
if not root:
return []
result, queue = [], [root]
while queue:
node = queue.pop(0)
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
# --- tests ---
# [1,2,3,2,null,2,4], target=2
# 1
# / \
# 2 3
# / / \
# 2 2 4
root1 = build_tree([1, 2, 3, 2, None, 2, 4])
print("Before:", level_order(root1)) # [1, 2, 3, 2, 2, 4]
root1 = remove_leaf_nodes(root1, 2)
print("After:", level_order(root1)) # [1, 3, 4]
# [1,3,3,3,2], target=3
root2 = build_tree([1, 3, 3, 3, 2])
print("Before:", level_order(root2)) # [1, 3, 3, 3, 2]
root2 = remove_leaf_nodes(root2, 3)
print("After:", level_order(root2)) # [1, 3, 2]
# Edge: entire tree is the target leaf
root3 = build_tree([1])
root3 = remove_leaf_nodes(root3, 1)
print("After deleting root:", root3) # None
# Chain deletion
root4 = build_tree([1, 2, None, 2, None, 2])
print("Before chain:", level_order(root4)) # [1, 2, 2, 2]
root4 = remove_leaf_nodes(root4, 2)
print("After chain:", level_order(root4) if root4 else []) # [1]
Complexity
- Time:
O(n)— every node is visited exactly once - Space:
O(h)— recursion stack depth equals tree height
Common Pitfalls
Using preorder (top-down) instead of postorder. If you check the current node before processing its children, you might miss cascading deletions. A node that’s not a leaf yet could become one after its target-valued leaf child is removed. Post-order handles this naturally.
Forgetting to reassign node.left and node.right. The deletion works by returning None from the recursive call and assigning it back to node.left or node.right. If you don’t do the assignment, the tree isn’t actually modified.
Not checking both leaf conditions. A node is only deletable if it’s a leaf (no left AND no right child) AND its value equals target. A node with one remaining child is not a leaf even if its value is the target.
Binary Tree Maximum Path Sum
Difficulty: Hard Source: NeetCode
Problem
A path in a binary tree is a sequence of nodes where each pair of adjacent nodes in the sequence has an edge connecting them. A node can only appear in the sequence at most once. Note that the path does not need to pass through the root. Given the
rootof a binary tree, return the maximum path sum of any non-empty path.Example 1: Input: root = [1,2,3] Output: 6 Explanation: The optimal path is 2 → 1 → 3 with a path sum of 2 + 1 + 3 = 6.
Example 2: Input: root = [-10,9,20,null,null,15,7] Output: 42 Explanation: The optimal path is 15 → 20 → 7 with a path sum of 15 + 20 + 7 = 42.
Constraints:
- The number of nodes in the tree is in the range
[1, 3 * 10^4]-1000 <= Node.val <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Diameter of Binary Tree — Very similar structure: compute gain at each node, track global max
- DFS Post-order — Computing values bottom-up
- Global Max Tracking — The answer isn’t always at the root; track it across all nodes
1. DFS with Global Max
Intuition
This is the classic “gain vs contribution” split. At each node, we think about two things:
- What’s the maximum path sum that passes through this node (using both subtrees)? This updates the global answer.
- What’s the maximum contribution this node can make to its parent? A node can only extend a path in one direction to its parent (left or right, not both), and we should ignore negative contributions.
The key insight: gain = node.val + max(left_gain, 0) + max(right_gain, 0) updates the global max, but we return node.val + max(left_gain, right_gain, 0) to the parent (one direction only, and at least 0 since we can always stop).
Algorithm
- Define
dfs(node)returning the max gain this node can contribute upward left_gain = max(dfs(node.left), 0)— ignore negative subtreesright_gain = max(dfs(node.right), 0)— ignore negative subtrees- Update
global_max = max(global_max, node.val + left_gain + right_gain) - Return
node.val + max(left_gain, right_gain)— can only go one direction upward
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def max_path_sum(root):
global_max = [float('-inf')] # Use list to allow mutation in nested function
def dfs(node):
if not node:
return 0
# Max gain from left and right subtrees (ignore if negative)
left_gain = max(dfs(node.left), 0)
right_gain = max(dfs(node.right), 0)
# Path through this node (can use both subtrees)
path_through_node = node.val + left_gain + right_gain
global_max[0] = max(global_max[0], path_through_node)
# Return max gain this node can contribute to parent (one direction only)
return node.val + max(left_gain, right_gain)
dfs(root)
return global_max[0]
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
# --- tests ---
root1 = build_tree([1, 2, 3])
print(max_path_sum(root1)) # 6 (2 → 1 → 3)
root2 = build_tree([-10, 9, 20, None, None, 15, 7])
print(max_path_sum(root2)) # 42 (15 → 20 → 7)
# All negative: answer is the least negative single node
root3 = build_tree([-3])
print(max_path_sum(root3)) # -3
root4 = build_tree([-1, -2, -3])
print(max_path_sum(root4)) # -1 (just the root)
# Path doesn't go through root
# -10
# / \
# 9 20
# / \
# 15 7
# Best path: 15 + 20 + 7 = 42, ignoring -10 entirely
# Mixed: some paths involve going up through a parent
root5 = build_tree([5, 4, 8, 11, None, 13, 4, 7, 2, None, None, None, 1])
print(max_path_sum(root5)) # 48 (7 + 11 + 4 + 5 + 8 + 13)
Complexity
- Time:
O(n)— every node is visited once - Space:
O(h)— recursion stack depth
Tracing Through Example 2
Tree: [-10, 9, 20, null, null, 15, 7]
-10
/ \
9 20
/ \
15 7
dfs(9) → 9, updates max = 9
dfs(15) → 15, updates max = 15
dfs(7) → 7, updates max = 7
dfs(20):
left_gain = max(15, 0) = 15
right_gain = max(7, 0) = 7
path_through = 20 + 15 + 7 = 42, updates max = 42
returns 20 + max(15, 7) = 35
dfs(-10):
left_gain = max(9, 0) = 9
right_gain = max(35, 0) = 35
path_through = -10 + 9 + 35 = 34, max stays 42
returns -10 + max(9, 35) = 25
Final answer: 42 ✓
Common Pitfalls
Not clamping gains at 0. If a subtree has a negative sum, including it only hurts the total. Using max(dfs(child), 0) means we treat negative subtrees as “don’t extend the path here.”
Returning the path-through value instead of the one-direction gain. The function returns what it can contribute to the parent — which can only go one direction. Returning node.val + left_gain + right_gain would let a path branch at every node going upward, which is invalid.
Initializing global_max to 0. If all values are negative, the answer is the largest (least negative) single node, which is negative. Initialize to float('-inf'), not 0.
Serialize and Deserialize Binary Tree
Difficulty: Hard Source: NeetCode
Problem
Serialization is the process of converting a data structure or object into a sequence of bits so that it can be stored in a file or memory buffer, or transmitted across a network connection link to be reconstructed later in the same or another computer environment. Design an algorithm to serialize and deserialize a binary tree. There is no restriction on how your serialization/deserialization algorithm works, but you just need to ensure that a tree can be serialized to a string and this string can be deserialized to the original tree structure.
Example 1: Input: root = [1,2,3,null,null,4,5] Output: [1,2,3,null,null,4,5]
Example 2: Input: root = [] Output: []
Constraints:
- The number of nodes in the tree is in the range
[0, 10^4]-1000 <= Node.val <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Preorder Traversal — Root first traversal which makes reconstruction straightforward
- BFS / Level Order — Alternative serialization strategy
- Null Markers — Encoding the absence of nodes to preserve tree structure
1. DFS Preorder Serialization
Intuition
In preorder, we visit root first, then left, then right. When serializing, we write null markers for missing children. This is powerful for deserialization: we read the stream left-to-right in preorder — the first value is always the root, then we recursively build left and right subtrees. A global pointer into the token list advances as we consume values.
Algorithm
Serialize:
- Preorder DFS: write
node.valor"null"for missing nodes - Join with a delimiter (e.g., comma)
Deserialize:
- Split string into token list
- Recursive build: consume first token
- If “null” → return None
- Otherwise → create node, recursively build left then right
Solution
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Codec:
def serialize(self, root):
"""Encodes a tree to a single string."""
tokens = []
def dfs(node):
if not node:
tokens.append("null")
return
tokens.append(str(node.val))
dfs(node.left)
dfs(node.right)
dfs(root)
return ",".join(tokens)
def deserialize(self, data):
"""Decodes your encoded data to tree."""
tokens = iter(data.split(","))
def build():
val = next(tokens)
if val == "null":
return None
node = TreeNode(int(val))
node.left = build()
node.right = build()
return node
return build()
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# --- tests ---
codec = Codec()
root1 = build_tree([1, 2, 3, None, None, 4, 5])
serialized1 = codec.serialize(root1)
print("Serialized:", serialized1)
# "1,2,null,null,3,4,null,null,5,null,null"
deserialized1 = codec.deserialize(serialized1)
print("Inorder original:", inorder(root1))
print("Inorder after roundtrip:", inorder(deserialized1))
# Both: [2, 1, 4, 3, 5]
root2 = build_tree([])
serialized2 = codec.serialize(root2)
print("Empty tree:", serialized2) # "null"
deserialized2 = codec.deserialize(serialized2)
print("Deserialized empty:", deserialized2) # None
root3 = build_tree([1])
s3 = codec.serialize(root3)
print("Single node:", s3) # "1,null,null"
d3 = codec.deserialize(s3)
print("Single val:", d3.val) # 1
Complexity
- Time:
O(n)— serialize visits each node once; deserialize creates each node once - Space:
O(n)— storing the serialized string and recursion stack
2. BFS Level-Order Serialization
Intuition
BFS produces the familiar level-order format (like LeetCode’s tree representation). Serialization is straightforward BFS. Deserialization rebuilds level by level using a queue of “parent” nodes — each parent claims the next two non-processed tokens as its children.
Algorithm
Serialize: BFS, write each node’s value or “null”
Deserialize:
- Create root from first token
- Use a queue of nodes that need children assigned
- For each node in the queue, read the next two tokens to set left and right children
Solution
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class CodecBFS:
def serialize(self, root):
if not root:
return "null"
tokens = []
queue = deque([root])
while queue:
node = queue.popleft()
if node:
tokens.append(str(node.val))
queue.append(node.left)
queue.append(node.right)
else:
tokens.append("null")
return ",".join(tokens)
def deserialize(self, data):
if data == "null":
return None
tokens = data.split(",")
root = TreeNode(int(tokens[0]))
queue = deque([root])
i = 1
while queue and i < len(tokens):
node = queue.popleft()
if i < len(tokens) and tokens[i] != "null":
node.left = TreeNode(int(tokens[i]))
queue.append(node.left)
i += 1
if i < len(tokens) and tokens[i] != "null":
node.right = TreeNode(int(tokens[i]))
queue.append(node.right)
i += 1
return root
# --- helpers ---
def build_tree(values):
if not values:
return None
root = TreeNode(values[0])
queue = [root]
i = 1
while queue and i < len(values):
node = queue.pop(0)
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# --- tests ---
codec = CodecBFS()
root1 = build_tree([1, 2, 3, None, None, 4, 5])
s1 = codec.serialize(root1)
print("BFS Serialized:", s1)
# "1,2,3,null,null,4,5,null,null,null,null"
d1 = codec.deserialize(s1)
print("Inorder match:", inorder(root1) == inorder(d1)) # True
root2 = build_tree([1, 2])
s2 = codec.serialize(root2)
print("BFS:", s2) # "1,2,null,null,null"
d2 = codec.deserialize(s2)
print("Roundtrip:", inorder(d2)) # [2, 1]
Complexity
- Time:
O(n)— BFS visits each node once - Space:
O(n)— queue holds up to the widest level; serialized string is O(n)
DFS vs BFS Tradeoff
| DFS (Preorder) | BFS (Level Order) | |
|---|---|---|
| Serialized format | Recursive depth-first | Familiar LeetCode format |
| Null markers | Explicit for every missing child | Trailing nulls omittable |
| String length | Usually shorter for sparse trees | Can have many trailing nulls |
| Deserialization | Clean recursive implementation | Iterative, slightly more bookkeeping |
Both approaches are O(n) time and space. The DFS approach tends to produce cleaner code; the BFS approach matches the LeetCode visualization format more closely.
Common Pitfalls
Not encoding null children. Without null markers, you can’t distinguish between [1,2] (root with left child) and [1,null,2] (root with right child). Null markers are essential for encoding structure.
Using whitespace or numbers in node values as delimiters. If node values can be multi-digit or negative (like -100), you need a delimiter that won’t appear in the value itself. A comma works fine; don’t use just a space.
Off-by-one in BFS deserialization. The index i must advance correctly for both the left and right child reads. A common mistake is advancing i only when a child exists, which misaligns the token sequence.
Backtracking
Imagine navigating a maze. You walk down a corridor, turn left, keep going — and hit a dead end. You do not give up. You retrace your steps to the last junction and try the right corridor instead. If that also fails, you go back further and try another branch entirely.
That is backtracking: explore a path, and if it fails, undo your steps and try the next option.
It is a systematic way to search through all possibilities without missing any, and without wasting time on paths you have already ruled out.
The Explore-and-Retreat Pattern
Every backtracking algorithm follows the same three-step loop:
1. CHOOSE — pick one option from the available choices
2. EXPLORE — recursively continue down that path
3. UNCHOOSE — if the path failed, undo the choice and try the next one
The “unchoose” step is what makes backtracking different from plain recursion. You are not just calling a function and forgetting about it — you are actively restoring state so the next branch starts from a clean slate.
Decision Trees
Backtracking explores a decision tree. At each node, you make a choice. At each leaf, you either found a solution or hit a dead end.
flowchart TD
Root["Start"] --> A["Choice A"]
Root --> B["Choice B"]
Root --> C["Choice C"]
A --> A1["Choice A1"]
A --> A2["Choice A2"]
B --> B1["Choice B1"]
B --> B2["Choice B2"]
C --> C1["Choice C1"]
C --> C2["Choice C2"]
A1 --> DA["Dead end\n(backtrack)"]
A2 --> SA["SOLUTION"]
B1 --> SB["SOLUTION"]
B2 --> DB["Dead end\n(backtrack)"]
C1 --> DC1["Dead end\n(backtrack)"]
C2 --> DC2["Dead end\n(backtrack)"]
style SA fill:#2d6a4f,color:#fff
style SB fill:#2d6a4f,color:#fff
style DA fill:#7f1d1d,color:#fff
style DB fill:#7f1d1d,color:#fff
style DC1 fill:#7f1d1d,color:#fff
style DC2 fill:#7f1d1d,color:#fff
The algorithm does a depth-first traversal of this tree. When it hits a dead end, it backtracks to the parent and tries the next branch. When it finds a solution, it can either return immediately (find one) or continue (find all).
Why Not Just Try Everything?
A brute-force search would generate all possible combinations and then check which ones are valid. Backtracking is smarter: it prunes branches early. The moment a partial choice violates a constraint, it backs up without exploring the entire subtree beneath that choice.
For a problem with millions of combinations, this pruning is the difference between an algorithm that finishes in milliseconds and one that runs for years.
The Template
Here is the skeleton that most backtracking solutions follow:
def backtrack(state, choices):
# Base case: have we reached a complete solution?
if is_solution(state):
record(state)
return
for choice in choices:
# Is this choice still valid?
if is_valid(state, choice):
# CHOOSE: apply the choice
apply(state, choice)
# EXPLORE: recurse deeper
backtrack(state, next_choices(state, choice))
# UNCHOOSE: undo the choice (backtrack)
undo(state, choice)
def is_solution(state): pass # placeholder
def is_valid(state, choice): pass
def apply(state, choice): pass
def next_choices(state, choice): pass
def undo(state, choice): pass
def record(state): pass
print("Backtracking template loaded — see Tree Maze for a concrete example.")
The apply / recurse / undo sandwich is the heart of every backtracking algorithm.
In This Section
- Tree Maze — search a binary tree for a target value and find all root-to-leaf paths; understand how the explore-and-retreat pattern translates directly to code
Tree Maze
A binary tree is a natural maze: at every node you can go left or right, and you keep going until you run out of branches. Backtracking lets you explore every path without getting lost — when a branch fails to find what you are looking for, you retreat and try the other direction.
The Tree We Will Explore
flowchart TD
A["1 (root)"] --> B["2"]
A --> C["3"]
B --> D["4"]
B --> E["5 ← TARGET"]
C --> F["6"]
C --> G["7"]
D --> H["8"]
D --> I["9"]
style E fill:#2d6a4f,color:#fff
style A fill:#1b4332,color:#fff
Let’s build this tree in code and then search it.
class Node:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
# Build the tree from the diagram above
tree = Node(1,
left=Node(2,
left=Node(4,
left=Node(8),
right=Node(9)),
right=Node(5)),
right=Node(3,
left=Node(6),
right=Node(7)))
print("Tree built successfully.")
print(f"Root: {tree.val}")
print(f"Root's children: {tree.left.val} and {tree.right.val}")
Part 1: Find a Target Value (Return True/False)
The algorithm is:
- If the current node is None, this path failed — return False.
- If the current node holds the target — found it, return True.
- Otherwise, try left. If left succeeds, we are done. Otherwise, try right.
class Node:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
tree = Node(1,
left=Node(2,
left=Node(4, left=Node(8), right=Node(9)),
right=Node(5)),
right=Node(3,
left=Node(6),
right=Node(7)))
def find(node, target):
# Base case 1: fallen off the tree — dead end
if node is None:
return False
# Base case 2: found the target
if node.val == target:
return True
# EXPLORE left subtree
if find(node.left, target):
return True # found it on the left — no need to try right
# Left failed. BACKTRACK implicitly (no state to undo here)
# EXPLORE right subtree
if find(node.right, target):
return True
# Both children failed — this whole subtree has no solution
return False
for target in [5, 9, 3, 10]:
result = find(tree, target)
status = "FOUND" if result else "not found"
print(f"Search for {target:>2}: {status}")
Notice there is no explicit “undo” step here because we are not modifying any state. The backtracking happens automatically when find(node.left, target) returns False and execution falls through to the right subtree. The call stack itself is the backtracking mechanism.
Visualising the Search Path
Let’s add tracing so we can see exactly which nodes get visited when searching for 5:
class Node:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
tree = Node(1,
left=Node(2,
left=Node(4, left=Node(8), right=Node(9)),
right=Node(5)),
right=Node(3,
left=Node(6),
right=Node(7)))
def find_traced(node, target, depth=0):
indent = " " * depth
if node is None:
print(f"{indent}None — dead end, backtracking")
return False
print(f"{indent}Visiting node {node.val}")
if node.val == target:
print(f"{indent}*** FOUND {target}! ***")
return True
print(f"{indent} Going left from {node.val}...")
if find_traced(node.left, target, depth + 1):
return True
print(f"{indent} Left failed. Going right from {node.val}...")
if find_traced(node.right, target, depth + 1):
return True
print(f"{indent} Both sides failed at {node.val}, backtracking")
return False
print("Searching for 5:\n")
find_traced(tree, 5)
Part 2: Find ALL Paths from Root to Leaves
Now we need explicit state management. We track the current path in a list, and we must undo our addition before returning — that is the classic backtracking pattern.
class Node:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
tree = Node(1,
left=Node(2,
left=Node(4, left=Node(8), right=Node(9)),
right=Node(5)),
right=Node(3,
left=Node(6),
right=Node(7)))
def all_paths(root):
results = []
path = [] # current path being explored
def backtrack(node):
if node is None:
return
# CHOOSE: add this node to the current path
path.append(node.val)
# Base case: leaf node — record the complete path
if node.left is None and node.right is None:
results.append(list(path)) # copy! path will be modified later
else:
# EXPLORE left and right subtrees
backtrack(node.left)
backtrack(node.right)
# UNCHOOSE: remove this node before returning to parent
path.pop()
backtrack(root)
return results
paths = all_paths(tree)
print(f"Found {len(paths)} root-to-leaf paths:\n")
for p in paths:
print(" " + " → ".join(str(v) for v in p))
The path.pop() at the end is the heart of the algorithm. Without it, the path list would keep growing and every “complete path” recorded would be contaminated by leftover values from previous branches.
Part 3: Find All Paths That Sum to a Target
A natural extension: find every root-to-leaf path where the values add up to a given target.
class Node:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
tree = Node(1,
left=Node(2,
left=Node(4, left=Node(8), right=Node(9)),
right=Node(5)),
right=Node(3,
left=Node(6),
right=Node(7)))
def paths_with_sum(root, target):
results = []
path = []
def backtrack(node, remaining):
if node is None:
return
# CHOOSE
path.append(node.val)
remaining -= node.val
# Base case: leaf — did we hit the target?
if node.left is None and node.right is None:
if remaining == 0:
results.append(list(path))
else:
# EXPLORE
backtrack(node.left, remaining)
backtrack(node.right, remaining)
# UNCHOOSE
path.pop()
backtrack(root, target)
return results
for target in [15, 11, 17]:
found = paths_with_sum(tree, target)
if found:
print(f"Paths summing to {target}:")
for p in found:
path_str = " + ".join(str(v) for v in p)
print(f" {path_str} = {sum(p)}")
else:
print(f"No paths sum to {target}")
print()
The Three Steps in Every Example
| Step | Part 1 (find target) | Part 2 (all paths) | Part 3 (path sum) |
|---|---|---|---|
| Choose | visit node | path.append(node.val) | path.append(node.val) |
| Explore | recurse left, then right | recurse left, then right | recurse left, then right |
| Unchoose | implicit (no state) | path.pop() | path.pop() |
The pattern is identical across all three. Only the base case and what constitutes a “solution” changes.
Real-World Applications
Solving Sudoku — A Sudoku board is a tree of choices. At each empty cell, try digits 1–9. If a digit violates a row/column/box constraint, backtrack and try the next digit. This is how all Sudoku solvers work under the hood.
The N-Queens Problem — Place N queens on an N×N chess board so no two threaten each other. Try a queen in each column of row 1, then recurse to row 2, pruning any placement that conflicts with existing queens. Backtrack when stuck.
GPS Route Planning — Finding all routes between two cities is a path-finding problem on a graph. Backtracking (depth-first search with path tracking) generates all possible routes, from which the shortest or fastest can be selected.
Game AI — Chess Move Trees — A chess engine explores a tree of possible moves. At each node, it tries every legal move, recurses to evaluate the resulting position, then backtracks to try the next move. Alpha-beta pruning is an advanced form of the same “cut off failing branches early” idea.
# Mini demo: generate all permutations of [1, 2, 3]
# This is the same backtracking pattern applied to combinations
def permutations(nums):
results = []
used = [False] * len(nums)
path = []
def backtrack():
if len(path) == len(nums):
results.append(list(path))
return
for i, num in enumerate(nums):
if used[i]:
continue
# CHOOSE
used[i] = True
path.append(num)
# EXPLORE
backtrack()
# UNCHOOSE
path.pop()
used[i] = False
backtrack()
return results
perms = permutations([1, 2, 3])
print(f"All {len(perms)} permutations of [1, 2, 3]:")
for p in perms:
print(f" {p}")
Key Takeaways
- Backtracking = depth-first search + explicit state undo.
- The choose / explore / unchoose pattern is the same regardless of problem complexity.
- When no state is modified (find target), backtracking is implicit in the call stack.
- When state is modified (path tracking), you must explicitly undo every change before returning.
- Early pruning — detecting dead ends before recursing — is what makes backtracking efficient.
Subsets
Your pizza app lets you choose any combination of toppings. How many possible pizzas? 2ⁿ — and backtracking generates them all.
With three toppings — mushrooms, peppers, olives — you get 2³ = 8 possible pizzas: the plain base, each topping alone, every pair, and all three together. The power set is the mathematical name for this collection of all possible subsets of a given set.
Backtracking is the engine that generates it. At each element you face one binary decision: include it or exclude it. Make that choice, move to the next element, repeat. When you run out of elements, you have a valid subset.
The binary decision tree
For the array [1, 2, 3] every path from root to leaf is one subset. The left branch always means “exclude”, the right branch always means “include”.
flowchart TD
Root["start: []"] --> A1["exclude 1\n[]"]
Root --> A2["include 1\n[1]"]
A1 --> B1["exclude 2\n[]"]
A1 --> B2["include 2\n[2]"]
A2 --> B3["exclude 2\n[1]"]
A2 --> B4["include 2\n[1,2]"]
B1 --> C1["exclude 3\n✓ []"]
B1 --> C2["include 3\n✓ [3]"]
B2 --> C3["exclude 3\n✓ [2]"]
B2 --> C4["include 3\n✓ [2,3]"]
B3 --> C5["exclude 3\n✓ [1]"]
B3 --> C6["include 3\n✓ [1,3]"]
B4 --> C7["exclude 3\n✓ [1,2]"]
B4 --> C8["include 3\n✓ [1,2,3]"]
The tree has 2ⁿ leaves and 2ⁿ⁺¹ - 1 total nodes. Every leaf is a valid answer — there is no pruning here, unlike many backtracking problems.
Backtracking implementation
The key insight: pass the current subset as a growing list. At each index, record the snapshot, then explore including and excluding the current element.
def subsets(nums):
result = []
def backtrack(index, current):
# Every state of current is a valid subset — record it
result.append(list(current))
for i in range(index, len(nums)):
current.append(nums[i]) # include nums[i]
backtrack(i + 1, current) # recurse with next index
current.pop() # exclude nums[i] (undo)
backtrack(0, [])
return result
nums = [1, 2, 3]
output = subsets(nums)
print(f"Input: {nums}")
print(f"Total subsets: {len(output)}")
for s in output:
print(s)
Walk through the call stack for [1, 2, 3]:
backtrack(0, [])records[], then tries each starting index.- Appending
1gives[1]— recorded, thenbacktrack(1, [1])continues. - Inside that call, appending
2gives[1, 2]— and so on. - After each recursive call returns,
pop()undoes the last choice.
The list(current) snapshot is critical. If you appended current directly you would store a reference to the same list, and every snapshot would end up identical.
Iterative bit-mask approach
Every integer from 0 to 2ⁿ - 1 can be read as a bitmask. Bit j being set means “include element j”. This maps each number to exactly one subset.
def subsets_bitmask(nums):
n = len(nums)
result = []
for mask in range(1 << n): # 0 to 2^n - 1
subset = []
for j in range(n):
if mask & (1 << j): # bit j is set
subset.append(nums[j])
result.append(subset)
return result
nums = [1, 2, 3]
output = subsets_bitmask(nums)
print(f"Input: {nums}")
print(f"Total subsets: {len(output)}")
for mask, s in enumerate(output):
binary = format(mask, f'0{len(nums)}b')
print(f"mask {binary} -> {s}")
The bitmask approach is iterative and easy to reason about, but it hits a wall at n = 30 or so (2³⁰ is over a billion subsets). The backtracking version runs into the same wall — the problem is inherently exponential.
Handling duplicates
When the input contains duplicate values, naive backtracking produces duplicate subsets.
# Naive approach — produces duplicates
def subsets_naive(nums):
result = []
def backtrack(index, current):
result.append(list(current))
for i in range(index, len(nums)):
current.append(nums[i])
backtrack(i + 1, current)
current.pop()
backtrack(0, [])
return result
# Correct approach — sort first, skip same value at same depth
def subsets_no_duplicates(nums):
nums.sort()
result = []
def backtrack(index, current):
result.append(list(current))
for i in range(index, len(nums)):
# Skip if same value was already tried at this position
if i > index and nums[i] == nums[i - 1]:
continue
current.append(nums[i])
backtrack(i + 1, current)
current.pop()
backtrack(0, [])
return result
nums_dup = [1, 2, 2]
naive = subsets_naive(nums_dup)
correct = subsets_no_duplicates(nums_dup)
print(f"Input: {nums_dup}")
print(f"\nNaive output ({len(naive)} subsets — has duplicates):")
for s in naive:
print(s)
print(f"\nCorrect output ({len(correct)} unique subsets):")
for s in correct:
print(s)
The duplicate-skip rule: if i > index and nums[i] == nums[i - 1]: continue. The condition i > index is crucial — it only skips when the same value appears more than once at the same level of the tree, not when it appears in a parent call.
Complexity analysis
| Approach | Time | Space |
|---|---|---|
| Backtracking (no duplicates) | O(n · 2ⁿ) | O(n) recursion stack + O(n · 2ⁿ) output |
| Bitmask | O(n · 2ⁿ) | O(n · 2ⁿ) output |
The n factor in time comes from copying each subset into the result. There is no way to do better — the output itself has O(n · 2ⁿ) characters, so any algorithm must spend at least that much time.
Real-world applications
Feature selection in machine learning. Given n candidate features, try every subset to find which combination produces the best model. This is exponential and impractical for large n, but exact for small feature sets.
Combinatorial testing. When software has n binary flags, you want to test every combination. Generating the power set gives you the full test matrix.
Power set in set theory. Many mathematical proofs need to enumerate all subsets of a finite set — the power set is the fundamental object.
Generating all possible configurations. A configuration system with n optional modules needs to validate that every possible combination is safe. Subsets give you the complete space.
# Practical example: find all subsets that sum to a target
def subsets_with_target_sum(nums, target):
results = []
def backtrack(index, current, current_sum):
if current_sum == target:
results.append(list(current))
if current_sum >= target or index == len(nums):
return
for i in range(index, len(nums)):
current.append(nums[i])
backtrack(i + 1, current, current_sum + nums[i])
current.pop()
backtrack(0, [], 0)
return results
nums = [1, 2, 3, 4, 5]
target = 6
found = subsets_with_target_sum(nums, target)
print(f"Input: {nums}")
print(f"Target sum: {target}")
print(f"Subsets that sum to {target}:")
for s in found:
print(f" {s} (sum = {sum(s)})")
This is a small variation: add a pruning condition (current_sum >= target) to stop exploring branches that can never reach the target. This transforms a pure enumeration into a constrained search.
Combinations
Choosing 3 players from a squad of 11 for a penalty shootout — order doesn’t matter. How many ways? C(11,3) = 165.
The coach doesn’t care whether they pick Rashford then Saka then Bellingham or Bellingham then Saka then Rashford — the same three players take the kicks either way. That indifference to order is what separates combinations from permutations.
The formula is C(n, k) = n! / (k! × (n-k)!). But the formula only counts. Backtracking actually builds each combination.
Combinations vs permutations
| Concept | Order matters? | Example | Count |
|---|---|---|---|
| Combination | No | Picking 3 from {A, B, C, D} | C(4,3) = 4 |
| Permutation | Yes | Arranging 3 from {A, B, C, D} | P(4,3) = 24 |
The set {1, 2, 3} is just one combination chosen from [1, 2, 3, 4]. But [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] are six distinct permutations.
The forward-only decision tree
To avoid revisiting elements (which would create duplicates), the tree only moves forward through the array. Once you include element i, future choices start from i + 1.
Below: choosing 2 elements from [1, 2, 3, 4].
flowchart TD
Root["start\n[]"] --> N1["pick 1\n[1]"]
Root --> N2["pick 2\n[2]"]
Root --> N3["pick 3\n[3]"]
N1 --> L1["pick 2\n✓ [1,2]"]
N1 --> L2["pick 3\n✓ [1,3]"]
N1 --> L3["pick 4\n✓ [1,4]"]
N2 --> L4["pick 3\n✓ [2,3]"]
N2 --> L5["pick 4\n✓ [2,4]"]
N3 --> L6["pick 4\n✓ [3,4]"]
Each level picks one element and only looks at elements to its right. This enforces lexicographic order and eliminates duplicates without any extra checking. C(4,2) = 6, and the diagram has exactly 6 leaf nodes.
Core implementation: choose k from 1..n
def combinations(n, k):
result = []
def backtrack(start, current):
if len(current) == k:
result.append(list(current))
return
for i in range(start, n + 1):
current.append(i)
backtrack(i + 1, current)
current.pop()
backtrack(1, [])
return result
# C(4, 2) = 6
output = combinations(4, 2)
print(f"C(4, 2) = {len(output)}")
for c in output:
print(c)
print()
# C(5, 3) = 10
output2 = combinations(5, 3)
print(f"C(5, 3) = {len(output2)}")
for c in output2:
print(c)
The base case fires when current is exactly k long. The loop starts at start and goes to n + 1 (inclusive upper bound), and each recursive call advances start to i + 1 so earlier elements are never revisited.
Pruning: stop early when there are not enough elements left
Without pruning, the loop tries indices that can never produce a full combination. Adding one condition eliminates those dead branches.
def combinations_pruned(n, k):
result = []
def backtrack(start, current):
if len(current) == k:
result.append(list(current))
return
remaining_needed = k - len(current)
remaining_available = n - start + 1
# Pruning: not enough elements left to complete the combination
if remaining_available < remaining_needed:
return
for i in range(start, n + 1):
current.append(i)
backtrack(i + 1, current)
current.pop()
backtrack(1, [])
return result
# Compare with and without pruning — same output, fewer calls
import sys
calls_normal = [0]
calls_pruned = [0]
def combinations_count_calls(n, k):
count = [0]
def backtrack(start, current):
count[0] += 1
if len(current) == k:
return
for i in range(start, n + 1):
current.append(i)
backtrack(i + 1, current)
current.pop()
backtrack(1, [])
return count[0]
def combinations_pruned_count_calls(n, k):
count = [0]
def backtrack(start, current):
count[0] += 1
if len(current) == k:
return
if (n - start + 1) < (k - len(current)):
return
for i in range(start, n + 1):
current.append(i)
backtrack(i + 1, current)
current.pop()
backtrack(1, [])
return count[0]
n, k = 10, 4
normal_calls = combinations_count_calls(n, k)
pruned_calls = combinations_pruned_count_calls(n, k)
print(f"C({n}, {k}) backtracking calls:")
print(f" Without pruning: {normal_calls}")
print(f" With pruning: {pruned_calls}")
print(f" Savings: {normal_calls - pruned_calls} calls avoided")
The pruning condition is: if (n - start + 1) < (k - len(current)), there are fewer elements left than slots to fill — stop immediately. This can cut the search space dramatically when k is close to n.
Combinations from an arbitrary array
The previous approach assumed elements 1..n. Real problems give you an actual array.
def combinations_from_array(nums, k):
result = []
nums.sort() # optional, but gives consistent ordering
def backtrack(start, current):
if len(current) == k:
result.append(list(current))
return
for i in range(start, len(nums)):
# Pruning: not enough elements left
if len(nums) - i < k - len(current):
break
current.append(nums[i])
backtrack(i + 1, current)
current.pop()
backtrack(0, [])
return result
# Choose 2 players from a squad
squad = ["Rashford", "Saka", "Bellingham", "Foden", "Kane"]
k = 2
picks = combinations_from_array(squad, k)
print(f"Choosing {k} from squad of {len(squad)}: C({len(squad)},{k}) = {len(picks)}")
for pair in picks:
print(f" {pair}")
Combination sum: target-sum variant
A classic interview problem: find all combinations of candidates that sum to a target. Elements can be reused (unlike standard combinations).
def combination_sum(candidates, target):
candidates.sort()
result = []
def backtrack(start, current, remaining):
if remaining == 0:
result.append(list(current))
return
for i in range(start, len(candidates)):
c = candidates[i]
# Pruning: sorted array means all later elements are also too big
if c > remaining:
break
current.append(c)
backtrack(i, current, remaining - c) # i not i+1: reuse allowed
current.pop()
backtrack(0, [], target)
return result
candidates = [2, 3, 6, 7]
target = 7
result = combination_sum(candidates, target)
print(f"Candidates: {candidates}")
print(f"Target: {target}")
print(f"Combinations that sum to {target}:")
for combo in result:
print(f" {combo} (sum = {sum(combo)})")
The one change from standard combinations: backtrack(i, ...) instead of backtrack(i + 1, ...). Staying at the same index allows reusing the same element.
Complexity analysis
| Operation | Time | Space |
|---|---|---|
| Generate all C(n,k) combinations | O(k × C(n,k)) | O(k) stack + O(k × C(n,k)) output |
| Combination sum (target T, max value M) | O(T/M × 2^(T/M)) | O(T/M) stack depth |
The k factor in time accounts for copying each combination into the result. The stack depth is at most k deep for standard combinations, and T/M deep for combination sum (where you can reuse elements).
Real-world applications
Lottery numbers. A standard lottery picks 6 from 49. There are C(49,6) = 13,983,816 possible tickets — backtracking generates every valid ticket for analysis.
Team selection. Any scenario where you choose a fixed-size group from a larger pool and the order of selection does not matter — interview panels, sports squads, committee formation.
Drug combination trials. Clinical researchers testing which combination of k drugs from a set of n candidates produces the best outcome need to enumerate C(n,k) treatment groups.
Network routing paths. Choosing which k of n links to activate in a network while meeting a constraint is a combinations problem at its core.
# Real-world: find all 3-person committees from a group
# where no two people share the same department
people = [
("Alice", "Engineering"),
("Bob", "Engineering"),
("Carol", "Marketing"),
("Dave", "Marketing"),
("Eve", "Finance"),
("Frank", "Finance"),
]
def valid_committees(people, size):
valid = []
def backtrack(start, current_people, current_depts):
if len(current_people) == size:
valid.append(list(current_people))
return
for i in range(start, len(people)):
name, dept = people[i]
if dept not in current_depts: # constraint: no shared department
current_people.append(name)
current_depts.add(dept)
backtrack(i + 1, current_people, current_depts)
current_people.pop()
current_depts.remove(dept)
backtrack(0, [], set())
return valid
committees = valid_committees(people, 3)
print(f"Valid 3-person committees (one per department):")
for c in committees:
print(f" {c}")
print(f"\nTotal: {len(committees)}")
This shows backtracking’s real power: it is not just enumeration, it is constrained enumeration. Pruning via the dept not in current_depts check cuts branches the moment a constraint is violated.
Permutations
How many ways can 4 runners finish a race? 4! = 24. Backtracking lists them all.
First place has 4 candidates. Once filled, second place has 3. Then 2. Then 1. That countdown — 4 × 3 × 2 × 1 — is factorial growth. For just 10 runners the answer is 3,628,800. For 20 it exceeds 2 quintillion. Backtracking navigates this space systematically, visiting every arrangement exactly once.
Permutations vs combinations
The critical difference: order matters for permutations and does not matter for combinations.
| Input | As a combination | As a permutation |
|---|---|---|
| Choosing 3 from {1, 2, 3} | {1,2,3} — one unique set | [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] — six arrangements |
A combination answers “who is on the team”. A permutation answers “who bats first, who bats second, who bats third”.
The decision tree for [1, 2, 3]
Each level of the tree picks the next position. The element chosen at level 1 fills position 0, the element chosen at level 2 fills position 1, and so on. Every element can only appear once per path.
flowchart TD
Root["start\n[]"] --> A1["pick 1\n[1]"]
Root --> A2["pick 2\n[2]"]
Root --> A3["pick 3\n[3]"]
A1 --> B1["pick 2\n[1,2]"]
A1 --> B2["pick 3\n[1,3]"]
A2 --> B3["pick 1\n[2,1]"]
A2 --> B4["pick 3\n[2,3]"]
A3 --> B5["pick 1\n[3,1]"]
A3 --> B6["pick 2\n[3,2]"]
B1 --> C1["pick 3\n✓ [1,2,3]"]
B2 --> C2["pick 2\n✓ [1,3,2]"]
B3 --> C3["pick 3\n✓ [2,1,3]"]
B4 --> C4["pick 1\n✓ [2,3,1]"]
B5 --> C5["pick 2\n✓ [3,1,2]"]
B6 --> C6["pick 1\n✓ [3,2,1]"]
3! = 6 leaves, one per valid permutation. The depth equals the length of the input.
Classic approach: used boolean array
Track which elements have been placed. At each position, try every unused element.
def permutations(nums):
result = []
used = [False] * len(nums)
def backtrack(current):
if len(current) == len(nums):
result.append(list(current))
return
for i in range(len(nums)):
if used[i]:
continue # skip already-placed elements
used[i] = True
current.append(nums[i])
backtrack(current)
current.pop()
used[i] = False # undo
backtrack([])
return result
nums = [1, 2, 3]
output = permutations(nums)
print(f"Input: {nums}")
print(f"Total permutations: {len(output)} (expected {len(nums)}! = 6)")
for p in output:
print(p)
The used array mirrors the call stack. When the recursion returns, used[i] = False un-marks the element so sibling branches can use it. This undo step is the hallmark of backtracking.
Swap-based approach
An alternative that avoids the used array: swap the current element into position, recurse, then swap back. The sub-array nums[start:] always holds the unused elements.
def permutations_swap(nums):
result = []
nums = list(nums) # work on a copy
def backtrack(start):
if start == len(nums):
result.append(list(nums))
return
for i in range(start, len(nums)):
nums[start], nums[i] = nums[i], nums[start] # swap in
backtrack(start + 1)
nums[start], nums[i] = nums[i], nums[start] # swap back
backtrack(0)
return result
nums = [1, 2, 3]
output = permutations_swap(nums)
print(f"Input: {nums}")
print(f"Total permutations: {len(output)}")
for p in output:
print(p)
The swap approach uses O(1) extra space beyond the recursion stack and the output, but produces permutations in a different order than the used array approach. Neither order is more correct — they both visit every arrangement.
Handling duplicates
When the input contains duplicate values, naive backtracking produces duplicate permutations. For [1, 1, 2] there are only 3!/2! = 3 unique arrangements, not 3! = 6.
def permutations_unique(nums):
nums.sort()
result = []
used = [False] * len(nums)
def backtrack(current):
if len(current) == len(nums):
result.append(list(current))
return
for i in range(len(nums)):
if used[i]:
continue
# Skip: same value, previous sibling was already used and unset
# This means we already explored this value at this position
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
used[i] = True
current.append(nums[i])
backtrack(current)
current.pop()
used[i] = False
backtrack([])
return result
nums_dup = [1, 1, 2]
naive_output = permutations([1, 1, 2])
unique_output = permutations_unique(nums_dup)
print(f"Input: {nums_dup}")
print(f"\nNaive ({len(naive_output)} permutations — has duplicates):")
for p in naive_output:
print(f" {p}")
print(f"\nUnique ({len(unique_output)} permutations):")
for p in unique_output:
print(f" {p}")
The skip condition i > 0 and nums[i] == nums[i-1] and not used[i-1] reads: “if this value equals the previous value AND the previous copy has already been unset (used and then backtracked), skip — we already explored this branch.” Sort the input first so duplicates are adjacent.
Next permutation algorithm
Given a permutation, find the lexicographically next one without generating all permutations up to that point. This is the building block for many contest problems.
def next_permutation(nums):
"""
Modifies nums in-place to the next lexicographic permutation.
If already the largest, wraps around to the smallest.
Algorithm:
1. Find the rightmost index i where nums[i] < nums[i+1] (the 'dip')
2. Find the rightmost index j where nums[j] > nums[i]
3. Swap nums[i] and nums[j]
4. Reverse everything after index i
"""
n = len(nums)
i = n - 2
# Step 1: find rightmost ascending pair
while i >= 0 and nums[i] >= nums[i + 1]:
i -= 1
if i >= 0:
# Step 2: find rightmost element greater than nums[i]
j = n - 1
while nums[j] <= nums[i]:
j -= 1
# Step 3: swap
nums[i], nums[j] = nums[j], nums[i]
# Step 4: reverse the suffix
left, right = i + 1, n - 1
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
# Show the sequence of next permutations starting from [1, 2, 3]
current = [1, 2, 3]
print("Lexicographic sequence for [1, 2, 3]:")
print(f" {current}")
for _ in range(5): # show all 6 permutations
current = next_permutation(list(current))
print(f" {current}")
print()
# Demonstrate on a mid-sequence example
p = [1, 3, 2]
print(f"next_permutation({p}) = {next_permutation(list(p))}")
p = [3, 2, 1] # largest — wraps to smallest
print(f"next_permutation({p}) = {next_permutation(list(p))} (wrapped)")
The next permutation algorithm runs in O(n) time and O(1) space — far better than generating all permutations when you only need the next one.
Complexity analysis
| Approach | Time | Space |
|---|---|---|
| Backtracking (used array) | O(n × n!) | O(n) stack + O(n × n!) output |
| Backtracking (swap) | O(n × n!) | O(n) stack + O(n × n!) output |
| Next permutation | O(n) | O(1) |
The n factor in backtracking time comes from copying each length-n permutation into the result. The total number of nodes in the decision tree is n! + n×(n-1)! + … which simplifies to O(n × n!).
Real-world applications
Scheduling jobs in all possible orders. Finding the optimal order to schedule n tasks (e.g., minimizing total waiting time) requires evaluating all n! orderings, or at least pruning smartly through them.
Anagram generation. All permutations of a word’s letters are its anagrams. Filtering by dictionary membership gives valid words.
Brute-force password cracking. A 4-character PIN from digits 0-9 has 10^4 = 10,000 combinations (repetition allowed). Character permutations without repetition are a subset of that problem.
Travelling salesman problem. For small numbers of cities, brute force evaluates all n! routes and picks the shortest. Permutations of city order form the search space.
# Practical example: find the optimal job schedule (minimize total wait time)
# Each job has a processing time. Schedule to minimize sum of completion times.
def optimal_schedule(jobs):
"""
Shortest Job First is optimal, but let's verify by checking all permutations.
Total cost = sum of completion times for each job.
"""
from itertools import permutations as itertools_perms
best_order = None
best_cost = float('inf')
for perm in itertools_perms(range(len(jobs))):
completion_time = 0
total_cost = 0
for idx in perm:
completion_time += jobs[idx]
total_cost += completion_time
if total_cost < best_cost:
best_cost = total_cost
best_order = [jobs[i] for i in perm]
return best_order, best_cost
jobs = [3, 1, 4, 1, 5] # processing times
order, cost = optimal_schedule(jobs)
print(f"Job processing times: {jobs}")
print(f"Optimal order: {order}")
print(f"Total completion cost: {cost}")
# Verify: Shortest Job First should match
sjf = sorted(jobs)
completion = 0
sjf_cost = 0
for t in sjf:
completion += t
sjf_cost += completion
print(f"\nShortest Job First order: {sjf}")
print(f"SJF total cost: {sjf_cost} (matches: {sjf_cost == cost})")
This confirms the classic SJF theorem: sorting by processing time minimizes total completion time. The brute-force permutation approach proves it holds for any input, and for small n is completely practical.
Backtracking Problems
Practice problems for exhaustive search, pruning, and recursive decision trees.
Generate Parentheses
Difficulty: Medium Source: NeetCode
Problem
Given
npairs of parentheses, write a function to generate all combinations of well-formed parentheses.Example 1: Input:
n = 3Output:["((()))","(()())","(())()","()(())","()()()"]Example 2: Input:
n = 1Output:["()"]Constraints:
1 <= n <= 8
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion — understanding how a function calls itself with modified state
- Backtracking — building a candidate solution step by step and undoing choices when a branch is invalid
1. Brute Force
Intuition
Generate every possible string of length 2n made up of ( and ) characters, then filter out the invalid ones. A string is valid if at no prefix does the count of ) exceed the count of (, and both counts are equal at the end. This works but wastes a lot of time building strings we immediately throw away.
Algorithm
- Generate all
2^(2n)strings of length2nusing(and). - For each string, check if it is a valid parentheses sequence.
- Collect and return all valid strings.
Solution
def generateParenthesis_brute(n: int):
result = []
def is_valid(s):
balance = 0
for c in s:
balance += 1 if c == '(' else -1
if balance < 0:
return False
return balance == 0
def generate(current):
if len(current) == 2 * n:
if is_valid(current):
result.append(current)
return
generate(current + '(')
generate(current + ')')
generate("")
return result
print(generateParenthesis_brute(3))
# ["((()))","(()())","(())()","()(())","()()()"]
print(generateParenthesis_brute(1))
# ["()"]
Complexity
- Time:
O(2^(2n) * n)— generate all strings, validate each one - Space:
O(n)— recursion depth
2. Backtracking
Intuition
Instead of generating everything and filtering, we build the string character by character and only ever add a character that keeps the sequence valid. The two rules are: add ( if we still have open parentheses left to place (open < n), and add ) if the number of close brackets placed so far is less than the number of open brackets placed (close < open). When the string reaches length 2n it is guaranteed to be valid — no filtering needed.
Algorithm
- Start with an empty string,
open = 0,close = 0. - If
len(current) == 2 * n, addcurrentto results and return. - If
open < n, recurse with(appended andopen + 1. - If
close < open, recurse with)appended andclose + 1.
Solution
def generateParenthesis(n: int):
result = []
def backtrack(current, open_count, close_count):
if len(current) == 2 * n:
result.append(current)
return
if open_count < n:
backtrack(current + '(', open_count + 1, close_count)
if close_count < open_count:
backtrack(current + ')', open_count, close_count + 1)
backtrack("", 0, 0)
return result
print(generateParenthesis(3))
# ["((()))","(()())","(())()","()(())","()()()"]
print(generateParenthesis(1))
# ["()"]
print(generateParenthesis(2))
# ["(())", "()()"]
Complexity
- Time:
O(4^n / sqrt(n))— the nth Catalan number counts valid sequences - Space:
O(n)— recursion depth is at most2n
Common Pitfalls
Adding ) without enough open brackets. If you don’t guard close < open, you’ll generate strings like )( which are never valid. Always check this condition before adding a closing bracket.
Checking validity at the end instead of pruning early. The brute force approach checks validity only after building the full string. The backtracking approach avoids entire subtrees by never placing an invalid character — this is the core win.
Passing a mutable list as the “current” string. Using a list and joining at the end is slightly more efficient in Python than string concatenation (current + '('), but either works correctly. If you use a list, remember to pop() after each recursive call to undo the choice.
Sum of All Subsets XOR Total
Difficulty: Easy Source: NeetCode
Problem
The XOR total of an array is defined as the bitwise XOR of all its elements, or
0if the array is empty.Given an array
nums, return the sum of all XOR totals for every subset ofnums.Note: Subsets with the same elements should be counted multiple times.
Example 1: Input:
nums = [1, 3]Output:6Explanation: Subsets:{}→ 0,{1}→ 1,{3}→ 3,{1,3}→ 2. Sum = 0+1+3+2 = 6.Example 2: Input:
nums = [5, 1, 6]Output:28Constraints:
1 <= nums.length <= 121 <= nums[i] <= 20
Prerequisites
Before attempting this problem, you should be comfortable with:
- Bit manipulation — XOR behaves differently from addition; know that
a ^ a = 0anda ^ 0 = a - Subsets / power set — understanding that an array of length
nhas2^nsubsets - Backtracking — recursively including or excluding each element
1. Brute Force
Intuition
Enumerate every subset of nums by either including or excluding each element, compute the XOR total of each subset, and add it to a running sum. With at most 12 elements there are only 2^12 = 4096 subsets, so this is totally fine.
Algorithm
- Define a recursive function
dfs(index, current_xor). - At each index, add
current_xorto the total (this accounts for the current subset). - Recurse by including
nums[index](XOR it in) and by excluding it (XOR unchanged). - Stop when
index == len(nums).
Solution
def subsetXORSum_brute(nums):
total = 0
def dfs(index, current_xor):
nonlocal total
if index == len(nums):
total += current_xor
return
# include nums[index]
dfs(index + 1, current_xor ^ nums[index])
# exclude nums[index]
dfs(index + 1, current_xor)
dfs(0, 0)
return total
print(subsetXORSum_brute([1, 3])) # 6
print(subsetXORSum_brute([5, 1, 6])) # 28
print(subsetXORSum_brute([3, 4, 5, 6, 7, 8])) # 480
Complexity
- Time:
O(2^n)— one call per subset - Space:
O(n)— recursion depth
2. Bit Observation
Intuition
There’s a beautiful math shortcut here. Each element nums[i] appears in exactly 2^(n-1) subsets (half of all subsets include any given element). For each bit position, a bit in the final answer is set if and only if at least one element in nums has that bit set — that’s exactly the OR of all elements. Multiplying by 2^(n-1) gives the answer directly. This turns an exponential problem into an O(n) one.
Algorithm
- Compute
or_total = OR of all elements in nums. - Return
or_total * (2 ** (len(nums) - 1)).
Solution
def subsetXORSum(nums):
or_total = 0
for num in nums:
or_total |= num
return or_total * (2 ** (len(nums) - 1))
print(subsetXORSum([1, 3])) # 6
print(subsetXORSum([5, 1, 6])) # 28
print(subsetXORSum([3, 4, 5, 6, 7, 8])) # 480
Complexity
- Time:
O(n)— single pass to compute OR - Space:
O(1)
Common Pitfalls
Forgetting the empty subset. The empty subset has an XOR total of 0, which contributes 0 to the sum — so you can safely ignore it, but make sure your recursion base case adds current_xor (which will be 0 for the empty subset) rather than skipping it.
Confusing XOR with OR. The per-subset operation is XOR, but the observation formula uses OR across all elements. These are different things — don’t mix them up.
Off-by-one in the exponent. The formula is OR_total * 2^(n-1), not 2^n. Each element appears in half the subsets, so the multiplier is 2^(n-1).
Subsets
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
numsof unique elements, return all possible subsets (the power set).The solution set must not contain duplicate subsets. Return the solution in any order.
Example 1: Input:
nums = [1, 2, 3]Output:[[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]Example 2: Input:
nums = [0]Output:[[], [0]]Constraints:
1 <= nums.length <= 10-10 <= nums[i] <= 10- All elements of
numsare unique.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion — calling a function with a smaller subproblem
- Backtracking — making a choice, recursing, then undoing the choice
1. Cascading (Iterative)
Intuition
Start with one subset: the empty set. For each element in nums, take every existing subset, append the current element to it, and add the result as a new subset. After processing all elements, you have the full power set. No recursion required — it’s a clean iterative build-up.
Algorithm
- Initialize
result = [[]]. - For each
numinnums:- For each existing subset
sinresult, creates + [num]. - Extend
resultwith all the new subsets.
- For each existing subset
- Return
result.
Solution
def subsets_cascading(nums):
result = [[]]
for num in nums:
new_subsets = [s + [num] for s in result]
result.extend(new_subsets)
return result
print(subsets_cascading([1, 2, 3]))
# [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]
print(subsets_cascading([0]))
# [[], [0]]
Complexity
- Time:
O(n * 2^n)— we process each of the2^nsubsets, each taking up to O(n) to copy - Space:
O(n * 2^n)— storing all subsets
2. Backtracking
Intuition
At each index i, we make a binary choice: include nums[i] in the current subset or skip it and move to i+1. By exploring both branches for every element, we naturally enumerate all 2^n subsets. We record a snapshot of the current subset at every node of the recursion tree (not just at the leaves), so every partial selection is captured.
Algorithm
- Define
backtrack(start, current). - Append a copy of
currenttoresult(every state is a valid subset). - For each index
ifromstarttolen(nums) - 1:- Append
nums[i]tocurrent. - Recurse with
start = i + 1. - Pop
nums[i]fromcurrent(backtrack).
- Append
- Call
backtrack(0, [])to kick things off.
Solution
def subsets(nums):
result = []
def backtrack(start, current):
result.append(list(current)) # snapshot of current subset
for i in range(start, len(nums)):
current.append(nums[i])
backtrack(i + 1, current)
current.pop() # undo the choice
backtrack(0, [])
return result
print(subsets([1, 2, 3]))
# [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
print(subsets([0]))
# [[], [0]]
Complexity
- Time:
O(n * 2^n)—2^nsubsets, each costs O(n) to copy - Space:
O(n)— recursion depth is at mostn(excluding output)
Common Pitfalls
Appending current instead of list(current). If you do result.append(current), you’re storing a reference to the same list that gets modified throughout the recursion. Always append a copy: result.append(list(current)) or result.append(current[:]).
Incrementing start incorrectly. When you recurse, pass i + 1 (not start + 1). Using start + 1 would only ever look one position ahead, missing many subsets.
Not popping after the recursive call. Every append inside the loop must have a corresponding pop after the recursive call. Forgetting the pop means your current list keeps growing across branches.
Combination Sum
Difficulty: Medium Source: NeetCode
Problem
Given an array of distinct integers
candidatesand a target integertarget, return a list of all unique combinations ofcandidateswhere the chosen numbers sum totarget. You may return the combinations in any order.The same number may be chosen from
candidatesan unlimited number of times. Two combinations are unique if the frequency of at least one of the chosen numbers is different.Example 1: Input:
candidates = [2, 3, 6, 7],target = 7Output:[[2, 2, 3], [7]]Example 2: Input:
candidates = [2, 3],target = 4Output:[[2, 2], [3], [2, 3]]— wait,[3]sums to 3 not 4. Correct:[[2, 2]]… Actually:[[2, 2], [2, 2]]— no.2+2=4and3alone is 3 not 4. Output:[[2, 2]]Example 3: Input:
candidates = [2, 3, 5],target = 8Output:[[2, 2, 2, 2], [2, 3, 3], [3, 5]]Constraints:
1 <= candidates.length <= 302 <= candidates[i] <= 40- All elements of
candidatesare distinct.1 <= target <= 40
Prerequisites
Before attempting this problem, you should be comfortable with:
- Backtracking — exploring choices and undoing them when a path doesn’t lead to a solution
- Recursion — building combinations incrementally
1. Brute Force
Intuition
Generate all possible combinations of candidates (with repetition) up to some maximum length and check whether each one sums to target. The maximum number of elements in a valid combination is target // min(candidates). This is horribly inefficient but gets the point across.
Algorithm
- Compute
max_len = target // min(candidates). - Generate all combinations with repetition up to length
max_len. - Filter those that sum exactly to
target. - Deduplicate (since the same set in different orders counts once).
Solution
from itertools import combinations_with_replacement
def combinationSum_brute(candidates, target):
result = set()
max_len = target // min(candidates)
for length in range(1, max_len + 1):
for combo in combinations_with_replacement(sorted(candidates), length):
if sum(combo) == target:
result.add(combo)
return [list(c) for c in result]
print(combinationSum_brute([2, 3, 6, 7], 7)) # [[2, 2, 3], [7]]
print(combinationSum_brute([2, 3, 5], 8)) # [[2, 2, 2, 2], [2, 3, 3], [3, 5]]
Complexity
- Time:
O(n^(target/min))— exponential in the worst case - Space:
O(target/min)— recursion depth
2. Backtracking
Intuition
Walk through the candidates array from left to right. At each position i, you can either keep using candidates[i] (recurse with the same i, subtracting its value from the remaining target) or move on to candidates[i+1]. If the remaining target hits 0 you’ve found a valid combination; if it goes negative you prune that branch. Because we always recurse starting from the current index or later, we never produce duplicate combinations.
Algorithm
- Define
backtrack(start, current, remaining). - If
remaining == 0: append a copy ofcurrentto results and return. - If
remaining < 0: return (prune). - For each index
ifromstarttolen(candidates) - 1:- Append
candidates[i]tocurrent. - Recurse with
start = i(same element allowed again) andremaining - candidates[i]. - Pop
candidates[i]fromcurrent.
- Append
Solution
def combinationSum(candidates, target):
result = []
def backtrack(start, current, remaining):
if remaining == 0:
result.append(list(current))
return
if remaining < 0:
return
for i in range(start, len(candidates)):
current.append(candidates[i])
backtrack(i, current, remaining - candidates[i]) # i not i+1 — reuse allowed
current.pop()
backtrack(0, [], target)
return result
print(combinationSum([2, 3, 6, 7], 7)) # [[2, 2, 3], [7]]
print(combinationSum([2, 3, 5], 8)) # [[2, 2, 2, 2], [2, 3, 3], [3, 5]]
print(combinationSum([2], 1)) # []
Complexity
- Time:
O(n^(target/min))— in the worst case, explores deep trees; sorting + pruning helps in practice - Space:
O(target/min)— maximum recursion depth
Common Pitfalls
Passing i + 1 instead of i when recursing. Since each candidate can be reused unlimited times, you need to recurse with start = i, not start = i + 1. Using i + 1 would prevent reuse and miss combinations like [2, 2, 3].
Not making a copy when appending to results. result.append(current) stores a reference; always use result.append(list(current)).
Not pruning when remaining < 0. Without this check, you’d explore paths that can never reach the target. Sorting candidates first lets you add an early break instead of return for more aggressive pruning.
Combination Sum II
Difficulty: Medium Source: NeetCode
Problem
Given a collection of candidate numbers (
candidates) and a target number (target), find all unique combinations incandidateswhere the candidate numbers sum totarget.Each number in
candidatesmay only be used once in the combination. The solution set must not contain duplicate combinations.Example 1: Input:
candidates = [10, 1, 2, 7, 6, 1, 5],target = 8Output:[[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]Example 2: Input:
candidates = [2, 5, 2, 1, 2],target = 5Output:[[1, 2, 2], [5]]Constraints:
1 <= candidates.length <= 1001 <= candidates[i] <= 501 <= target <= 30
Prerequisites
Before attempting this problem, you should be comfortable with:
- Combination Sum I — the version where reuse is allowed and all candidates are distinct
- Backtracking — exploring and pruning a decision tree
- Duplicate handling — sorting to group duplicates and skipping at the same recursion level
1. Brute Force
Intuition
Sort the candidates. Generate every possible subset of candidates (each element used at most once). For each subset that sums to target, convert it to a sorted tuple and add to a set to avoid duplicate results. Return the deduplicated list. This is wasteful because we build many subsets that can never sum to target, but it’s conceptually simple.
Algorithm
- Sort
candidates. - Use backtracking to enumerate all subsets.
- If a subset sums to
target, add its tuple to a result set. - Convert the set back to a list of lists.
Solution
def combinationSum2_brute(candidates, target):
candidates.sort()
result_set = set()
def backtrack(start, current, remaining):
if remaining == 0:
result_set.add(tuple(current))
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break
current.append(candidates[i])
backtrack(i + 1, current, remaining - candidates[i])
current.pop()
backtrack(0, [], target)
return [list(t) for t in result_set]
print(sorted(combinationSum2_brute([10, 1, 2, 7, 6, 1, 5], 8)))
# [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]
print(sorted(combinationSum2_brute([2, 5, 2, 1, 2], 5)))
# [[1, 2, 2], [5]]
Complexity
- Time:
O(2^n * n)— exponential subsets, each costs O(n) to hash - Space:
O(n)— recursion depth
2. Backtracking with Duplicate Skipping
Intuition
Sort candidates first so duplicates are adjacent. During backtracking, after picking candidates[i] and recursing, if the next element candidates[i+1] has the same value as candidates[i], skip it entirely. This prevents starting the same subtree twice from the same position in the recursion, which is exactly what causes duplicate results. Each element is still used at most once (we pass i + 1 when recursing).
Algorithm
- Sort
candidates. - Define
backtrack(start, current, remaining). - If
remaining == 0: recordcurrentand return. - For
ifromstartto end:- Skip if
i > startandcandidates[i] == candidates[i - 1](same value at same level). - Skip if
candidates[i] > remaining(prune — sorted order makes this a break, not continue). - Append
candidates[i], recurse withi + 1, pop.
- Skip if
Solution
def combinationSum2(candidates, target):
candidates.sort()
result = []
def backtrack(start, current, remaining):
if remaining == 0:
result.append(list(current))
return
for i in range(start, len(candidates)):
# skip duplicates at the same recursion level
if i > start and candidates[i] == candidates[i - 1]:
continue
# prune: sorted array, so all remaining are too large
if candidates[i] > remaining:
break
current.append(candidates[i])
backtrack(i + 1, current, remaining - candidates[i])
current.pop()
backtrack(0, [], target)
return result
print(combinationSum2([10, 1, 2, 7, 6, 1, 5], 8))
# [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]
print(combinationSum2([2, 5, 2, 1, 2], 5))
# [[1, 2, 2], [5]]
print(combinationSum2([1, 1, 1, 1], 2))
# [[1, 1]]
Complexity
- Time:
O(2^n)— in the worst case (all distinct), explores every subset - Space:
O(n)— recursion depth
Common Pitfalls
Using i > 0 instead of i > start for the duplicate check. The condition i > start and candidates[i] == candidates[i-1] means “skip if we’re not the first element at this recursion level and the previous element had the same value.” Using i > 0 instead is wrong — it would skip valid combinations like [1, 1, 6] where you legitimately pick the second 1 at a deeper level.
Passing i instead of i + 1 when recursing. Unlike Combination Sum I, each number can only be used once. Pass i + 1 to move past the current element.
Forgetting to sort. Without sorting, duplicates aren’t adjacent and the skip condition candidates[i] == candidates[i-1] doesn’t catch all duplicates. Always sort first.
Combinations
Difficulty: Medium Source: NeetCode
Problem
Given two integers
nandk, return all possible combinations ofknumbers chosen from the range[1, n].You may return the answer in any order.
Example 1: Input:
n = 4,k = 2Output:[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]Example 2: Input:
n = 1,k = 1Output:[[1]]Constraints:
1 <= n <= 201 <= k <= n
Prerequisites
Before attempting this problem, you should be comfortable with:
- Subsets — generating all subsets is the generalization of this problem (subsets of size k)
- Backtracking — making choices, recursing, and undoing choices
- Combinatorics — C(n, k) counts the number of valid outputs
1. Brute Force
Intuition
Use Python’s itertools.combinations to directly generate all C(n, k) combinations of k elements chosen from [1, n]. This is a valid approach in an interview if you’re allowed standard library usage, but understanding the underlying mechanism is the real goal here.
Algorithm
- Create a range
[1, 2, ..., n]. - Use
itertools.combinations(range(1, n+1), k)to get all combinations. - Convert to list of lists and return.
Solution
from itertools import combinations
def combine_builtin(n, k):
return [list(c) for c in combinations(range(1, n + 1), k)]
print(combine_builtin(4, 2))
# [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
print(combine_builtin(1, 1))
# [[1]]
Complexity
- Time:
O(C(n,k) * k)— C(n,k) combinations, each of length k - Space:
O(k)— recursion depth (excluding output)
2. Backtracking
Intuition
Start from number 1 and at each step decide which number to pick next. We always pick numbers in increasing order (start the next pick from current + 1), which naturally avoids duplicates like [2,1] and [1,2] both appearing. We stop recursing once the current combination has k elements. A useful pruning trick: if there aren’t enough numbers left to fill k spots, bail early.
Algorithm
- Define
backtrack(start, current). - If
len(current) == k: append a copy to results and return. - For
ifromstartton:- Pruning: if
n - i + 1 < k - len(current), there aren’t enough numbers left — break. - Append
i, recurse withstart = i + 1, popi.
- Pruning: if
Solution
def combine(n, k):
result = []
def backtrack(start, current):
if len(current) == k:
result.append(list(current))
return
# pruning: need (k - len(current)) more numbers, have (n - i + 1) available
for i in range(start, n + 1):
if n - i + 1 < k - len(current):
break # not enough numbers remaining
current.append(i)
backtrack(i + 1, current)
current.pop()
backtrack(1, [])
return result
print(combine(4, 2))
# [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
print(combine(1, 1))
# [[1]]
print(combine(5, 3))
# [[1,2,3],[1,2,4],[1,2,5],[1,3,4],[1,3,5],[1,4,5],[2,3,4],[2,3,5],[2,4,5],[3,4,5]]
Complexity
- Time:
O(C(n,k) * k)— C(n,k) valid combinations, each costs O(k) to copy - Space:
O(k)— maximum recursion depth is k
Common Pitfalls
Iterating up to n but forgetting + 1 in range. range(start, n) stops at n-1; you need range(start, n + 1) to include n itself.
Missing the pruning condition. Without pruning, combine(20, 10) explores many dead-end branches. The check n - i + 1 < k - len(current) short-circuits these: if remaining numbers are fewer than remaining spots needed, break early.
Appending current instead of list(current). Classic backtracking pitfall — always copy the list before appending to results.
Permutations
Difficulty: Medium Source: NeetCode
Problem
Given an array
numsof distinct integers, return all the possible permutations. You can return the answer in any order.Example 1: Input:
nums = [1, 2, 3]Output:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]Example 2: Input:
nums = [0, 1]Output:[[0,1],[1,0]]Example 3: Input:
nums = [1]Output:[[1]]Constraints:
1 <= nums.length <= 6-10 <= nums[i] <= 10- All the integers of
numsare unique.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Subsets — at each step you’re choosing which element to place next
- Backtracking — making a choice, recursing, then undoing the choice to try another
1. Backtracking with used Array
Intuition
Build the permutation position by position. At each step, scan through all elements and place any element that hasn’t been used yet. After filling a position, mark it as used and recurse to fill the next position. When all positions are filled, record the permutation. Then unmark the element (backtrack) and try the next candidate for this position.
Algorithm
- Initialize
used = [False] * len(nums)andcurrent = []. - Define
backtrack(). - If
len(current) == len(nums): append a copy to results and return. - For each index
iinnums:- If
used[i]: skip. - Mark
used[i] = True, appendnums[i]. - Recurse.
- Mark
used[i] = False, popnums[i].
- If
Solution
def permute_used(nums):
result = []
used = [False] * len(nums)
def backtrack(current):
if len(current) == len(nums):
result.append(list(current))
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
current.append(nums[i])
backtrack(current)
current.pop()
used[i] = False
backtrack([])
return result
print(permute_used([1, 2, 3]))
# [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
print(permute_used([0, 1]))
# [[0,1],[1,0]]
Complexity
- Time:
O(n! * n)— n! permutations, each costs O(n) to copy - Space:
O(n)— recursion depth plus theusedarray
2. Swap-Based Backtracking
Intuition
Think of it differently: the permutation is being built left to right, and at position start we’re deciding which element occupies that slot. We swap nums[start] with each nums[i] (for i >= start), then recurse on the rest of the array (start + 1). After the recursive call, we swap back to restore the original order. This approach modifies nums in place and avoids a separate used array.
Algorithm
- Define
backtrack(start). - If
start == len(nums): append a copy ofnumsto results and return. - For
ifromstarttolen(nums) - 1:- Swap
nums[start]andnums[i]. - Recurse with
start + 1. - Swap
nums[start]andnums[i]back.
- Swap
Solution
def permute(nums):
result = []
def backtrack(start):
if start == len(nums):
result.append(list(nums))
return
for i in range(start, len(nums)):
nums[start], nums[i] = nums[i], nums[start] # choose
backtrack(start + 1)
nums[start], nums[i] = nums[i], nums[start] # undo
backtrack(0)
return result
print(permute([1, 2, 3]))
# [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
print(permute([0, 1]))
# [[0,1],[1,0]]
print(permute([1]))
# [[1]]
Complexity
- Time:
O(n! * n)— n! permutations, each costs O(n) to copy - Space:
O(n)— recursion depth; no extrausedarray needed
Common Pitfalls
Forgetting to swap back after recursing. In the swap-based approach, if you don’t swap back, the array is permanently modified and subsequent iterations of the loop see a wrong array state.
Appending nums instead of list(nums). At the base case, nums is the live array that keeps changing. You must snapshot it with list(nums) or nums[:].
Confusing permutations with combinations. In permutations, order matters — [1,2,3] and [3,2,1] are different outputs. In combinations (subsets), order doesn’t matter. The used array approach lets every element appear at every position, which is what creates the ordering freedom.
Subsets II
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
numsthat may contain duplicates, return all possible subsets (the power set).The solution set must not contain duplicate subsets. Return the solution in any order.
Example 1: Input:
nums = [1, 2, 2]Output:[[], [1], [1,2], [1,2,2], [2], [2,2]]Example 2: Input:
nums = [0]Output:[[], [0]]Constraints:
1 <= nums.length <= 10-10 <= nums[i] <= 10
Prerequisites
Before attempting this problem, you should be comfortable with:
- Subsets (LeetCode 78) — the version without duplicates; this problem extends it
- Backtracking — recursive inclusion/exclusion of elements
- Duplicate handling — the same sort-and-skip trick from Combination Sum II
1. Brute Force
Intuition
Run the standard subset backtracking from Subsets I, but convert every found subset to a sorted tuple and add it to a set to deduplicate. At the end, convert back to a list of lists. It works but the set-based deduplication happens after the fact, so we still explore duplicate branches unnecessarily.
Algorithm
- Sort
nums. - Backtrack to generate all subsets.
- Store each subset as a tuple in a set.
- Return the deduplicated results.
Solution
def subsetsWithDup_brute(nums):
nums.sort()
result_set = set()
def backtrack(start, current):
result_set.add(tuple(current))
for i in range(start, len(nums)):
current.append(nums[i])
backtrack(i + 1, current)
current.pop()
backtrack(0, [])
return [list(t) for t in result_set]
print(sorted(subsetsWithDup_brute([1, 2, 2])))
# [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
print(sorted(subsetsWithDup_brute([0])))
# [[], [0]]
Complexity
- Time:
O(2^n * n)— exponential subsets, hashing each - Space:
O(2^n * n)— storing all subsets in a set
2. Backtracking with Duplicate Skipping
Intuition
Sort nums first so duplicate values are adjacent. During backtracking, at a given recursion level (a given start value), if we pick nums[i] and then try nums[i+1] where nums[i+1] == nums[i], we’d generate the exact same subtree twice. So skip any nums[i] where i > start and nums[i] == nums[i-1]. This is the same skip rule as Combination Sum II — it only prevents picking the same value twice at the same recursion level, not at deeper levels.
Algorithm
- Sort
nums. - Define
backtrack(start, current). - Append a copy of
currentto results (every state is a valid subset). - For
ifromstartto end:- If
i > start and nums[i] == nums[i-1]: skip (duplicate at same level). - Append
nums[i], recurse withi + 1, pop.
- If
Solution
def subsetsWithDup(nums):
nums.sort()
result = []
def backtrack(start, current):
result.append(list(current))
for i in range(start, len(nums)):
# skip duplicate elements at the same recursion level
if i > start and nums[i] == nums[i - 1]:
continue
current.append(nums[i])
backtrack(i + 1, current)
current.pop()
backtrack(0, [])
return result
print(subsetsWithDup([1, 2, 2]))
# [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
print(subsetsWithDup([0]))
# [[], [0]]
print(subsetsWithDup([1, 1, 2, 2]))
# [[], [1], [1, 1], [1, 1, 2], [1, 1, 2, 2], [1, 2], [1, 2, 2], [2], [2, 2]]
Complexity
- Time:
O(n * 2^n)— at most2^nsubsets, each costs O(n) to copy - Space:
O(n)— recursion depth
Common Pitfalls
Using i > 0 instead of i > start in the skip condition. This is the most common mistake in this problem. The check i > start means “we’re not the first element being considered at this recursion level.” Using i > 0 would incorrectly skip valid deeper picks — for example, it would prevent [1, 2, 2] from being generated because it would skip the second 2 even at a deeper recursion level where it’s valid.
Forgetting to sort. The skip condition relies on duplicates being adjacent. Without sorting, [2, 1, 2] would not be caught and you’d generate duplicate subsets.
Not appending before the loop. The result.append(list(current)) line must come before the for loop, not inside it. Every state — including the empty set — is a valid subset.
Permutations II
Difficulty: Medium Source: NeetCode
Problem
Given a collection of numbers
numsthat might contain duplicates, return all possible unique permutations in any order.Example 1: Input:
nums = [1, 1, 2]Output:[[1, 1, 2], [1, 2, 1], [2, 1, 1]]Example 2: Input:
nums = [1, 2, 3]Output:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]Constraints:
1 <= nums.length <= 8-10 <= nums[i] <= 10
Prerequisites
Before attempting this problem, you should be comfortable with:
- Permutations I — the version with all distinct elements
- Backtracking — building permutations position by position with a
usedarray - Duplicate handling — sort-and-skip applied to permutations
1. Brute Force
Intuition
Generate all permutations using the standard used-array approach (from Permutations I), convert each one to a tuple, and add it to a set to deduplicate. Simple, but we still do redundant work exploring branches that produce duplicate permutations.
Algorithm
- Sort
nums(optional for brute force but good habit). - Backtrack with a
usedarray; at base case, convert to tuple and add to a set. - Return the set converted to list of lists.
Solution
def permuteUnique_brute(nums):
result_set = set()
used = [False] * len(nums)
def backtrack(current):
if len(current) == len(nums):
result_set.add(tuple(current))
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
current.append(nums[i])
backtrack(current)
current.pop()
used[i] = False
backtrack([])
return [list(t) for t in result_set]
print(sorted(permuteUnique_brute([1, 1, 2])))
# [[1, 1, 2], [1, 2, 1], [2, 1, 1]]
Complexity
- Time:
O(n! * n)— explores all permutations, hashing each - Space:
O(n! * n)— storing all permutations in a set
2. Backtracking with Sorted Duplicate Skip
Intuition
Sort nums so duplicates are adjacent. Use the used array approach from Permutations I. Before placing nums[i] at the current position, add a skip condition: if i > 0 and nums[i] == nums[i-1] and used[i-1] is False, skip nums[i]. This rule says: “don’t place nums[i] at this position if an identical earlier element is available but hasn’t been placed yet.” This enforces a consistent ordering where duplicate values are always picked left-to-right, eliminating duplicate permutations without needing a set.
Algorithm
- Sort
nums. - Initialize
used = [False] * len(nums). - Define
backtrack(current). - If
len(current) == len(nums): append a copy and return. - For each
i:- Skip if
used[i]. - Skip if
i > 0 and nums[i] == nums[i-1] and not used[i-1]. - Mark used, append, recurse, pop, unmark.
- Skip if
Solution
def permuteUnique(nums):
nums.sort()
result = []
used = [False] * len(nums)
def backtrack(current):
if len(current) == len(nums):
result.append(list(current))
return
for i in range(len(nums)):
if used[i]:
continue
# skip: same value as previous, and previous hasn't been used yet
# this ensures we always use the left duplicate before the right one
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
used[i] = True
current.append(nums[i])
backtrack(current)
current.pop()
used[i] = False
backtrack([])
return result
print(permuteUnique([1, 1, 2]))
# [[1, 1, 2], [1, 2, 1], [2, 1, 1]]
print(permuteUnique([1, 2, 3]))
# [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
print(permuteUnique([1, 1, 1]))
# [[1, 1, 1]]
Complexity
- Time:
O(n! * n)— in the worst case (all distinct), n! permutations; duplicates reduce this - Space:
O(n)— recursion depth plususedarray
Common Pitfalls
The skip condition feels backwards. not used[i-1] checks that the previous duplicate has NOT been used. This ensures that when you try to place nums[i] (a duplicate of nums[i-1]), nums[i-1] was placed before nums[i] in a previous call — enforcing left-to-right order for duplicates. If you use used[i-1] (the opposite), you’d generate far more duplicates.
Forgetting to sort. Without sorting, nums[i] == nums[i-1] won’t reliably detect duplicates since they may not be adjacent.
Using a set to deduplicate instead of pruning. A set works but is wasteful — you pay the cost of generating and hashing duplicate permutations. The not used[i-1] pruning avoids generating duplicates in the first place.
Word Search
Difficulty: Medium Source: NeetCode
Problem
Given an
m x ngrid of charactersboardand a stringword, returntrueifwordexists in the grid.The word can be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once.
Example 1: Input:
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]],word = "ABCCED"Output:trueExample 2: Input:
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]],word = "SEE"Output:trueExample 3: Input:
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]],word = "ABCB"Output:falseConstraints:
m == board.length,n == board[i].length1 <= m, n <= 61 <= word.length <= 15boardandwordconsist of only lowercase and uppercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS on a grid — traversing cells by exploring neighbors recursively
- Backtracking — marking cells as visited, recursing, then unmarking to restore state
- 2D array indexing — handling row/col bounds carefully
1. Brute Force
Intuition
Try starting the word search from every cell in the grid. From each starting cell, do a depth-first search that follows adjacent cells matching successive characters of word. Mark cells as visited during a path and unmark them when backtracking so other starting points or paths can use them. Return true as soon as a valid path is found.
Algorithm
- For each cell
(r, c)in the grid, calldfs(r, c, 0)where0is the current index inword. - In
dfs(r, c, index):- If
index == len(word): returnTrue(all characters matched). - If out of bounds, already visited, or
board[r][c] != word[index]: returnFalse. - Mark
board[r][c]as visited (temporarily replace with#). - Recurse in all 4 directions with
index + 1. - Unmark (restore the original character).
- Return whether any direction succeeded.
- If
Solution
def exist(board, word):
rows, cols = len(board), len(board[0])
def dfs(r, c, index):
if index == len(word):
return True
if r < 0 or r >= rows or c < 0 or c >= cols:
return False
if board[r][c] != word[index]:
return False
# mark as visited
temp = board[r][c]
board[r][c] = '#'
# explore all 4 directions
found = (dfs(r + 1, c, index + 1) or
dfs(r - 1, c, index + 1) or
dfs(r, c + 1, index + 1) or
dfs(r, c - 1, index + 1))
# backtrack: restore cell
board[r][c] = temp
return found
for r in range(rows):
for c in range(cols):
if dfs(r, c, 0):
return True
return False
board1 = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]
print(exist(board1, "ABCCED")) # True
print(exist(board1, "SEE")) # True
print(exist(board1, "ABCB")) # False
board2 = [["a"]]
print(exist(board2, "a")) # True
Complexity
- Time:
O(m * n * 4^L)— for each of them*nstarting cells, DFS explores up to 4 directions at each ofLsteps - Space:
O(L)— recursion depth equals word lengthL
Common Pitfalls
Not restoring the cell after backtracking. If you forget board[r][c] = temp, cells remain marked as # from a failed path and block other search paths. This is the backtracking step — without it, you’ll get incorrect False results.
Checking bounds after accessing the cell. Always check r < 0 or r >= rows or c < 0 or c >= cols before accessing board[r][c], or you’ll get an index out of range error.
Using a separate visited set vs. modifying in place. A visited set works too, but modifying the board in place (temporarily writing #) is slightly faster because it avoids set lookups. Just make sure to restore the original character.
Short-circuiting with or. The expression dfs(...) or dfs(...) or ... stops as soon as one direction returns True. This is efficient — once you find a valid path you stop exploring. Make sure this short-circuit doesn’t skip the restore step, which is why the restore happens after the found = ... line, not inside the or chain.
Palindrome Partitioning
Difficulty: Medium Source: NeetCode
Problem
Given a string
s, partitionssuch that every substring of the partition is a palindrome. Return all possible palindrome partitionings.Example 1: Input:
s = "aab"Output:[["a","a","b"],["aa","b"]]Example 2: Input:
s = "a"Output:[["a"]]Constraints:
1 <= s.length <= 16sconsists only of lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Palindrome checking — a string equals its reverse; or check with two pointers
- Backtracking — at each position, try all possible next substrings
- Subsets/combinations — this problem is about choosing “cut points” in the string
1. Brute Force
Intuition
At each index in the string, try every possible prefix ending at some index end. If the prefix is a palindrome, add it to the current partition and recurse on the remainder of the string. When the start index reaches the end of the string, we’ve partitioned the whole string into palindromes — record it. This is already basically the optimal backtracking approach; there’s no fundamentally simpler way to enumerate all partitions.
Algorithm
- Define
backtrack(start, current). - If
start == len(s): append a copy ofcurrentto results and return. - For
endfromstart + 1tolen(s):- Extract
substr = s[start:end]. - If
substris a palindrome:- Append
substrtocurrent. - Recurse with
start = end. - Pop
substr.
- Append
- Extract
Solution
def partition_brute(s):
result = []
def is_palindrome(sub):
return sub == sub[::-1]
def backtrack(start, current):
if start == len(s):
result.append(list(current))
return
for end in range(start + 1, len(s) + 1):
substr = s[start:end]
if is_palindrome(substr):
current.append(substr)
backtrack(end, current)
current.pop()
backtrack(0, [])
return result
print(partition_brute("aab")) # [['a', 'a', 'b'], ['aa', 'b']]
print(partition_brute("a")) # [['a']]
Complexity
- Time:
O(n * 2^n)— up to 2^(n-1) partitions, each palindrome check takes O(n) - Space:
O(n)— recursion depth
2. Backtracking with DP Palindrome Precomputation
Intuition
The approach is the same as brute force, but we precompute a 2D table dp[i][j] that stores whether s[i:j+1] is a palindrome. This turns every palindrome check from O(n) into O(1), which helps when the string is longer. dp[i][j] is a palindrome if s[i] == s[j] and either the substring is length 1 or 2, or dp[i+1][j-1] is also a palindrome.
Algorithm
- Precompute
dp[i][j] = True if s[i:j+1] is a palindrome. - Run the same backtracking, but use
dp[start][end-1]instead of callingis_palindrome.
Solution
def partition(s):
n = len(s)
# precompute palindrome table
dp = [[False] * n for _ in range(n)]
for i in range(n - 1, -1, -1):
for j in range(i, n):
if s[i] == s[j] and (j - i <= 1 or dp[i + 1][j - 1]):
dp[i][j] = True
result = []
def backtrack(start, current):
if start == n:
result.append(list(current))
return
for end in range(start, n):
if dp[start][end]: # O(1) palindrome check
current.append(s[start:end + 1])
backtrack(end + 1, current)
current.pop()
backtrack(0, [])
return result
print(partition("aab")) # [['a', 'a', 'b'], ['aa', 'b']]
print(partition("a")) # [['a']]
print(partition("aba")) # [['a', 'b', 'a'], ['aba']]
print(partition("aabaa")) # multiple valid partitions
Complexity
- Time:
O(n^2)for DP precomputation +O(n * 2^n)for backtracking — overallO(n * 2^n) - Space:
O(n^2)for the DP table +O(n)recursion depth
Common Pitfalls
Off-by-one in the end range. The substring s[start:end] uses Python’s exclusive upper bound. If you want to include s[end], you need s[start:end+1]. When using the DP table, dp[start][end] checks if s[start..end] (inclusive) is a palindrome, so you pass the end index inclusively.
Appending current without copying. Always result.append(list(current)). If you append the reference, all stored partitions will point to the same list and end up empty after the recursion finishes.
Forgetting to try single-character substrings. Every single character is trivially a palindrome. Make sure your loop starts at end = start (for the DP version) or end = start + 1 (for the slice version where s[start:start+1] is one character). A common mistake is starting at end = start + 2, which would skip single characters.
Letter Combinations of a Phone Number
Difficulty: Medium Source: NeetCode
Problem
Given a string containing digits from
2-9inclusive, return all possible letter combinations that the number could represent. Return the answer in any order.A mapping of digits to letters (just like on a telephone keypad) is given below. Note that
1does not map to any letters.2 → abc 3 → def 4 → ghi 5 → jkl 6 → mno 7 → pqrs 8 → tuv 9 → wxyzExample 1: Input:
digits = "23"Output:["ad","ae","af","bd","be","bf","cd","ce","cf"]Example 2: Input:
digits = ""Output:[]Example 3: Input:
digits = "2"Output:["a","b","c"]Constraints:
0 <= digits.length <= 4digits[i]is a digit in the range['2', '9'].
Prerequisites
Before attempting this problem, you should be comfortable with:
- Backtracking — at each step, branch on each letter mapped to the current digit
- Recursion — processing digits one at a time and combining results
1. Brute Force (Iterative)
Intuition
Start with a list containing one empty string. For each digit in digits, take every combination built so far and extend it with each letter that the current digit maps to. After processing all digits, the list contains all valid combinations. This is an iterative version of the backtracking idea.
Algorithm
- Initialize
result = [""]. - For each
digitindigits:- Get its
lettersfrom the phone map. - Replace
resultwith every existing string inresultextended by each letter.
- Get its
- Return
result(or[]ifdigitsis empty).
Solution
def letterCombinations_iterative(digits):
if not digits:
return []
phone_map = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
result = [""]
for digit in digits:
letters = phone_map[digit]
result = [combo + letter for combo in result for letter in letters]
return result
print(letterCombinations_iterative("23"))
# ['ad', 'ae', 'af', 'bd', 'be', 'bf', 'cd', 'ce', 'cf']
print(letterCombinations_iterative(""))
# []
print(letterCombinations_iterative("2"))
# ['a', 'b', 'c']
Complexity
- Time:
O(4^n * n)— at most 4 letters per digit, n digits; building each string costs O(n) - Space:
O(4^n * n)— storing all combinations
2. Backtracking
Intuition
Process one digit at a time. For the digit at position index, try each letter it maps to, add that letter to the current combination, then recurse on index + 1. When index reaches the end of digits, the current combination is complete — add it to results. Each recursive call handles exactly one digit, so the recursion depth equals the number of digits.
Algorithm
- Return
[]ifdigitsis empty. - Define the phone keypad mapping.
- Define
backtrack(index, current). - If
index == len(digits): append"".join(current)to results and return. - For each
letterinphone_map[digits[index]]:- Append
letter, recurse withindex + 1, popletter.
- Append
Solution
def letterCombinations(digits):
if not digits:
return []
phone_map = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
result = []
def backtrack(index, current):
if index == len(digits):
result.append("".join(current))
return
for letter in phone_map[digits[index]]:
current.append(letter)
backtrack(index + 1, current)
current.pop()
backtrack(0, [])
return result
print(letterCombinations("23"))
# ['ad', 'ae', 'af', 'bd', 'be', 'bf', 'cd', 'ce', 'cf']
print(letterCombinations(""))
# []
print(letterCombinations("2"))
# ['a', 'b', 'c']
print(letterCombinations("79"))
# ['pw', 'px', 'py', 'pz', 'qw', 'qx', 'qy', 'qz', 'rw', 'rx', 'ry', 'rz', 'sw', 'sx', 'sy', 'sz']
Complexity
- Time:
O(4^n * n)— at most 4 letters per digit;ndigits; joining each combination costs O(n) - Space:
O(n)— recursion depth equals number of digits
Common Pitfalls
Not handling the empty string input. If digits = "", you should return [], not [""]. Always add this guard at the start.
Building the string with + vs. a list. In Python, string concatenation (current + letter) creates a new string object each time. Using a list and "".join(current) at the end is more efficient, especially for longer digit strings.
Forgetting to pop after the recursive call. If you’re using a mutable list current, you must current.pop() after each recursive call. Alternatively, pass an immutable string (current + letter) and skip the pop — this is simpler but slightly less efficient.
Matchsticks to Square
Difficulty: Medium Source: NeetCode
Problem
You are given an integer array
matchstickswherematchsticks[i]is the length of theith matchstick. You want to use all the matchsticks to make one square. You should not break any stick, but you can link them up, and each matchstick must be used exactly once.Return
trueif you can make this square andfalseotherwise.Example 1: Input:
matchsticks = [1, 1, 2, 2, 2]Output:trueExplanation: You can form a square with side length 2: sides [2], [2], [2], [1,1].Example 2: Input:
matchsticks = [3, 3, 3, 3, 4]Output:falseExplanation: Total is 16, which would need sides of length 4 — but the 4 can’t be combined with anything to make 4 using only 3s.Constraints:
1 <= matchsticks.length <= 151 <= matchsticks[i] <= 10^8
Prerequisites
Before attempting this problem, you should be comfortable with:
- Backtracking — trying to place each matchstick into one of 4 sides
- Pruning — stopping early when a branch cannot lead to a solution
1. Brute Force
Intuition
The square has 4 sides, each of length total / 4. Try placing each matchstick into one of the 4 sides. If a side would exceed the target, skip that placement. When all matchsticks are placed, check if all 4 sides are exactly the target. The key insight is that only if total % 4 == 0 is a square even possible.
Algorithm
- If
sum(matchsticks) % 4 != 0: returnFalse. - Set
target = sum(matchsticks) // 4. - Initialize
sides = [0, 0, 0, 0]. - Define
backtrack(index):- If
index == len(matchsticks): return all sides equaltarget. - For each
sideinsides:- If
sides[side] + matchsticks[index] <= target:- Add matchstick to side, recurse, remove.
- If recursion returned
True, returnTrue.
- If
- If
- Return
False.
Solution
def makesquare_brute(matchsticks):
total = sum(matchsticks)
if total % 4 != 0:
return False
target = total // 4
sides = [0] * 4
def backtrack(index):
if index == len(matchsticks):
return all(s == target for s in sides)
for i in range(4):
if sides[i] + matchsticks[index] <= target:
sides[i] += matchsticks[index]
if backtrack(index + 1):
return True
sides[i] -= matchsticks[index]
return False
return backtrack(0)
print(makesquare_brute([1, 1, 2, 2, 2])) # True
print(makesquare_brute([3, 3, 3, 3, 4])) # False
Complexity
- Time:
O(4^n)— each matchstick can go into one of 4 sides - Space:
O(n)— recursion depth
2. Backtracking with Pruning
Intuition
Two key optimizations make this dramatically faster in practice:
-
Sort descending. Place the longest matchsticks first. Large sticks have fewer placement options, so we fail fast when a placement is impossible. This prunes the most branches at the top of the recursion tree.
-
Skip duplicate side lengths. If two sides currently have the same length, placing the current matchstick in either one produces identical subtrees. Skip to the first unique side length.
These pruning tricks turn what would be a TLE solution into one that runs in milliseconds even for n = 15.
Algorithm
- Early exit:
sum % 4 != 0or any stick > target. - Sort
matchsticksin descending order. - Run
backtrack(index, sides)with the two pruning conditions.
Solution
def makesquare(matchsticks):
total = sum(matchsticks)
if total % 4 != 0:
return False
target = total // 4
matchsticks.sort(reverse=True) # sort descending for better pruning
if matchsticks[0] > target:
return False
sides = [0] * 4
def backtrack(index):
if index == len(matchsticks):
# if we placed all sticks and no side exceeded target, it must be a square
return True
seen = set()
for i in range(4):
if sides[i] in seen:
continue # skip identical side lengths — same subtree
if sides[i] + matchsticks[index] <= target:
seen.add(sides[i])
sides[i] += matchsticks[index]
if backtrack(index + 1):
return True
sides[i] -= matchsticks[index]
return False
return backtrack(0)
print(makesquare([1, 1, 2, 2, 2])) # True
print(makesquare([3, 3, 3, 3, 4])) # False
print(makesquare([5, 5, 5, 5])) # True
print(makesquare([1, 1, 1, 1, 1])) # False (sum=5, not divisible by 4)
Complexity
- Time:
O(4^n)worst case, but pruning makes it much faster in practice - Space:
O(n)— recursion depth
Common Pitfalls
Not checking total % 4 != 0 upfront. This is an O(n) check that immediately eliminates many inputs. Always do this first.
Not sorting descending. Sorting ascending works correctly but is much slower because smaller sticks have more placement options, leading to more branching before failures are detected.
Skipping the duplicate-side pruning. Without seen, you try placing the matchstick into side 1 (length 3) and side 2 (length 3) as separate branches, but both produce identical results. The seen set ensures you only try each unique side length once.
Checking all sides equal target only at the end. Since we enforce sides[i] + matchsticks[index] <= target before every placement, if we’ve placed all matchsticks and haven’t violated this, the sides must all be exactly target (because they sum to total = 4 * target). So the base case index == len(matchsticks) returning True is correct.
Partition to K Equal Sum Subsets
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
numsand an integerk, returntrueif it is possible to divide this array intoknon-empty subsets whose sums are all equal.Example 1: Input:
nums = [4, 3, 2, 3, 5, 2, 1],k = 4Output:trueExplanation: It’s possible to divide it into 4 subsets (5), (1, 4), (2, 3), (2, 3) with equal sums.Example 2: Input:
nums = [1, 2, 3, 4],k = 3Output:falseConstraints:
1 <= k <= len(nums) <= 160 < nums[i] < 10000- Every element of
numswill fit in a 32-bit integer.- It is guaranteed that the answer is unique.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Matchsticks to Square — this is essentially the same problem with k sides instead of 4
- Backtracking — trying to fill k buckets one element at a time
- Pruning — avoiding redundant branches to keep runtime feasible
1. Brute Force
Intuition
This is a generalization of Matchsticks to Square. Each of the k buckets must reach exactly target = sum(nums) / k. Try placing each unused element into each bucket in turn. If a bucket reaches target, it’s done — leave it and move on. If placing the element exceeds target, skip that bucket.
Algorithm
- If
sum(nums) % k != 0: returnFalse. - Set
target = sum(nums) // k. - Sort
numsdescending; ifnums[0] > target: returnFalse. - Define
backtrack(index, buckets). - If all buckets are
target: returnTrue. - Try placing
nums[index]into each bucket (skip if overflow). - Backtrack (remove from bucket) and try the next bucket.
Solution
def canPartitionKSubsets_brute(nums, k):
total = sum(nums)
if total % k != 0:
return False
target = total // k
nums.sort(reverse=True)
if nums[0] > target:
return False
buckets = [0] * k
def backtrack(index):
if index == len(nums):
return all(b == target for b in buckets)
seen = set()
for i in range(k):
if buckets[i] in seen:
continue
if buckets[i] + nums[index] <= target:
seen.add(buckets[i])
buckets[i] += nums[index]
if backtrack(index + 1):
return True
buckets[i] -= nums[index]
return False
return backtrack(0)
print(canPartitionKSubsets_brute([4, 3, 2, 3, 5, 2, 1], 4)) # True
print(canPartitionKSubsets_brute([1, 2, 3, 4], 3)) # False
Complexity
- Time:
O(k^n)— each element can go into k buckets - Space:
O(n + k)— recursion depth plus buckets array
2. Backtracking with Bitmask Memoization
Intuition
Instead of tracking which elements are in each bucket, track which elements have been “used” using a bitmask. A bitmask of n bits where bit i is 1 means nums[i] has been placed. The current bucket’s running sum can be computed from the bitmask. Cache results: if we’ve seen this exact bitmask before and returned False, return False immediately — we don’t need to explore the same set of remaining elements again.
Algorithm
- Compute
target. - Define
backtrack(mask, current_sum):current_sumis the running sum of the current (incomplete) bucket.- If
mask == (1 << n) - 1: all elements placed, returnTrue. - For each
inot inmask:- If
current_sum + nums[i] <= target:- If
current_sum + nums[i] == target: recurse withmask | (1 << i)andcurrent_sum = 0(bucket complete). - Else: recurse with
mask | (1 << i)andcurrent_sum + nums[i].
- If
- If
- Memoize on
mask.
Solution
def canPartitionKSubsets(nums, k):
total = sum(nums)
if total % k != 0:
return False
target = total // k
nums.sort(reverse=True)
if nums[0] > target:
return False
n = len(nums)
memo = {}
def backtrack(mask, current_sum):
if mask == (1 << n) - 1:
return True
if mask in memo:
return memo[mask]
for i in range(n):
if mask & (1 << i): # already used
continue
if current_sum + nums[i] > target:
continue
new_sum = (current_sum + nums[i]) % target # reset to 0 when bucket completes
if backtrack(mask | (1 << i), new_sum):
memo[mask] = True
return True
memo[mask] = False
return False
return backtrack(0, 0)
print(canPartitionKSubsets([4, 3, 2, 3, 5, 2, 1], 4)) # True
print(canPartitionKSubsets([1, 2, 3, 4], 3)) # False
print(canPartitionKSubsets([2, 2, 2, 2, 3, 4, 5], 4)) # False
Complexity
- Time:
O(2^n * n)— at most2^nunique masks, each exploring up tonelements - Space:
O(2^n)— memoization table
Common Pitfalls
Not sorting descending. Large elements fail faster (fewer valid buckets). Sorting descending lets you prune bad branches early in the recursion.
Missing the duplicate-bucket skip. Without seen, you’ll try placing an element into bucket 1 (sum=3) and bucket 2 (sum=3) as separate branches — but they’re identical. Track the set of current bucket sums and skip repeats.
Wrong bitmask memoization. The memoization key is mask alone (which elements remain), not (mask, current_sum). This works because the current bucket’s partial sum equals sum of used elements mod target, which is determined by mask. Using (mask, current_sum) as the key is also valid but caches fewer states.
Forgetting the % target trick. When current_sum + nums[i] == target, the bucket is complete and the new bucket starts at 0. Using (current_sum + nums[i]) % target cleanly handles this: it’s 0 when the bucket completes and current_sum + nums[i] otherwise.
N-Queens
Difficulty: Hard Source: NeetCode
Problem
The n-queens puzzle is the problem of placing
nqueens on ann x nchessboard such that no two queens attack each other.Given an integer
n, return all distinct solutions to the n-queens puzzle. You may return the answer in any order.Each solution contains a distinct board configuration of the n-queens’ placement, where
'Q'and'.'both indicate a queen and an empty space, respectively.Example 1: Input:
n = 4Output:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]Example 2: Input:
n = 1Output:[["Q"]]Constraints:
1 <= n <= 9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Backtracking — placing queens row by row and undoing choices when conflicts arise
- Sets for O(1) conflict detection — tracking occupied columns, diagonals, and anti-diagonals
1. Brute Force
Intuition
Place queens one per row (a queen must be in every row since we need n queens on n rows). For each row, try every column. Before placing, check if any previously placed queen attacks this position: same column, same diagonal (row - col is constant on a diagonal), or same anti-diagonal (row + col is constant on an anti-diagonal). If no conflict, place and recurse on the next row. If all n rows are filled, record the board.
Algorithm
- Track
cols,diag(row-col),anti_diag(row+col) as sets. - Define
backtrack(row, board). - If
row == n: record the board and return. - For each
colin0..n-1:- If
col,row-col, orrow+colis in any conflict set: skip. - Place
'Q', add to conflict sets, recurse withrow+1. - Remove queen, remove from conflict sets.
- If
Solution
def solveNQueens_brute(n):
result = []
cols = set()
diag = set() # row - col is constant along a diagonal
anti_diag = set() # row + col is constant along an anti-diagonal
board = [['.' for _ in range(n)] for _ in range(n)]
def backtrack(row):
if row == n:
result.append(["".join(r) for r in board])
return
for col in range(n):
if col in cols or (row - col) in diag or (row + col) in anti_diag:
continue
board[row][col] = 'Q'
cols.add(col)
diag.add(row - col)
anti_diag.add(row + col)
backtrack(row + 1)
board[row][col] = '.'
cols.remove(col)
diag.remove(row - col)
anti_diag.remove(row + col)
backtrack(0)
return result
print(solveNQueens_brute(4))
# [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
print(solveNQueens_brute(1))
# [["Q"]]
Complexity
- Time:
O(n!)— at most n choices for row 0, n-1 for row 1 (one column blocked), etc. - Space:
O(n)— recursion depth plus sets and board (O(n^2) for the board)
2. Backtracking with Column Array
Intuition
Instead of maintaining a 2D board, store only which column each row’s queen is placed in (queens[row] = col). Build the board strings only when a solution is found. The conflict detection is identical — sets for columns, diagonals, and anti-diagonals — but we avoid building a full board on every recursive call.
Algorithm
- Use
queens = [-1] * nto track the column placement per row. - Use sets for
cols,diag,anti_diag. - On finding a complete placement, construct the board from
queens.
Solution
def solveNQueens(n):
result = []
queens = [-1] * n # queens[row] = col
cols = set()
diag = set()
anti_diag = set()
def build_board():
board = []
for row in range(n):
board.append('.' * queens[row] + 'Q' + '.' * (n - queens[row] - 1))
return board
def backtrack(row):
if row == n:
result.append(build_board())
return
for col in range(n):
if col in cols or (row - col) in diag or (row + col) in anti_diag:
continue
queens[row] = col
cols.add(col)
diag.add(row - col)
anti_diag.add(row + col)
backtrack(row + 1)
queens[row] = -1
cols.remove(col)
diag.remove(row - col)
anti_diag.remove(row + col)
backtrack(0)
return result
print(solveNQueens(4))
# [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
print(solveNQueens(1))
# [["Q"]]
print(len(solveNQueens(8))) # 92 solutions for n=8
Complexity
- Time:
O(n!)— same as brute force; set operations are O(1) each - Space:
O(n)— recursion depth; sets have at most n elements each
Common Pitfalls
Using row - col for diagonals and row + col for anti-diagonals. It’s easy to get these mixed up. A top-left-to-bottom-right diagonal has a constant row - col value. A top-right-to-bottom-left anti-diagonal has a constant row + col value. Draw a small grid and verify.
Rebuilding the board on every call. Building the board on every recursive call is wasteful. Build it only when a complete solution is found (at the base case). This is what build_board() does in the optimized version.
Not removing from sets after backtracking. Sets must be cleaned up after each recursive call. Leaving stale entries means future rows will incorrectly see those columns/diagonals as occupied.
Iterating columns incorrectly. We place exactly one queen per row. The outer loop is over rows (implicit in recursion depth), and the inner loop is over columns. Don’t iterate rows inside the loop — that leads to placing multiple queens in one row.
N-Queens II
Difficulty: Hard Source: NeetCode
Problem
The n-queens puzzle is the problem of placing
nqueens on ann x nchessboard such that no two queens attack each other.Given an integer
n, return the number of distinct solutions to the n-queens puzzle.Example 1: Input:
n = 4Output:2Example 2: Input:
n = 1Output:1Constraints:
1 <= n <= 9
Prerequisites
Before attempting this problem, you should be comfortable with:
- N-Queens (LeetCode 51) — this is the same problem; just count solutions instead of collecting boards
- Backtracking — row-by-row queen placement with conflict detection via sets
1. Brute Force (Count via N-Queens I)
Intuition
Reuse the full N-Queens solution from problem 15 and just count how many solutions it returns. This is correct but builds all the board strings unnecessarily — we only need the count.
Algorithm
- Run
solveNQueens(n)from N-Queens I. - Return
len(result).
Solution
def totalNQueens_via_boards(n):
result = []
cols = set()
diag = set()
anti_diag = set()
def backtrack(row):
if row == n:
result.append(True) # just mark a solution found
return
for col in range(n):
if col in cols or (row - col) in diag or (row + col) in anti_diag:
continue
cols.add(col)
diag.add(row - col)
anti_diag.add(row + col)
backtrack(row + 1)
cols.remove(col)
diag.remove(row - col)
anti_diag.remove(row + col)
backtrack(0)
return len(result)
print(totalNQueens_via_boards(4)) # 2
print(totalNQueens_via_boards(1)) # 1
print(totalNQueens_via_boards(8)) # 92
Complexity
- Time:
O(n!)— explores all placements - Space:
O(n)— sets and recursion depth
2. Backtracking — Count Only
Intuition
Same backtracking as N-Queens I but we skip building the board entirely. Instead of appending a board to a result list, we increment a counter. This saves the O(n^2) cost of constructing and storing each board. The conflict detection logic is identical — sets for columns, diagonals, and anti-diagonals.
Algorithm
- Initialize
count = 0. - Define
backtrack(row):- If
row == n: incrementcountand return. - For each
colin0..n-1:- Skip if conflict.
- Add to sets, recurse with
row + 1, remove from sets.
- If
- Call
backtrack(0)and returncount.
Solution
def totalNQueens(n):
count = 0
cols = set()
diag = set() # row - col
anti_diag = set() # row + col
def backtrack(row):
nonlocal count
if row == n:
count += 1
return
for col in range(n):
if col in cols or (row - col) in diag or (row + col) in anti_diag:
continue
cols.add(col)
diag.add(row - col)
anti_diag.add(row + col)
backtrack(row + 1)
cols.remove(col)
diag.remove(row - col)
anti_diag.remove(row + col)
backtrack(0)
return count
print(totalNQueens(1)) # 1
print(totalNQueens(4)) # 2
print(totalNQueens(5)) # 10
print(totalNQueens(8)) # 92
print(totalNQueens(9)) # 352
Complexity
- Time:
O(n!)— same tree traversal as N-Queens I - Space:
O(n)— recursion depth; sets hold at most n elements
Common Pitfalls
Forgetting nonlocal count. In Python 3, you can’t assign to a variable from an enclosing scope without declaring it nonlocal. Without this, count += 1 creates a local variable and the outer count is never updated. Alternatively, use a list count = [0] and increment count[0], which avoids the nonlocal keyword.
Building the board when you don’t need to. N-Queens II only asks for a count. Skip all board construction — no 2D arrays, no string joining. This makes the solution cleaner and avoids the O(n^2) board-building cost at each solution.
Reusing N-Queens I and calling len() on the result. This works but is wasteful — every board string is built and stored just to be counted. The dedicated count-only version here is both simpler and faster.
Word Break II
Difficulty: Hard Source: NeetCode
Problem
Given a string
sand a dictionary of stringswordDict, add spaces insto construct a sentence where each word is a valid dictionary word. Return all such possible sentences in any order.Note: The same word in the dictionary may be reused multiple times in the segmentation.
Example 1: Input:
s = "catsanddog",wordDict = ["cat","cats","and","sand","dog"]Output:["cats and dog","cat sand dog"]Example 2: Input:
s = "pineapplepenapple",wordDict = ["apple","pen","applepen","pine","pineapple"]Output:["pine apple pen apple","pineapple pen apple","pine applepen apple"]Example 3: Input:
s = "catsandog",wordDict = ["cats","dog","sand","and","cat"]Output:[]Constraints:
1 <= s.length <= 201 <= wordDict.length <= 10001 <= wordDict[i].length <= 10sandwordDict[i]consist of only lowercase English letters.- All the strings of
wordDictare unique.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Word Break I — the decision version (can the string be broken?); this problem finds all ways
- Backtracking — at each position, try all words that match the current prefix
- Memoization — cache results for each starting index to avoid redundant work
1. Brute Force (Backtracking without Memoization)
Intuition
At each starting position start, look at every word in the dictionary. If the substring s[start:start+len(word)] matches the word, add it to the current path and recurse with the new starting position start + len(word). When start reaches the end of s, we’ve built a valid sentence — record it. This explores all possibilities but may redo work for the same starting position many times.
Algorithm
- Convert
wordDictto a set for O(1) lookup. - Define
backtrack(start, current_words). - If
start == len(s): append" ".join(current_words)to results and return. - For each
wordinwordDict:- If
s[start:start+len(word)] == word:- Append
word, recurse withstart + len(word), pop.
- Append
- If
Solution
def wordBreak_brute(s, wordDict):
word_set = set(wordDict)
result = []
def backtrack(start, current_words):
if start == len(s):
result.append(" ".join(current_words))
return
for word in word_set:
end = start + len(word)
if s[start:end] == word:
current_words.append(word)
backtrack(end, current_words)
current_words.pop()
backtrack(0, [])
return result
print(wordBreak_brute("catsanddog", ["cat","cats","and","sand","dog"]))
# ['cat sand dog', 'cats and dog'] (order may vary)
print(wordBreak_brute("catsandog", ["cats","dog","sand","and","cat"]))
# []
Complexity
- Time:
O(n * 2^n)— at each position, we may branch on multiple words; exponential in worst case - Space:
O(n)— recursion depth
2. Backtracking with Memoization
Intuition
The brute force may call backtrack(start, ...) many times for the same start index. The set of valid sentences starting at start depends only on start, not on how we got there. So we cache memo[start] = list of suffixes (word sequences) that can be formed from s[start:]. When we revisit a start, we return the cached result and combine it with the current word without re-exploring.
This is top-down DP over starting positions combined with backtracking to generate all paths.
Algorithm
- Define
backtrack(start)that returns all sentence fragments fors[start:]. - Base case:
start == len(s)→ return[""]. - If
startinmemo: returnmemo[start]. - For each word that matches
s[start:start+len(word)]:- Recurse on
start + len(word)to get suffixes. - Prepend
wordto each suffix (with a space separator).
- Recurse on
- Cache and return results.
Solution
def wordBreak(s, wordDict):
word_set = set(wordDict)
memo = {}
def backtrack(start):
if start in memo:
return memo[start]
if start == len(s):
return [""] # empty string — base case sentinel
sentences = []
for word in word_set:
end = start + len(word)
if s[start:end] == word:
suffixes = backtrack(end)
for suffix in suffixes:
if suffix:
sentences.append(word + " " + suffix)
else:
sentences.append(word)
memo[start] = sentences
return sentences
return backtrack(0)
print(wordBreak("catsanddog", ["cat","cats","and","sand","dog"]))
# ['cat sand dog', 'cats and dog']
print(wordBreak("pineapplepenapple", ["apple","pen","applepen","pine","pineapple"]))
# ['pine apple pen apple', 'pineapple pen apple', 'pine applepen apple']
print(wordBreak("catsandog", ["cats","dog","sand","and","cat"]))
# []
print(wordBreak("aaaa", ["a", "aa", "aaa"]))
# multiple segmentations
Complexity
- Time:
O(n^2 * W)— at most n starting positions, each checking at most W words of total length up to n - Space:
O(n * 2^n)— in the worst case the memo stores exponentially many sentences per position
Common Pitfalls
Returning [] vs. [""] at the base case. The base case start == len(s) should return [""] (a list with one empty string), not []. Returning [] means “no valid sentences from this position” which is wrong — we’ve successfully matched the entire string. The empty string is the sentinel that signals a complete match.
Building sentences wrong when suffix is empty. At the base case, suffix is "". If you do word + " " + suffix you get "word " with a trailing space. Always check if suffix before adding the space separator, or use " ".join([word, suffix]).strip().
Not using a set for wordDict. Iterating over the list to check each word is O(n*W) per call. Converting to a set and checking each word by prefix (s[start:start+len(word)] == word) is fine since we iterate the set — but a Trie would be even faster for long strings with large dictionaries.
Memoizing on (start, tuple(current_words)). The memo key should be start only. The sentences starting from start are always the same regardless of how we arrived at start. Including current_words in the key defeats the purpose of memoization.
Heap / Priority Queue
Think about the emergency room triage system — the most critical patient is always treated first, no matter when they arrived. That’s a priority queue. It doesn’t care about arrival order; it always surfaces the most important item next.
A heap is the data structure that makes a priority queue work in O(log n) time. It looks like a binary tree, but is stored in a plain array — making it both memory-efficient and cache-friendly.
Min-Heap vs Max-Heap
There are two flavours:
- Min-heap: the smallest value is always at the top (root). Every parent is less than or equal to its children.
- Max-heap: the largest value is always at the top. Every parent is greater than or equal to its children.
flowchart TD
subgraph Min-Heap
A1["1 (root)"] --> B1["3"]
A1 --> C1["5"]
B1 --> D1["7"]
B1 --> E1["9"]
C1 --> F1["8"]
end
subgraph Max-Heap
A2["9 (root)"] --> B2["7"]
A2 --> C2["5"]
B2 --> D2["3"]
B2 --> E2["1"]
C2 --> F2["4"]
end
The heap property is the single rule that defines a heap: every parent must maintain its order relationship with its children. The heap does not require the left and right subtrees to be sorted relative to each other — only that each parent beats its own children.
Heaps in Python
Python’s standard library provides a min-heap through the heapq module. There is no built-in max-heap, but you can simulate one by negating values.
import heapq
# --- Min-heap ---
tasks = []
heapq.heappush(tasks, (3, "Write tests"))
heapq.heappush(tasks, (1, "Fix production bug")) # highest priority
heapq.heappush(tasks, (2, "Code review"))
print("Min-heap order (priority, task):")
while tasks:
print(" ", heapq.heappop(tasks))
# --- Max-heap trick: negate the priority ---
scores = []
heapq.heappush(scores, -95)
heapq.heappush(scores, -72)
heapq.heappush(scores, -88)
print("\nMax-heap (highest score first):")
while scores:
print(" ", -heapq.heappop(scores))
What Makes Heaps Special
| Operation | Heap | Sorted Array | Unsorted Array |
|---|---|---|---|
| Find min/max | O(1) | O(1) | O(n) |
| Insert | O(log n) | O(n) | O(1) |
| Remove min/max | O(log n) | O(n) | O(n) |
| Build from n elements | O(n) | O(n log n) | O(1) |
Heaps are the sweet spot when you need to repeatedly find and remove the minimum (or maximum) element efficiently.
What You Will Learn
- Heap Properties — how a heap is structured as a tree and stored as an array, and the index arithmetic that makes it work.
- Push and Pop — how inserting and removing elements maintains the heap property through sift-up and sift-down operations.
- Heapify — how to turn any unsorted array into a valid heap in O(n) time, which is faster than inserting elements one by one.
Heap Properties
A heap looks like a tree but is stored in a plain array — one of the most elegant tricks in computer science. There are no pointers, no node objects, no wasted memory. Just a flat list and a bit of index arithmetic.
The Complete Binary Tree Property
A heap is always a complete binary tree: every level is fully filled except possibly the last, which is filled from left to right. This shape guarantee is what lets us store the tree in an array without gaps.
flowchart TD
A["1\n index 0"] --> B["3\n index 1"]
A --> C["5\n index 2"]
B --> D["7\n index 3"]
B --> E["9\n index 4"]
C --> F["8\n index 5"]
The same data in an array:
index: 0 1 2 3 4 5
value: [1, 3, 5, 7, 9, 8]
Index Arithmetic: The Magic Formula
Given a node at index i, you can reach its relatives with simple arithmetic — no pointers needed:
| Relationship | Formula |
|---|---|
| Parent | (i - 1) // 2 |
| Left child | 2 * i + 1 |
| Right child | 2 * i + 2 |
Let’s verify with the tree above. Node 3 is at index 1:
- Its parent is at
(1 - 1) // 2 = 0→ value1. Correct. - Its left child is at
2*1 + 1 = 3→ value7. Correct. - Its right child is at
2*1 + 2 = 4→ value9. Correct.
def parent(i):
return (i - 1) // 2
def left_child(i):
return 2 * i + 1
def right_child(i):
return 2 * i + 2
heap = [1, 3, 5, 7, 9, 8]
for i, val in enumerate(heap):
p = parent(i) if i > 0 else None
l = left_child(i) if left_child(i) < len(heap) else None
r = right_child(i) if right_child(i) < len(heap) else None
print(
f"index {i} (value={val}): "
f"parent={heap[p] if p is not None else 'none':>4} "
f"left={heap[l] if l is not None else 'none':>4} "
f"right={heap[r] if r is not None else 'none':>4}"
)
The Heap Ordering Property
The shape property alone doesn’t make a heap. There is a second rule about the values:
- Min-heap: every parent is less than or equal to both its children.
- Max-heap: every parent is greater than or equal to both its children.
This is enforced locally at every node — the heap does not require the entire left subtree to be smaller than the right subtree, only that each parent beats its immediate children.
def is_min_heap(arr):
"""Check whether arr satisfies the min-heap property."""
n = len(arr)
for i in range(n):
left = 2 * i + 1
right = 2 * i + 2
if left < n and arr[i] > arr[left]:
return False
if right < n and arr[i] > arr[right]:
return False
return True
valid_heap = [1, 3, 5, 7, 9, 8]
invalid_heap = [1, 3, 5, 2, 9, 8] # index 3 (value 2) is smaller than parent index 1 (value 3)
print("valid_heap is min-heap: ", is_min_heap(valid_heap))
print("invalid_heap is min-heap:", is_min_heap(invalid_heap))
Why This Matters in the Real World
Heaps are not just a textbook curiosity — they underpin some of the most important algorithms and systems:
- OS process scheduling: the kernel maintains a priority queue of runnable processes. The scheduler always picks the highest-priority process next, in O(log n).
- Dijkstra’s shortest path: at each step the algorithm greedily expands the unvisited node with the smallest known distance. A min-heap makes that lookup O(log n) instead of O(n).
- A pathfinding* (used in GPS navigation and game AI): the open set is a min-heap ordered by estimated total cost, ensuring the most promising path is always explored first.
In all three cases the same pattern repeats: repeatedly pull the “best” item, do some work, push new items. That is precisely what a heap is optimized for.
Push and Pop
Every time you add or remove an element, the heap has one job: restore its ordering property as quickly as possible. It does this with two simple operations — sift up (after a push) and sift down (after a pop). Both run in O(log n) because the tree height is at most log₂(n).
Push: Add to the End, Then Sift Up
When you push a new element, you append it at the end of the array (maintaining the complete binary tree shape), then bubble it up until the heap property is restored.
Pushing 2 into the heap [4, 7, 6, 9, 10, 8]:
flowchart TD
subgraph Step1["Step 1: append 2 at end (index 6)"]
A1["4"] --> B1["7"]
A1 --> C1["6"]
B1 --> D1["9"]
B1 --> E1["10"]
C1 --> F1["8"]
C1 --> G1["2 ← new"]
end
subgraph Step2["Step 2: compare with parent (6 at index 2) — swap"]
A2["4"] --> B2["7"]
A2 --> C2["2 ↑"]
B2 --> D2["9"]
B2 --> E2["10"]
C2 --> F2["8"]
C2 --> G2["6"]
end
subgraph Step3["Step 3: compare with parent (4 at index 0) — no swap, done"]
A3["4"] --> B3["7"]
A3 --> C3["2"]
B3 --> D3["9"]
B3 --> E3["10"]
C3 --> F3["8"]
C3 --> G3["6"]
end
Step1 --> Step2 --> Step3
Pop: Swap Root with Last, Then Sift Down
Removing the minimum (the root) directly would leave a hole. Instead, swap the root with the last element, remove the last element (which was the root), then sift the new root down until the heap property is restored.
Popping from [2, 7, 4, 9, 10, 8, 6]:
flowchart TD
subgraph Pop1["Step 1: swap root (2) with last (6), remove 2"]
PA1["6 ← moved here"] --> PB1["7"]
PA1 --> PC1["4"]
PB1 --> PD1["9"]
PB1 --> PE1["10"]
PC1 --> PF1["8"]
end
subgraph Pop2["Step 2: compare 6 with children (7, 4) — smallest child is 4, swap"]
PA2["4 ↑"] --> PB2["7"]
PA2 --> PC2["6 ↓"]
PB2 --> PD2["9"]
PB2 --> PE2["10"]
PC2 --> PF2["8"]
end
subgraph Pop3["Step 3: compare 6 with children (8) — 6 < 8, done"]
PA3["4"] --> PB3["7"]
PA3 --> PC3["6"]
PB3 --> PD3["9"]
PB3 --> PE3["10"]
PC3 --> PF3["8"]
end
Pop1 --> Pop2 --> Pop3
Implementing From Scratch
class MinHeap:
def __init__(self):
self.data = []
def _parent(self, i): return (i - 1) // 2
def _left(self, i): return 2 * i + 1
def _right(self, i): return 2 * i + 2
def push(self, val):
self.data.append(val)
self._sift_up(len(self.data) - 1)
def pop(self):
if len(self.data) == 0:
raise IndexError("pop from empty heap")
# Swap root with last element
self.data[0], self.data[-1] = self.data[-1], self.data[0]
minimum = self.data.pop() # remove the old root from the end
self._sift_down(0)
return minimum
def peek(self):
return self.data[0]
def _sift_up(self, i):
while i > 0:
p = self._parent(i)
if self.data[p] > self.data[i]:
self.data[p], self.data[i] = self.data[i], self.data[p]
i = p
else:
break # heap property satisfied
def _sift_down(self, i):
n = len(self.data)
while True:
smallest = i
l, r = self._left(i), self._right(i)
if l < n and self.data[l] < self.data[smallest]:
smallest = l
if r < n and self.data[r] < self.data[smallest]:
smallest = r
if smallest == i:
break # heap property satisfied
self.data[i], self.data[smallest] = self.data[smallest], self.data[i]
i = smallest
# --- Demo ---
h = MinHeap()
for val in [7, 2, 9, 4, 1, 6]:
h.push(val)
print("Heap array after all pushes:", h.data)
print("Peek (minimum):", h.peek())
print("Popping in sorted order:", end=" ")
while h.data:
print(h.pop(), end=" ")
print()
Using Python’s heapq
Python’s heapq module provides the same min-heap operations as built-in functions:
import heapq
heap = []
for val in [7, 2, 9, 4, 1, 6]:
heapq.heappush(heap, val)
print("Heap array:", heap)
print("Peek:", heap[0]) # the root is always heap[0]
print("Popping in sorted order:", end=" ")
while heap:
print(heapq.heappop(heap), end=" ")
print()
Time Complexity
| Operation | Time | Why |
|---|---|---|
| push | O(log n) | Sift up travels at most tree height |
| pop | O(log n) | Sift down travels at most tree height |
| peek | O(1) | Root is always index 0 |
The tree height of a complete binary tree with n nodes is floor(log₂ n), so both push and pop make at most log₂ n comparisons.
Real-World Application: Task Scheduler
import heapq
class TaskScheduler:
def __init__(self):
self._queue = []
self._counter = 0 # tie-break by insertion order
def add_task(self, priority, description):
# lower priority number = more urgent
heapq.heappush(self._queue, (priority, self._counter, description))
self._counter += 1
print(f" Queued: [{priority}] {description}")
def run_next(self):
if not self._queue:
print(" No tasks pending.")
return
priority, _, description = heapq.heappop(self._queue)
print(f" Running: [{priority}] {description}")
scheduler = TaskScheduler()
scheduler.add_task(3, "Send weekly report")
scheduler.add_task(1, "Restart crashed service") # most urgent
scheduler.add_task(2, "Deploy new feature")
scheduler.add_task(1, "Respond to security alert") # also priority 1
print()
print("Processing tasks in priority order:")
while scheduler._queue:
scheduler.run_next()
Heapify
Why build a heap one element at a time in O(n log n) when you can do it in O(n)?
If you already have all the data up front, there is a smarter approach called heapify. Instead of pushing each element individually and sifting it up, you start from the middle of the array and sift down each node. The result is a valid heap in linear time.
Why Start From the Middle?
Leaf nodes (roughly the second half of the array) are already valid one-element heaps — there is nothing to do for them. The first internal node that can actually have children is at index (n // 2) - 1. That is where the work begins.
For an array of length 6, index (6 // 2) - 1 = 2 is the last internal node. We sift down from index 2, then 1, then 0.
Step-by-Step: Heapify [3, 1, 6, 5, 2, 4]
flowchart TD
subgraph Start["Starting array: 3, 1, 6, 5, 2, 4"]
N0["3\n idx 0"] --> N1["1\n idx 1"]
N0 --> N2["6\n idx 2"]
N1 --> N3["5\n idx 3"]
N1 --> N4["2\n idx 4"]
N2 --> N5["4\n idx 5"]
end
subgraph Step1["Step 1: sift down idx 2 (value 6) — child 4 is smaller, swap"]
S1N0["3"] --> S1N1["1"]
S1N0 --> S1N2["4 ↑"]
S1N1 --> S1N3["5"]
S1N1 --> S1N4["2"]
S1N2 --> S1N5["6 ↓"]
end
subgraph Step2["Step 2: sift down idx 1 (value 1) — children are 5 and 2; 1 < both, no swap"]
S2N0["3"] --> S2N1["1"]
S2N0 --> S2N2["4"]
S2N1 --> S2N3["5"]
S2N1 --> S2N4["2"]
S2N2 --> S2N5["6"]
end
subgraph Step3["Step 3: sift down idx 0 (value 3) — children are 1 and 4; 1 is smallest, swap"]
S3N0["1 ↑"] --> S3N1["3 ↓"]
S3N0 --> S3N2["4"]
S3N1 --> S3N3["5"]
S3N1 --> S3N4["2"]
S3N2 --> S3N5["6"]
end
subgraph Step4["Step 4: continue sifting 3 down — children 5 and 2; swap with 2"]
S4N0["1"] --> S4N1["2 ↑"]
S4N0 --> S4N2["4"]
S4N1 --> S4N3["5"]
S4N1 --> S4N4["3 ↓"]
S4N2 --> S4N5["6"]
end
Start --> Step1 --> Step2 --> Step3 --> Step4
Final heap array: [1, 2, 4, 5, 3, 6]
Implementing Heapify From Scratch
def sift_down(arr, i, n):
"""Sift the element at index i down until the heap property holds."""
while True:
smallest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and arr[left] < arr[smallest]:
smallest = left
if right < n and arr[right] < arr[smallest]:
smallest = right
if smallest == i:
break
arr[i], arr[smallest] = arr[smallest], arr[i]
i = smallest
def heapify(arr):
"""Turn arr into a valid min-heap in-place in O(n) time."""
n = len(arr)
# Start from the last internal node and work backwards to the root
for i in range(n // 2 - 1, -1, -1):
sift_down(arr, i, n)
# --- Demo ---
data = [3, 1, 6, 5, 2, 4]
print("Before heapify:", data)
heapify(data)
print("After heapify: ", data)
# Verify: extract elements in sorted order
import heapq
import copy
heap_copy = copy.copy(data)
sorted_vals = []
while heap_copy:
# manually pop using our sift_down
heap_copy[0], heap_copy[-1] = heap_copy[-1], heap_copy[0]
sorted_vals.append(heap_copy.pop())
sift_down(heap_copy, 0, len(heap_copy))
print("Elements in min order:", sorted_vals)
Using Python’s heapq.heapify()
heapq.heapify() does the same thing in-place, transforming any list into a min-heap:
import heapq
data = [3, 1, 6, 5, 2, 4]
print("Before:", data)
heapq.heapify(data) # O(n), in-place
print("After: ", data)
# heapq.heapify guarantees data[0] is the minimum
print("Minimum:", data[0])
# Pull all elements out in sorted order
print("Sorted:", [heapq.heappop(data) for _ in range(len(data) + 0)])
Why O(n) and Not O(n log n)?
It feels like it should be O(n log n) — we call sift_down on n/2 nodes, and sift_down is O(log n). But not every sift_down call does the same amount of work.
- Nodes near the bottom of the tree have very little room to sift down (height 0 or 1).
- Only the root can sift down the full tree height.
When you add up the actual work across all levels, the total converges to O(n). The formal proof uses the fact that there are at most n/2^(h+1) nodes at height h, each doing O(h) work — and the sum ∑ h / 2^h converges to a constant.
| Method | Time | When to use |
|---|---|---|
| Push n times | O(n log n) | Elements arrive one at a time |
| heapify once | O(n) | All data available at the start |
Real-World Applications
Heap Sort: heapify the array in O(n), then repeatedly extract the minimum in O(log n). Total: O(n log n) with O(1) extra space — a fully in-place sort.
import heapq
def heap_sort(arr):
heapq.heapify(arr)
return [heapq.heappop(arr) for _ in range(len(arr))]
numbers = [42, 7, 19, 3, 55, 1, 28]
print("Unsorted:", numbers)
print("Sorted: ", heap_sort(numbers))
One-time priority queue setup: when a system starts up with a known backlog of jobs (e.g. a build system loading thousands of pending tasks from a database), heapify transforms the entire backlog into a priority queue in linear time, ready for O(log n) push/pop from that point on.
Two Heaps
Find the median of a stream of numbers in O(log n) per insertion. As numbers arrive one by one, you must always report the current median — fast. Banks use exactly this for real-time fraud detection thresholds: if a transaction amount is above the running median by a large factor, it is flagged for review.
Why the naive approach breaks
The median is the middle value when data is sorted. With a static array you sort once: O(n log n), then read the middle. But what if numbers keep arriving? Re-sorting after every insertion is O(n log n) per step — far too slow for a live data stream.
A sorted array insert is O(n) due to shifting. A balanced BST insert is O(log n) but finding the median requires O(log n) rank queries, and the code is complex.
The two-heaps approach achieves O(log n) insert and O(1) median — with surprisingly little code.
The insight: split the data in half
Imagine the data sorted in a line. Draw a dividing line through the middle. Everything to the left of the line is the “lower half”; everything to the right is the “upper half.”
To get the median instantly you only need to peek at the tops of each half:
- Lower half → stored in a max-heap (its top = the largest element in the lower half)
- Upper half → stored in a min-heap (its top = the smallest element in the upper half)
If both halves have the same size, the median is the average of the two tops. If one is larger by one, the median is the top of the larger heap.
flowchart LR
subgraph MaxHeap["Max-Heap (lower half)"]
direction TB
M1["5 ← top (median candidate)"]
M2["3"]
M3["1"]
end
subgraph MinHeap["Min-Heap (upper half)"]
direction TB
N1["7 ← top (median candidate)"]
N2["8"]
N3["9"]
end
MaxHeap -- "median = (5+7)/2 = 6.0" --> MinHeap
The two heaps always satisfy:
- Every element in the max-heap is ≤ every element in the min-heap.
- The sizes differ by at most 1.
Building up the structure: step by step
Watch the two heaps evolve as [5, 15, 1, 3, 8, 7, 9, 2] arrives one by one:
flowchart LR
A["Insert 5\nmax-heap:[5] min-heap:[]\nMedian: 5"] -->
B["Insert 15\nmax-heap:[5] min-heap:[15]\nMedian: (5+15)/2=10"] -->
C["Insert 1\nmax-heap:[5,1] min-heap:[15]\nMedian: 5"] -->
D["Insert 3\nmax-heap:[5,3,1] min-heap:[15]\nRebalance!\nmax-heap:[3,1] min-heap:[5,15]\nMedian: (3+5)/2=4"]
flowchart LR
D["max:[3,1] min:[5,15]"] -->
E["Insert 8\nmax:[3,1] min:[5,8,15]\nRebalance!\nmax:[5,3,1] min:[8,15]\nMedian: 5"] -->
F["Insert 7\nmax:[7,5,3,1] min:[8,15]\nRebalance!\nmax:[5,3,1] min:[7,8,15]\nMedian: (5+7)/2=6"] -->
G["Insert 9\nmax:[5,3,1] min:[7,8,9,15]\nRebalance!\nmax:[7,5,3,1] min:[8,9,15]\nMedian: 7"] -->
H["Insert 2\nmax:[7,5,3,2,1] min:[8,9,15]\nRebalance!\nmax:[5,3,2,1] min:[7,8,9,15]\nMedian: (5+7)/2=6"]
The rebalancing rule
After every insertion:
- Decide which heap to insert into: if the new number ≤ current median (or max-heap is empty), push to max-heap; otherwise push to min-heap.
- If
len(max_heap) > len(min_heap) + 1: move max-heap’s top to min-heap. - If
len(min_heap) > len(max_heap): move min-heap’s top to max-heap.
This keeps the size invariant. At most one element moves per insertion — O(log n) total.
Implementation
Python’s heapq module only provides a min-heap. To simulate a max-heap, negate the values before pushing and negate again when popping.
import heapq
class MedianFinder:
def __init__(self):
# max-heap for the lower half (store negated values)
self.lower = []
# min-heap for the upper half
self.upper = []
def add_num(self, num):
# Step 1: route to the correct heap
if not self.lower or num <= -self.lower[0]:
heapq.heappush(self.lower, -num)
else:
heapq.heappush(self.upper, num)
# Step 2: rebalance so sizes differ by at most 1
if len(self.lower) > len(self.upper) + 1:
heapq.heappush(self.upper, -heapq.heappop(self.lower))
elif len(self.upper) > len(self.lower):
heapq.heappush(self.lower, -heapq.heappop(self.upper))
def find_median(self):
if len(self.lower) == len(self.upper):
return (-self.lower[0] + self.upper[0]) / 2.0
else:
return float(-self.lower[0]) # lower always has the extra element
mf = MedianFinder()
stream = [5, 15, 1, 3, 8, 7, 9, 2]
print(f"{'After inserting':<20} {'Lower half (max-heap)':<28} {'Upper half (min-heap)':<28} {'Median'}")
print("-" * 85)
for num in stream:
mf.add_num(num)
lower_view = sorted([-x for x in mf.lower], reverse=True)
upper_view = sorted(mf.upper)
print(f"{str(num):<20} {str(lower_view):<28} {str(upper_view):<28} {mf.find_median()}")
Complexity
| Operation | Time | Space |
|---|---|---|
add_num | O(log n) | — |
find_median | O(1) | — |
| Total space | — | O(n) |
Compared to alternatives:
| Approach | Insert | Median |
|---|---|---|
| Sorted list | O(n) | O(1) |
| Re-sort each time | O(n log n) | O(1) |
| Two heaps | O(log n) | O(1) |
| Order-statistics tree | O(log n) | O(log n) |
Extension: sliding window median
A harder variant: maintain the median of the last k elements. As new elements arrive, old ones expire. The challenge is that heaps do not support arbitrary removal efficiently.
The trick is lazy deletion: keep a dictionary of “elements to ignore.” When the expired element surfaces at the top of a heap, discard it then.
import heapq
from collections import defaultdict
class SlidingWindowMedian:
"""
Maintains the median of the last k elements in a stream.
Uses lazy deletion to handle expiring elements.
"""
def __init__(self, k):
self.k = k
self.lower = [] # max-heap (negated)
self.upper = [] # min-heap
self.invalid = defaultdict(int) # element -> count pending removal
self.lower_size = 0 # effective size (excluding lazy-deleted)
self.upper_size = 0
def _prune(self, heap):
"""Remove invalidated elements from the top of a heap."""
while heap and self.invalid[-heap[0] if heap is self.lower else heap[0]]:
top = -heapq.heappop(heap) if heap is self.lower else heapq.heappop(heap)
self.invalid[top] -= 1
def _balance(self):
while self.lower_size > self.upper_size + 1:
self._prune(self.lower)
val = -heapq.heappop(self.lower)
self.lower_size -= 1
heapq.heappush(self.upper, val)
self.upper_size += 1
self._prune(self.upper)
while self.upper_size > self.lower_size:
self._prune(self.upper)
val = heapq.heappop(self.upper)
self.upper_size -= 1
heapq.heappush(self.lower, -val)
self.lower_size += 1
self._prune(self.lower)
def insert(self, num):
self._prune(self.lower)
if not self.lower or num <= -self.lower[0]:
heapq.heappush(self.lower, -num)
self.lower_size += 1
else:
heapq.heappush(self.upper, num)
self.upper_size += 1
self._balance()
def remove(self, num):
self.invalid[num] += 1
self._prune(self.lower)
if self.lower and num <= -self.lower[0]:
self.lower_size -= 1
else:
self.upper_size -= 1
self._balance()
def get_median(self):
self._prune(self.lower)
self._prune(self.upper)
if self.lower_size == self.upper_size:
return (-self.lower[0] + self.upper[0]) / 2.0
return float(-self.lower[0])
nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3
swm = SlidingWindowMedian(k)
medians = []
for i, num in enumerate(nums):
swm.insert(num)
if i >= k - 1:
medians.append(swm.get_median())
swm.remove(nums[i - k + 1])
print("Array: ", nums)
print(f"k = {k}")
print("Medians: ", medians)
# Expected: [1.0, -1.0, -1.0, 3.0, 5.0, 6.0]
Real-world uses
Real-time fraud detection — a bank streams transactions and maintains a running median transaction amount. A new transaction at 50× the median triggers an alert. The threshold must update in real time as new transactions arrive.
Median response time monitoring — infrastructure dashboards (Datadog, Prometheus) display the P50 (median) and P95 latency for API endpoints. Requests arrive continuously; recomputing from scratch is not feasible.
Financial data streams — algorithmic trading systems track the median bid-ask spread over a rolling window. The two-heaps structure handles the constant stream of tick data efficiently.
Sensor data processing — IoT pipelines use running medians (rather than means) as a noise-resistant summary statistic. The median is unaffected by a single faulty sensor reading; the mean is not.
A/B testing running statistics — experimentation platforms compute the median conversion value for each variant as events stream in, updating significance tests without storing the full event history.
Heap / Priority Queue Problems
Practice problems for heap ordering, streaming values, scheduling, and greedy selection.
Kth Largest Element In a Stream
Difficulty: Easy Source: NeetCode
Problem
Design a class to find the
kth largest element in a stream. Note that it is thekth largest element in sorted order, not thekth distinct element.Implement the
KthLargestclass:
KthLargest(int k, int[] nums)— initializes the object with the integerkand the stream of integersnums.int add(int val)— appends the integervalto the stream and returns the element representing thekth largest element in the stream.Example 1: Input:
k = 3,nums = [4, 5, 8, 2], thenadd(3),add(5),add(10),add(9),add(4)Output:[4, 5, 5, 8, 8]Constraints:
1 <= k <= 10^40 <= nums.length <= 10^4-10^4 <= nums[i] <= 10^4-10^4 <= val <= 10^4- At most
10^4calls will be made toadd- It is guaranteed that there will be at least
kelements in the array whenaddis called
Prerequisites
Before attempting this problem, you should be comfortable with:
- Heaps / Priority Queues — understanding min-heap and max-heap structure and operations
- Python’s
heapqmodule —heappush,heappop, and the fact that Python only provides a min-heap
1. Brute Force
Intuition
Every time add is called, insert the new value into a sorted list, then return the element at index -k (the kth from the end). Sorting on every call is simple but wasteful — we redo work we already did.
Algorithm
- In
__init__, storekand concatenatevalinto a sorted list. - In
add(val), appendval, sort the list, then returnsorted_list[-k].
Solution
import bisect
class KthLargest:
def __init__(self, k, nums):
self.k = k
self.stream = sorted(nums)
def add(self, val):
bisect.insort(self.stream, val)
return self.stream[-self.k]
# Test 1
obj = KthLargest(3, [4, 5, 8, 2])
print(obj.add(3)) # 4
print(obj.add(5)) # 5
print(obj.add(10)) # 5
print(obj.add(9)) # 8
print(obj.add(4)) # 8
# Test 2
obj2 = KthLargest(1, [])
print(obj2.add(-3)) # -3
print(obj2.add(-2)) # -2
Complexity
- Time:
O(n log n)init,O(log n)per add (with bisect insert) - Space:
O(n)
2. Min-Heap of Size K
Intuition
We only care about the top k largest elements — everything smaller is irrelevant. A min-heap of size exactly k keeps exactly those elements, with the smallest of the top-k sitting at the heap root. That root is our answer: it’s the kth largest. Whenever the heap exceeds size k, pop the minimum to evict the element that just fell out of the top-k.
Algorithm
- In
__init__, heapify the input and pop elements until the heap has exactlykitems. - In
add(val), pushvalonto the heap. If the heap size exceedsk, pop the minimum. Returnheap[0](the kth largest).
Solution
import heapq
class KthLargest:
def __init__(self, k, nums):
self.k = k
self.heap = nums[:]
heapq.heapify(self.heap)
# Trim down to k elements
while len(self.heap) > k:
heapq.heappop(self.heap)
def add(self, val):
heapq.heappush(self.heap, val)
if len(self.heap) > self.k:
heapq.heappop(self.heap)
return self.heap[0] # root = kth largest
# Test 1
obj = KthLargest(3, [4, 5, 8, 2])
print(obj.add(3)) # 4
print(obj.add(5)) # 5
print(obj.add(10)) # 5
print(obj.add(9)) # 8
print(obj.add(4)) # 8
# Test 2 — stream starts empty
obj2 = KthLargest(1, [])
print(obj2.add(5)) # 5
print(obj2.add(3)) # 5
print(obj2.add(7)) # 7
# Test 3 — k equals length of initial nums
obj3 = KthLargest(2, [3, 1, 2])
print(obj3.add(0)) # 1
Complexity
- Time:
O(n log n)init,O(log k)per add - Space:
O(k)
Common Pitfalls
Forgetting to trim the heap in __init__. If you skip the initial trim, the heap can hold more than k elements and heap[0] will not be the kth largest — it will be the overall minimum.
Using a max-heap unnecessarily. A max-heap of size n would let you find the kth largest, but it stores all elements and costs O(log n) per operation instead of O(log k). The min-heap of size k is both simpler and faster.
Off-by-one on size check. After pushing, the heap can be size k + 1 at most. The pop should happen when len(heap) > k, not >= k.
Last Stone Weight
Difficulty: Easy Source: NeetCode
Problem
You are given an array of integers
stoneswherestones[i]is the weight of theith stone.We are playing a game with the stones. On each turn, we choose the two heaviest stones and smash them together. Suppose the two heaviest stones have weights
xandywherex <= y. The result of this smash is:
- If
x == y, both stones are destroyed.- If
x != y, the stone of weightxis destroyed and the stone of weightyhas new weighty - x.Return the weight of the last remaining stone. If there are no stones left, return
0.Example 1: Input:
stones = [2, 7, 4, 1, 8, 1]Output:1Example 2: Input:
stones = [1]Output:1Constraints:
1 <= stones.length <= 301 <= stones[i] <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Heaps / Priority Queues — extracting the maximum element efficiently
- Python’s
heapqmodule — Python only gives you a min-heap; negate values to simulate a max-heap
1. Brute Force
Intuition
Sort the stones on every iteration, grab the two largest, smash them, put the remainder back, and repeat. Sorting every round guarantees we always pick the two heaviest, but it repeats work we’ve already done.
Algorithm
- While
len(stones) > 1: a. Sortstonesin descending order. b. Lety = stones[0],x = stones[1]. Remove both. c. Ify != x, appendy - xback tostones. - Return
stones[0]if the list is non-empty, else0.
Solution
def lastStoneWeight(stones):
stones = stones[:]
while len(stones) > 1:
stones.sort(reverse=True)
y, x = stones[0], stones[1]
stones = stones[2:]
if y != x:
stones.append(y - x)
return stones[0] if stones else 0
print(lastStoneWeight([2, 7, 4, 1, 8, 1])) # 1
print(lastStoneWeight([1])) # 1
print(lastStoneWeight([2, 2])) # 0
Complexity
- Time:
O(n² log n)— up to n rounds, each with an O(n log n) sort - Space:
O(1)extra (ignoring the copy)
2. Max-Heap
Intuition
We only ever need the two largest elements. A max-heap keeps them at the top and lets us extract them in O(log n) without re-sorting everything. Python’s heapq is a min-heap, so we store negated values — the most negative number is the largest stone.
Algorithm
- Negate all stone weights and build a heap with
heapq.heapify. - While more than one stone remains:
a. Pop the two largest (most negative values), negate back to get
y >= x. b. Ify != x, push-(y - x)back onto the heap. - Return
-heap[0]if the heap is non-empty, else0.
Solution
import heapq
def lastStoneWeight(stones):
# Negate to turn min-heap into max-heap
heap = [-s for s in stones]
heapq.heapify(heap)
while len(heap) > 1:
y = -heapq.heappop(heap) # heaviest
x = -heapq.heappop(heap) # second heaviest
if y != x:
heapq.heappush(heap, -(y - x))
return -heap[0] if heap else 0
print(lastStoneWeight([2, 7, 4, 1, 8, 1])) # 1
print(lastStoneWeight([1])) # 1
print(lastStoneWeight([2, 2])) # 0
Complexity
- Time:
O(n log n)— at most n-1 smash rounds, each O(log n) - Space:
O(n)
Common Pitfalls
Forgetting to negate for max-heap. heapq always gives you the minimum. If you push raw weights, you’ll keep smashing the two lightest stones instead of the two heaviest.
Returning heap[0] instead of -heap[0]. Values in the heap are negated. Always negate back before returning.
Not handling the empty heap case. If the two heaviest stones cancel out on the last round, the heap will be empty. Return 0 in that case, not heap[0] (which would raise an IndexError).
K Closest Points to Origin
Difficulty: Medium Source: NeetCode
Problem
Given an array of
pointswherepoints[i] = [xi, yi]represents a point on the X-Y plane, and an integerk, return thekclosest points to the origin(0, 0).The distance between two points on the X-Y plane is the Euclidean distance:
sqrt((x1 - x2)² + (y1 - y2)²).You may return the answer in any order. The answer is guaranteed to be unique (except for the order of the answer).
Example 1: Input:
points = [[1, 3], [-2, 2]],k = 1Output:[[-2, 2]]Example 2: Input:
points = [[3, 3], [5, -1], [-2, 4]],k = 2Output:[[3, 3], [-2, 4]]Constraints:
1 <= k <= points.length <= 10^4-10^4 <= xi, yi <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Euclidean distance — you can skip the
sqrtwhen comparing distances sincesqrtis monotonic - Heaps / Priority Queues — maintaining a bounded collection of k elements efficiently
- Python’s
heapqmodule —heappush,heappop, andnsmallest
1. Brute Force — Sort by Distance
Intuition
Compute the squared distance for every point, sort by that distance, and slice off the first k results. Simple and correct — the only downside is that we sort all n points even though we only need k of them.
Algorithm
- For each point, compute
x² + y²(no need forsqrtsince we’re just comparing). - Sort the points by this distance.
- Return the first
kpoints.
Solution
def kClosest(points, k):
points.sort(key=lambda p: p[0]**2 + p[1]**2)
return points[:k]
print(kClosest([[1, 3], [-2, 2]], 1)) # [[-2, 2]]
print(kClosest([[3, 3], [5, -1], [-2, 4]], 2)) # [[3, 3], [-2, 4]]
print(kClosest([[0, 1], [1, 0]], 2)) # [[0, 1], [1, 0]]
Complexity
- Time:
O(n log n) - Space:
O(1)extra (sort is in-place)
2. Max-Heap of Size K
Intuition
We want to keep exactly k closest points. Use a max-heap (by distance) of size k. For every new point, push it onto the heap. If the heap grows beyond k, pop the farthest point — it’s no longer in the top-k closest. When all points are processed, everything left in the heap is a winner.
Since Python’s heapq is a min-heap, store (-distance, x, y) so the largest distance sits at the root and gets popped first.
Algorithm
- Initialize an empty heap.
- For each point
[x, y]: a. Push(-dist, x, y)onto the heap wheredist = x² + y². b. Iflen(heap) > k, pop the root (farthest point). - Return
[[x, y] for _, x, y in heap].
Solution
import heapq
def kClosest(points, k):
heap = [] # max-heap via negation
for x, y in points:
dist = x * x + y * y
heapq.heappush(heap, (-dist, x, y))
if len(heap) > k:
heapq.heappop(heap) # remove farthest
return [[x, y] for _, x, y in heap]
print(kClosest([[1, 3], [-2, 2]], 1)) # [[-2, 2]]
print(kClosest([[3, 3], [5, -1], [-2, 4]], 2)) # [[3, 3], [-2, 4]]
print(kClosest([[0, 1], [1, 0]], 2)) # [[0, 1], [1, 0]]
Complexity
- Time:
O(n log k)— each push/pop on a heap of size k costs O(log k) - Space:
O(k)
3. Bonus — heapq.nsmallest
Intuition
Python’s standard library has heapq.nsmallest(k, iterable, key) which does exactly this under the hood. Worth knowing for interviews and quick scripting.
Solution
import heapq
def kClosest(points, k):
return heapq.nsmallest(k, points, key=lambda p: p[0]**2 + p[1]**2)
print(kClosest([[1, 3], [-2, 2]], 1)) # [[-2, 2]]
print(kClosest([[3, 3], [5, -1], [-2, 4]], 2)) # [[3, 3], [-2, 4]]
Complexity
- Time:
O(n log k)internally - Space:
O(k)
Common Pitfalls
Taking the square root unnecessarily. Since sqrt is a monotonically increasing function, sqrt(a) < sqrt(b) iff a < b. Skip the sqrt — it saves computation and avoids floating-point precision issues.
Confusing min-heap and max-heap semantics. We want to evict the farthest point when the heap overflows, so we need the farthest at the top. That means a max-heap (negate distances) even though we ultimately want the closest.
Returning distances instead of points. The problem asks for the points themselves, not their distances. Make sure the heap stores the coordinates, not just the distance value.
Kth Largest Element In An Array
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
numsand an integerk, return thekth largest element in the array.Note that it is the
kth largest element in sorted order, not thekth distinct element.Can you solve it without sorting?
Example 1: Input:
nums = [3, 2, 1, 5, 6, 4],k = 2Output:5Example 2: Input:
nums = [3, 2, 3, 1, 2, 4, 5, 5, 6],k = 4Output:4Constraints:
1 <= k <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Heaps / Priority Queues — min-heap of bounded size
- QuickSelect — partition-based selection algorithm (same idea as quicksort but only recurse on one side)
- Python’s
heapqmodule —heappush,heappop
1. Brute Force — Sort
Intuition
Sort the array in descending order and return the element at index k - 1. Dead simple and fast enough for most practical cases — just not O(n).
Algorithm
- Sort
numsin descending order. - Return
nums[k - 1].
Solution
def findKthLargest(nums, k):
nums.sort(reverse=True)
return nums[k - 1]
print(findKthLargest([3, 2, 1, 5, 6, 4], 2)) # 5
print(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)) # 4
print(findKthLargest([1], 1)) # 1
Complexity
- Time:
O(n log n) - Space:
O(1)(in-place sort)
2. Min-Heap of Size K
Intuition
Keep a min-heap of exactly k elements. As we stream through nums, if the heap has fewer than k elements we push freely. Once it’s full, only push the new element if it’s larger than the heap’s minimum — and in that case pop the minimum first to keep the heap at size k. At the end, heap[0] is the kth largest.
Algorithm
- Initialize an empty heap.
- For each
numinnums: a. Pushnum. b. Iflen(heap) > k, pop the minimum. - Return
heap[0].
Solution
import heapq
def findKthLargest(nums, k):
heap = []
for num in nums:
heapq.heappush(heap, num)
if len(heap) > k:
heapq.heappop(heap)
return heap[0]
print(findKthLargest([3, 2, 1, 5, 6, 4], 2)) # 5
print(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)) # 4
print(findKthLargest([1], 1)) # 1
Complexity
- Time:
O(n log k) - Space:
O(k)
3. QuickSelect — O(n) Average
Intuition
QuickSelect is like quicksort, but we only recurse into the partition that contains our target index. We pick a pivot, partition the array so all elements greater than the pivot come first, then check: if the pivot lands at index k - 1 we’re done; otherwise recurse left or right. On average this touches n + n/2 + n/4 + ... ≈ 2n elements.
Algorithm
- Pick a pivot (we’ll use the last element for simplicity).
- Partition: move all elements greater than the pivot to the left side. Track the boundary index
p. - If
p == k - 1, returnnums[p]. - If
p < k - 1, recurse on the right partition with the samek. - If
p > k - 1, recurse on the left partition with the samek.
Solution
import random
def findKthLargest(nums, k):
def quickselect(left, right, k_idx):
# Randomize pivot to avoid worst-case O(n²)
pivot_idx = random.randint(left, right)
nums[pivot_idx], nums[right] = nums[right], nums[pivot_idx]
pivot = nums[right]
# Partition: elements > pivot go to the left
p = left
for i in range(left, right):
if nums[i] > pivot:
nums[p], nums[i] = nums[i], nums[p]
p += 1
nums[p], nums[right] = nums[right], nums[p]
if p == k_idx:
return nums[p]
elif p < k_idx:
return quickselect(p + 1, right, k_idx)
else:
return quickselect(left, p - 1, k_idx)
return quickselect(0, len(nums) - 1, k - 1)
print(findKthLargest([3, 2, 1, 5, 6, 4], 2)) # 5
print(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)) # 4
print(findKthLargest([1], 1)) # 1
Complexity
- Time:
O(n)average,O(n²)worst case (mitigated by random pivot) - Space:
O(n)worst case recursion stack (use iterative version to getO(1))
Common Pitfalls
Confusing kth largest with kth smallest. The kth largest in a descending sort is at index k - 1, or equivalently the kth smallest in an ascending sort is at index n - k. It’s easy to mix these up — be explicit about your sort direction.
QuickSelect worst case without randomization. A sorted input kills naive QuickSelect when always picking the last element as pivot. Always shuffle or pick a random pivot.
Heap of size k returns heap[0] (the minimum of the top-k). That minimum is the kth largest — not the overall minimum. Students sometimes confuse this and try max(heap) instead.
Task Scheduler
Difficulty: Medium Source: NeetCode
Problem
You are given an array of CPU tasks, each labeled with a letter from A to Z, and a number
n. Each CPU interval can be idle or allow the completion of one task. Tasks can be completed in any order, but there’s a constraint: identical tasks must be separated by at leastnintervals due to cooling time.Return the minimum number of CPU intervals required to complete all the tasks.
Example 1: Input:
tasks = ["A","A","A","B","B","B"],n = 2Output:8Explanation: A possible sequence is:A -> B -> idle -> A -> B -> idle -> A -> BExample 2: Input:
tasks = ["A","A","A","B","B","B"],n = 0Output:6Constraints:
1 <= tasks.length <= 10^4tasks[i]is an uppercase English letter0 <= n <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — always scheduling the most frequent available task
- Max-heap — extracting the task with the highest remaining count
- Cooldown queue — tracking when a task becomes available again
1. Brute Force — Math Formula
Intuition
Think about the most frequent task (say task A appears max_count times). It creates max_count - 1 “frames” of size n + 1, plus a final slot. Any other tasks slot into these frames. If there are more tasks than frames can hold, we don’t need any idle time — the answer is just len(tasks). Otherwise the formula gives us the minimum intervals.
Algorithm
- Count the frequency of each task.
- Find
max_count= highest frequency. - Count how many tasks share that max frequency (
num_max). - Return
max(len(tasks), (max_count - 1) * (n + 1) + num_max).
Solution
from collections import Counter
def leastInterval(tasks, n):
counts = Counter(tasks)
max_count = max(counts.values())
num_max = sum(1 for c in counts.values() if c == max_count)
return max(len(tasks), (max_count - 1) * (n + 1) + num_max)
print(leastInterval(["A","A","A","B","B","B"], 2)) # 8
print(leastInterval(["A","A","A","B","B","B"], 0)) # 6
print(leastInterval(["A","A","A","A","B","B"], 2)) # 8 → A-B-idle-A-B-idle-A-idle-idle-A
Complexity
- Time:
O(n)where n = number of tasks - Space:
O(1)— at most 26 distinct task types
2. Greedy Max-Heap with Cooldown Queue
Intuition
Simulate the CPU cycle by cycle. At each step, pick the available task with the highest remaining count (that’s the greedy choice — reduce the bottleneck). After running a task, it enters a cooldown queue and becomes available again after n intervals. If no task is available, we idle. This approach is more verbose than the formula but explicitly models the scheduling process.
Algorithm
- Count frequencies, build a max-heap (negate counts).
- Use a deque
cooldownstoring(count, available_at_time). - Simulate time
t = 0, 1, 2, ...: a. Ifcooldownhas a task ready at timet, push it back onto the heap. b. If the heap is non-empty, pop the most frequent task, run it (decrement count), and if count > 0, add(count, t + n + 1)to the cooldown queue. c. Incrementt. - Return
t.
Solution
import heapq
from collections import Counter, deque
def leastInterval(tasks, n):
counts = Counter(tasks)
# Max-heap via negation
heap = [-c for c in counts.values()]
heapq.heapify(heap)
cooldown = deque() # (negative_remaining_count, available_at_time)
t = 0
while heap or cooldown:
# Release tasks whose cooldown has expired
if cooldown and cooldown[0][1] <= t:
heapq.heappush(heap, cooldown.popleft()[0])
if heap:
count = heapq.heappop(heap) + 1 # increment because count is negative
if count < 0: # still has remaining runs
cooldown.append((count, t + n + 1))
# else: idle this cycle
t += 1
return t
print(leastInterval(["A","A","A","B","B","B"], 2)) # 8
print(leastInterval(["A","A","A","B","B","B"], 0)) # 6
print(leastInterval(["A","A","A","A","B","B"], 2)) # 10
Complexity
- Time:
O(t)where t is the total intervals (bounded byO(tasks * n)) - Space:
O(1)— at most 26 task types in heap and queue
Common Pitfalls
Forgetting the max(len(tasks), formula) in the math approach. When tasks are diverse and n is small, there’s no idle time and the answer is simply the number of tasks. The formula can undercount in that case.
Simulation: not checking the cooldown queue before idling. If you forget to check whether the cooldown has released a task before declaring an idle cycle, you’ll over-count intervals.
Off-by-one on cooldown availability. A task run at time t is available again at time t + n + 1, not t + n. The cooldown lasts n intervals after the task runs, so n + 1 total steps later.
Design Twitter
Difficulty: Medium Source: NeetCode
Problem
Design a simplified version of Twitter where users can post tweets, follow/unfollow another user, and see the 10 most recent tweets in their news feed.
Implement the
Twitter()— initializes your Twitter object.postTweet(userId, tweetId)— composes a new tweet with IDtweetIdby the useruserId.getNewsFeed(userId)— retrieves the 10 most recent tweet IDs in the user’s news feed. Each item must be posted by users who the user followed or by the user themselves. Tweets are ordered from most recent to least recent.follow(followerId, followeeId)— the user with IDfollowerIdstarts following the user with IDfolloweeId.unfollow(followerId, followeeId)— the user with IDfollowerIdstops following the user with IDfolloweeId.Example 1: Input:
postTweet(1, 5),getNewsFeed(1)→[5],follow(1, 2),postTweet(2, 6),getNewsFeed(1)→[6, 5],unfollow(1, 2),getNewsFeed(1)→[5]Constraints:
1 <= userId, followerId, followeeId <= 5000 <= tweetId <= 10^4- All tweets have unique IDs
- At most
3 * 10^4calls will be made topostTweet,getNewsFeed,follow, andunfollow
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash maps — storing per-user tweet lists and follow sets
- Min-heap merge — merging multiple sorted lists using a heap (k-way merge)
- Timestamps — using a monotonically increasing counter to order events
1. Brute Force — Collect and Sort
Intuition
For getNewsFeed, gather all tweets from the user and their followees into one big list, sort by timestamp descending, and return the top 10. Simple to implement but doesn’t scale when users have millions of tweets.
Algorithm
- Store tweets as
(timestamp, tweetId)per user in a list. - Store followees per user in a set (always include self).
- For
getNewsFeed: collect all tweets from user and followees, sort by timestamp descending, return first 10 tweet IDs.
Solution
from collections import defaultdict
class Twitter:
def __init__(self):
self.time = 0
self.tweets = defaultdict(list) # userId -> [(timestamp, tweetId)]
self.following = defaultdict(set) # followerId -> {followeeId}
def postTweet(self, userId, tweetId):
self.tweets[userId].append((self.time, tweetId))
self.time += 1
def getNewsFeed(self, userId):
all_tweets = list(self.tweets[userId])
for followee in self.following[userId]:
all_tweets.extend(self.tweets[followee])
all_tweets.sort(key=lambda x: -x[0])
return [tid for _, tid in all_tweets[:10]]
def follow(self, followerId, followeeId):
self.following[followerId].add(followeeId)
def unfollow(self, followerId, followeeId):
self.following[followerId].discard(followeeId)
# Test
t = Twitter()
t.postTweet(1, 5)
print(t.getNewsFeed(1)) # [5]
t.follow(1, 2)
t.postTweet(2, 6)
print(t.getNewsFeed(1)) # [6, 5]
t.unfollow(1, 2)
print(t.getNewsFeed(1)) # [5]
Complexity
- Time:
O(n log n)pergetNewsFeedwhere n = total tweets by user + followees - Space:
O(n)total tweet storage
2. Min-Heap K-Way Merge
Intuition
Each user’s tweet list is already sorted by time (we append in order). getNewsFeed is really just merging the tweet lists of the user and their followees and taking the top 10. We can do this efficiently with a min-heap: seed the heap with the most recent tweet from each relevant user, then repeatedly extract the latest tweet and advance that user’s pointer — at most 10 times total.
We store (-timestamp, tweetId, userId, tweet_index) in the heap. The negative timestamp makes Python’s min-heap behave as a max-heap by recency.
Algorithm
- Same data storage as brute force.
- For
getNewsFeed: a. Consider the user and all their followees. b. For each, if they have tweets, push(-timestamp, tweetId, userId, last_index)onto the heap. c. Pop up to 10 times: record the tweetId, then push the next tweet from the same user (if any) onto the heap.
Solution
import heapq
from collections import defaultdict
class Twitter:
def __init__(self):
self.time = 0
self.tweets = defaultdict(list) # userId -> [(timestamp, tweetId)]
self.following = defaultdict(set)
def postTweet(self, userId, tweetId):
self.tweets[userId].append((self.time, tweetId))
self.time += 1
def getNewsFeed(self, userId):
heap = []
# Include the user themselves
relevant = self.following[userId] | {userId}
for uid in relevant:
user_tweets = self.tweets[uid]
if user_tweets:
idx = len(user_tweets) - 1
ts, tid = user_tweets[idx]
heapq.heappush(heap, (-ts, tid, uid, idx - 1))
feed = []
while heap and len(feed) < 10:
neg_ts, tid, uid, next_idx = heapq.heappop(heap)
feed.append(tid)
if next_idx >= 0:
ts, next_tid = self.tweets[uid][next_idx]
heapq.heappush(heap, (-ts, next_tid, uid, next_idx - 1))
return feed
def follow(self, followerId, followeeId):
self.following[followerId].add(followeeId)
def unfollow(self, followerId, followeeId):
self.following[followerId].discard(followeeId)
# Test 1
t = Twitter()
t.postTweet(1, 5)
print(t.getNewsFeed(1)) # [5]
t.follow(1, 2)
t.postTweet(2, 6)
print(t.getNewsFeed(1)) # [6, 5]
t.unfollow(1, 2)
print(t.getNewsFeed(1)) # [5]
# Test 2 — self-tweets always in feed
t2 = Twitter()
t2.postTweet(1, 1)
t2.postTweet(1, 2)
t2.postTweet(2, 3)
t2.follow(1, 2)
print(t2.getNewsFeed(1)) # [3, 2, 1]
Complexity
- Time:
O(f log f)pergetNewsFeedwhere f = number of followees (heap seed), thenO(10 log f)for extraction — effectivelyO(f log f) - Space:
O(f)for the heap,O(total tweets)for storage
Common Pitfalls
Not including the user in their own news feed. The problem says the feed includes the user’s own tweets. Don’t forget to add userId to the relevant set.
Using remove instead of discard for unfollow. If the user tries to unfollow someone they don’t follow, set.remove() raises a KeyError. Use set.discard() which silently does nothing.
Timestamp collisions. If you use real time (time.time()), tweets posted in the same millisecond can have equal timestamps. Use a simple integer counter that increments with every postTweet call.
Single Threaded CPU
Difficulty: Medium Source: NeetCode
Problem
You are given
ntasks labeled from0ton - 1represented by a 2D integer arraytasks, wheretasks[i] = [enqueueTime_i, processingTime_i]means that theith task will be available to process atenqueueTime_iand will takeprocessingTime_iunits of time to finish processing.You have a single-threaded CPU that can process at most one task at a time and will act in the following way:
- If the CPU is idle and there are no available tasks to process, the CPU waits until the next task becomes available.
- If the CPU is idle and there are available tasks, the CPU picks the task with the shortest processing time. If multiple tasks have the same shortest processing time, it will choose the task with the smallest index.
Return the order in which the CPU will process the tasks.
Example 1: Input:
tasks = [[1,2],[2,4],[3,2],[4,1]]Output:[0, 2, 3, 1]Example 2: Input:
tasks = [[7,10],[7,12],[7,5],[7,4],[7,2]]Output:[4, 3, 2, 0, 1]Constraints:
tasks.length == n1 <= n <= 10^51 <= enqueueTime_i, processingTime_i <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Simulation — stepping through a process with explicit time tracking
- Min-heap — extracting the task with the shortest processing time
- Sorting — sorting tasks by enqueue time while preserving original indices
1. Brute Force — Simulate with Linear Scan
Intuition
At each CPU step, find all tasks that have arrived so far, pick the one with the shortest processing time (tie-break by original index), run it, advance time by its processing time, and repeat. The inner scan is O(n) and we do it n times.
Algorithm
- Tag each task with its original index:
(enqueueTime, processingTime, index). - Sort by enqueue time.
- Maintain
time(current CPU time) and a list of completed task indices. - While tasks remain: find all arrived tasks, pick the best one, advance time, record it.
Solution
def getOrder(tasks):
# Attach original indices
indexed = sorted(enumerate(tasks), key=lambda x: x[1][0])
n = len(tasks)
result = []
time = 0
i = 0 # pointer into sorted indexed list
while len(result) < n:
# Collect all tasks available at current time
available = []
while i < n and indexed[i][1][0] <= time:
orig_idx, (eq, pt) = indexed[i]
available.append((pt, orig_idx))
i += 1
if available:
available.sort()
pt, orig_idx = available[0]
result.append(orig_idx)
time += pt
# Put back unused tasks (brute force: re-scan next iteration)
# For simplicity, use a remaining list approach
else:
# CPU idle — jump to next task's enqueue time
if i < n:
time = indexed[i][1][0]
return result
# This brute force version is simplified; for correctness with "put back",
# use the heap approach below.
print(getOrder([[7,10],[7,12],[7,5],[7,4],[7,2]])) # [4,3,2,0,1]
Complexity
- Time:
O(n²)— linear scan per task - Space:
O(n)
2. Min-Heap Simulation
Intuition
Sort tasks by enqueue time (keeping original indices). Maintain a min-heap of (processingTime, originalIndex) for tasks that have already arrived. At each step, push all newly available tasks onto the heap, then pop the best one (shortest time, then smallest index). If the heap is empty, jump the clock forward to the next task’s enqueue time.
Algorithm
- Create
sorted_tasks = sorted(enumerate(tasks), key=lambda x: x[1][0])— sorted by enqueue time, preserving original index. - Initialize
time = 0,heap = [],i = 0,result = []. - While
resulthas fewer thannitems: a. Push all tasks withenqueueTime <= timeonto the heap as(processingTime, originalIndex). b. If heap is empty, jumptimeto the next task’s enqueue time and continue. c. Pop(pt, orig_idx)from the heap, appendorig_idxto result, advancetime += pt. - Return
result.
Solution
import heapq
def getOrder(tasks):
# (enqueueTime, processingTime, originalIndex)
sorted_tasks = sorted(
[(eq, pt, i) for i, (eq, pt) in enumerate(tasks)],
key=lambda x: x[0]
)
n = len(tasks)
result = []
heap = [] # (processingTime, originalIndex)
time = 0
i = 0
while len(result) < n:
# Enqueue all tasks that have arrived by current time
while i < n and sorted_tasks[i][0] <= time:
eq, pt, orig = sorted_tasks[i]
heapq.heappush(heap, (pt, orig))
i += 1
if heap:
pt, orig = heapq.heappop(heap)
result.append(orig)
time += pt
else:
# CPU idle — jump to next task's arrival
time = sorted_tasks[i][0]
return result
print(getOrder([[1,2],[2,4],[3,2],[4,1]])) # [0, 2, 3, 1]
print(getOrder([[7,10],[7,12],[7,5],[7,4],[7,2]])) # [4, 3, 2, 0, 1]
print(getOrder([[1,1],[2,2],[4,3]])) # [0, 1, 2]
Complexity
- Time:
O(n log n)— sort plus n heap operations - Space:
O(n)
Common Pitfalls
Not preserving original indices after sorting. You sort tasks by enqueue time to know what’s available, but the output wants original indices. Always track (enqueueTime, processingTime, originalIndex) together.
Forgetting the CPU idle jump. When the heap is empty (no task has arrived yet), don’t increment time by 1 — jump directly to the next task’s enqueue time. Incrementing by 1 works but gives TLE for large inputs with big gaps.
Tie-breaking order. Python’s heap is lexicographic on tuples, so (processingTime, originalIndex) automatically breaks ties by smaller index first — exactly what the problem requires. Make sure your tuple has this order.
Reorganize String
Difficulty: Medium Source: NeetCode
Problem
Given a string
s, rearrange the characters ofsso that any two adjacent characters are not the same. If this is not possible, return an empty string"".Example 1: Input:
s = "aab"Output:"aba"Example 2: Input:
s = "aaab"Output:""Constraints:
1 <= s.length <= 500sconsists of lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — always placing the most frequent available character
- Max-heap — efficiently retrieving the character with the highest remaining count
- Frequency counting — using
Counterfrom thecollectionsmodule
1. Brute Force — Interleave Sorted Frequencies
Intuition
Sort characters by frequency descending and try to place them at even positions first, then odd positions. This is the classic “task scheduler” interleave trick. If the most frequent character appears more than (len + 1) // 2 times, it’s impossible.
Algorithm
- Count character frequencies.
- If any count exceeds
(len(s) + 1) // 2, return"". - Sort characters by frequency descending.
- Place them at even indices
0, 2, 4, ...then odd indices1, 3, 5, ....
Solution
from collections import Counter
def reorganizeString(s):
counts = Counter(s)
n = len(s)
if max(counts.values()) > (n + 1) // 2:
return ""
# Sort by frequency descending
chars = sorted(counts.keys(), key=lambda c: -counts[c])
result = [''] * n
idx = 0 # fill even positions first, then odd
for char in chars:
for _ in range(counts[char]):
if idx >= n:
idx = 1 # switch to odd positions
result[idx] = char
idx += 2
return ''.join(result)
print(reorganizeString("aab")) # "aba"
print(reorganizeString("aaab")) # ""
print(reorganizeString("vvvlo")) # "vlvov" or similar valid answer
Complexity
- Time:
O(n log n)— sort dominates - Space:
O(n)
2. Greedy Max-Heap
Intuition
Build a result character by character. At each step, greedily pick the character with the highest remaining count that is not the same as the last character placed. A max-heap makes retrieval of the best candidate O(log k) where k ≤ 26. If we can’t place any character (only the same one remains), it’s impossible.
The trick: after picking a character, hold it aside (don’t push back yet). Pick the next best character, place it, then push the held character back. This ensures we never place two of the same character in a row.
Algorithm
- Count frequencies. If any >
(n + 1) // 2, return"". - Build a max-heap
(-count, char). - While the heap is non-empty:
a. Pop
(neg_count1, char1)— the most frequent character. b. Appendchar1to result. c. If the heap is non-empty, pop(neg_count2, char2)— second most frequent. d. Appendchar2to result. e. Push back any characters that still have remaining count. - Return the result (check if its length equals
n— if not, return"").
Solution
import heapq
from collections import Counter
def reorganizeString(s):
counts = Counter(s)
n = len(s)
# Early impossibility check
if max(counts.values()) > (n + 1) // 2:
return ""
# Max-heap via negation: (-count, char)
heap = [(-cnt, ch) for ch, cnt in counts.items()]
heapq.heapify(heap)
result = []
while heap:
neg1, ch1 = heapq.heappop(heap)
result.append(ch1)
if heap:
neg2, ch2 = heapq.heappop(heap)
result.append(ch2)
if neg2 + 1 < 0: # still has remaining count
heapq.heappush(heap, (neg2 + 1, ch2))
if neg1 + 1 < 0: # push ch1 back after ch2 is placed
heapq.heappush(heap, (neg1 + 1, ch1))
return ''.join(result) if len(result) == n else ""
print(reorganizeString("aab")) # "aba"
print(reorganizeString("aaab")) # ""
print(reorganizeString("aabb")) # "abab" or "baba"
print(reorganizeString("vvvlo")) # valid arrangement like "vlvov"
Complexity
- Time:
O(n log k)where k ≤ 26 — n characters placed, each heap op is O(log k) - Space:
O(n)for the result
Common Pitfalls
Forgetting the impossibility check. If any character appears more than (len + 1) // 2 times, no valid arrangement exists. Always check this first — it saves time and avoids an infinite loop in naive implementations.
Pushing the current character back too early. In the pair-based approach, push ch1 back only after ch2 has been appended. If you push it back before placing ch2, the heap may immediately pop ch1 again and place it twice in a row.
Returning the result without length validation. In edge cases, the result might be shorter than n (e.g., only one character type remains and it’s the last). Check len(result) == n before returning.
Longest Happy String
Difficulty: Medium Source: NeetCode
Problem
A string
sis called happy if it satisfies the following conditions:
sonly contains the letters'a','b', and'c'.sdoes not contain"aaa","bbb", or"ccc"as a substring.scontains at mostaoccurrences of the letter'a'.scontains at mostboccurrences of the letter'b'.scontains at mostcoccurrences of the letter'c'.Given three integers
a,b, andc, return the longest possible happy string. If there are multiple longest happy strings, return any of them. If there is no such string, return the empty string"".Example 1: Input:
a = 1, b = 1, c = 7Output:"ccaccbcc"(or any valid happy string of the same length)Example 2: Input:
a = 7, b = 1, c = 0Output:"aabaa"(any valid output with at most 2 consecutive a’s)Constraints:
0 <= a, b, c <= 100a + b + c > 0
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — always choosing the character that allows the longest string
- Max-heap — retrieving the most frequent remaining character
- String building — checking the last two characters to avoid three consecutive same chars
1. Brute Force — Greedy with Sorted List
Intuition
At each step, try to add the most frequent character. If adding it would create three consecutive identical characters, try the second most frequent instead. If neither works, stop. Re-sort after each step to keep the most frequent at the front.
Algorithm
- Start with
counts = [(-a, 'a'), (-b, 'b'), (-c, 'c')]. - While true: a. Sort by count descending. b. Try the most frequent char — if the last two chars of result are the same as this char, skip and try the second most frequent. c. If we found a valid char, append it and decrement its count. d. If no valid char exists, break.
- Return result.
Solution
def longestDiverseString(a, b, c):
counts = [[-a, 'a'], [-b, 'b'], [-c, 'c']]
result = []
while True:
counts.sort() # most negative = most frequent at index 0
# Try most frequent first
placed = False
for i in range(len(counts)):
if counts[i][0] == 0:
continue
ch = counts[i][1]
# Check for triple
if len(result) >= 2 and result[-1] == ch and result[-2] == ch:
continue
result.append(ch)
counts[i][0] += 1 # increment because stored as negative
placed = True
break
if not placed:
break
return ''.join(result)
print(longestDiverseString(1, 1, 7)) # "ccaccbcc" or similar
print(longestDiverseString(7, 1, 0)) # "aabaa" or similar
print(longestDiverseString(2, 2, 1)) # "aabbc" or "aabcb" etc.
Complexity
- Time:
O((a+b+c) * 3 log 3)— each character placement involves a sort of size 3, so effectively O(n) - Space:
O(a+b+c)for the result
2. Greedy Max-Heap
Intuition
Use a max-heap so we always have the most frequent character available in O(log k) instead of sorting repeatedly. At each step:
- Pop the most frequent character.
- If the last two characters in the result are already this character, we must pop the second most frequent instead and use it, then push the first back.
- Append the chosen character, decrement its count, push it back if it still has remaining count.
- Stop when the heap is empty or we can’t make a valid placement.
Algorithm
- Build max-heap
(-count, char)for a, b, c (skip zeros). - While the heap is non-empty:
a. Pop
(neg_cnt1, ch1)— the most frequent. b. If the result ends withch1twice:- If heap is empty, break (can’t place anything else).
- Pop
(neg_cnt2, ch2)— second most frequent. - Append
ch2, pushch2back (if remaining), pushch1back. c. Otherwise, appendch1, pushch1back (if remaining).
- Return result.
Solution
import heapq
def longestDiverseString(a, b, c):
heap = []
for count, ch in [(a, 'a'), (b, 'b'), (c, 'c')]:
if count > 0:
heapq.heappush(heap, (-count, ch))
result = []
while heap:
neg1, ch1 = heapq.heappop(heap)
# If last two chars are ch1, we must use a different character
if len(result) >= 2 and result[-1] == ch1 and result[-2] == ch1:
if not heap:
break # nothing else to place
neg2, ch2 = heapq.heappop(heap)
result.append(ch2)
if neg2 + 1 < 0:
heapq.heappush(heap, (neg2 + 1, ch2))
# Put ch1 back unchanged
heapq.heappush(heap, (neg1, ch1))
else:
result.append(ch1)
if neg1 + 1 < 0:
heapq.heappush(heap, (neg1 + 1, ch1))
return ''.join(result)
print(longestDiverseString(1, 1, 7)) # "ccaccbcc" or similar, length 8
print(longestDiverseString(7, 1, 0)) # "aabaa" or "aab" etc.
print(longestDiverseString(2, 2, 1)) # length 5
print(longestDiverseString(0, 0, 1)) # "c"
Complexity
- Time:
O(n log 3)=O(n)where n = a + b + c — constant-size heap - Space:
O(n)for the result
Common Pitfalls
Greedily adding two of the most frequent character at once. A common optimization is to add two copies of the most frequent char when its count is much larger than the others. This is valid but adds complexity — the single-append approach with the “last two chars” check is simpler and equally correct.
Not pushing the first character back before breaking. If we can’t place ch1 (because it would cause a triple) and the heap is empty, we must break. But don’t forget that ch1 is already popped — if you have other logic that relies on the heap being complete, this matters.
Checking only the last character instead of the last two. The constraint is no three consecutive same chars, meaning result[-1] == ch and result[-2] == ch — both of the last two must match. Checking only result[-1] is too conservative and may cut the string short.
Car Pooling
Difficulty: Medium Source: NeetCode
Problem
There is a car with
capacityempty seats. The vehicle only drives east (i.e., it cannot turn around and drive west).You are given the integer
capacityand an arraytripswheretrips[i] = [numPassengers_i, from_i, to_i]indicates that theith trip hasnumPassengers_ipassengers and the passengers must be picked up fromfrom_iand dropped off atto_i.Return
trueif it is possible to pick up and drop off all passengers for all the given trips, orfalseotherwise.Example 1: Input:
trips = [[2,1,5],[3,3,7]],capacity = 4Output:falseExample 2: Input:
trips = [[2,1,5],[3,3,7]],capacity = 5Output:trueConstraints:
1 <= trips.length <= 1000trips[i].length == 31 <= numPassengers_i <= 1000 <= from_i < to_i <= 10001 <= capacity <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Difference arrays — a technique for efficiently applying range updates and querying a prefix sum
- Min-heap — processing events (drop-offs) in order of time
- Sorting — processing trips in order of pickup time
1. Difference Array
Intuition
Since all locations are between 0 and 1000, we can model passenger counts over position with a difference array. For each trip, add numPassengers at from and subtract at to. Then compute the prefix sum to get the occupancy at each position. If any position exceeds capacity, return false.
Algorithm
- Create a difference array
diff[0..1001]initialized to 0. - For each trip
[num, frm, to]:diff[frm] += num,diff[to] -= num. - Compute the running sum (prefix sum) of
diff. If any prefix sum > capacity, return false. - Return true.
Solution
def carPooling(trips, capacity):
diff = [0] * 1001 # locations are in [0, 1000]
for num, frm, to in trips:
diff[frm] += num
diff[to] -= num # passengers leave at 'to'
current = 0
for count in diff:
current += count
if current > capacity:
return False
return True
print(carPooling([[2,1,5],[3,3,7]], 4)) # False
print(carPooling([[2,1,5],[3,3,7]], 5)) # True
print(carPooling([[3,2,7],[3,7,9],[8,3,9]], 11)) # True
Complexity
- Time:
O(n + L)where n = number of trips and L = 1001 (location range) - Space:
O(L)
2. Min-Heap — Event Processing
Intuition
Sort trips by pickup location. Process trips in order: whenever we pick up passengers, also drop off any passengers whose destination we’ve already passed. Use a min-heap ordered by drop-off location to efficiently find who should exit next.
Algorithm
- Sort
tripsbyfromlocation. - Initialize
heap(min bytolocation) andcurrent_passengers = 0. - For each trip
[num, frm, to]: a. Drop off all passengers withto <= frm(pop from heap while condition holds, subtract from current). b. Addnumto current passengers and push(to, num)onto the heap. c. Ifcurrent_passengers > capacity, return false. - Return true.
Solution
import heapq
def carPooling(trips, capacity):
# Sort by pickup location
trips = sorted(trips, key=lambda x: x[1])
heap = [] # (dropoff_location, num_passengers)
current = 0
for num, frm, to in trips:
# Drop off passengers who have reached their destination
while heap and heap[0][0] <= frm:
_, dropped = heapq.heappop(heap)
current -= dropped
# Pick up new passengers
current += num
heapq.heappush(heap, (to, num))
if current > capacity:
return False
return True
print(carPooling([[2,1,5],[3,3,7]], 4)) # False
print(carPooling([[2,1,5],[3,3,7]], 5)) # True
print(carPooling([[3,2,7],[3,7,9],[8,3,9]], 11)) # True
print(carPooling([[9,3,4],[9,1,7],[4,2,4]], 13)) # True
Complexity
- Time:
O(n log n)— sorting + heap operations - Space:
O(n)
Common Pitfalls
Passengers exit at to, not at to - 1. When a trip is [num, 3, 7], passengers leave at position 7. At position 7, those seats are free again. The difference array subtract should be at index to, not to - 1.
Heap approach: drop off before picking up. When we arrive at location frm, we should first drop off any passengers with dropoff <= frm before counting the new pickups. If you pick up first and then drop off, you’ll get a false overcount.
Not handling multiple trips with the same pickup location. The heap approach handles this naturally because we sort by from, and multiple trips at the same from are all processed before the capacity check.
Find Median From Data Stream
Difficulty: Hard Source: NeetCode
Problem
The median is the middle value in an ordered integer list. If the size of the list is even, there is no middle value and the median is the mean of the two middle values.
Implement the
MedianFinderclass:
MedianFinder()— initializes theMedianFinderobject.void addNum(int num)— adds the integernumfrom the data stream to the data structure.double findMedian()— returns the median of all elements so far. Answers within10^-5of the actual answer will be accepted.Example 1: Input:
addNum(1),addNum(2),findMedian()→1.5,addNum(3),findMedian()→2.0Constraints:
-10^5 <= num <= 10^5- There will be at least one element before
findMedianis called- At most
5 * 10^4calls will be made toaddNumandfindMedian
Prerequisites
Before attempting this problem, you should be comfortable with:
- Two heaps pattern — splitting a dataset at its median using a max-heap for the lower half and a min-heap for the upper half
- Heap balancing — ensuring both halves are within 1 element of each other
- Python’s
heapqmodule — min-heap only; negate values for max-heap behavior
1. Brute Force — Sorted List
Intuition
Maintain a sorted list of all elements seen so far. Insertion into a sorted list is O(n) (shift elements), but findMedian is O(1) — just index into the middle. Simple but slow for large streams.
Algorithm
- Store elements in a sorted list
nums. addNum(num): usebisect.insortto insert in sorted position.findMedian(): if odd length, return middle element; if even, return average of two middle elements.
Solution
import bisect
class MedianFinder:
def __init__(self):
self.nums = []
def addNum(self, num):
bisect.insort(self.nums, num)
def findMedian(self):
n = len(self.nums)
if n % 2 == 1:
return float(self.nums[n // 2])
else:
return (self.nums[n // 2 - 1] + self.nums[n // 2]) / 2.0
mf = MedianFinder()
mf.addNum(1)
mf.addNum(2)
print(mf.findMedian()) # 1.5
mf.addNum(3)
print(mf.findMedian()) # 2.0
Complexity
- Time:
O(n)peraddNum(insertion shift),O(1)perfindMedian - Space:
O(n)
2. Two Heaps
Intuition
Split the data into two halves at the median:
small: a max-heap holding the lower half (negate values for Python’s min-heap)large: a min-heap holding the upper half
Keep them balanced: len(small) == len(large) or len(small) == len(large) + 1 (small gets the extra element when the total is odd).
The median is:
- If same size: average of
small[0]andlarge[0] - If small has one extra:
small[0](which is the max of the lower half)
For every addNum, push to small, then balance by moving the largest of small to large if needed, then re-balance size if large is bigger than small.
Algorithm
addNum(num): a. Push-numtosmall(max-heap via negation). b. Ifsmallhas elements andlargehas elements and-small[0] > large[0]: movesmall’s max tolarge. c. Iflen(large) > len(small): movelarge’s min tosmall.findMedian():- If equal sizes:
(-small[0] + large[0]) / 2.0 - If
smallis larger:-small[0]
- If equal sizes:
Solution
import heapq
class MedianFinder:
def __init__(self):
self.small = [] # max-heap (negated) — lower half
self.large = [] # min-heap — upper half
def addNum(self, num):
# Always push to small first
heapq.heappush(self.small, -num)
# Ensure every element in small <= every element in large
if self.small and self.large and (-self.small[0] > self.large[0]):
val = -heapq.heappop(self.small)
heapq.heappush(self.large, val)
# Balance sizes: small can have at most 1 extra
if len(self.small) > len(self.large) + 1:
val = -heapq.heappop(self.small)
heapq.heappush(self.large, val)
elif len(self.large) > len(self.small):
val = heapq.heappop(self.large)
heapq.heappush(self.small, -val)
def findMedian(self):
if len(self.small) == len(self.large):
return (-self.small[0] + self.large[0]) / 2.0
return float(-self.small[0])
# Test 1
mf = MedianFinder()
mf.addNum(1)
mf.addNum(2)
print(mf.findMedian()) # 1.5
mf.addNum(3)
print(mf.findMedian()) # 2.0
# Test 2
mf2 = MedianFinder()
mf2.addNum(6)
print(mf2.findMedian()) # 6.0
mf2.addNum(10)
print(mf2.findMedian()) # 8.0
mf2.addNum(2)
print(mf2.findMedian()) # 6.0
mf2.addNum(6)
print(mf2.findMedian()) # 6.0
# Test 3 — single element
mf3 = MedianFinder()
mf3.addNum(-1)
print(mf3.findMedian()) # -1.0
mf3.addNum(-2)
print(mf3.findMedian()) # -1.5
Complexity
- Time:
O(log n)peraddNum,O(1)perfindMedian - Space:
O(n)
Common Pitfalls
Forgetting to negate when pushing to or popping from small. The max-heap is simulated by negating all values. When you push num to small, push -num. When you pop from small, negate the result to get the actual value.
Not re-balancing after the ordering fix. The two-step rebalance matters: first fix the cross-heap ordering (ensure max of small ≤ min of large), then fix the sizes. If you only do one or the other, the invariant can be violated.
Returning an int instead of a float. The problem asks for a double. In Python, (a + b) / 2 returns a float if either is a float, but -small[0] returns an int. Wrap with float() or use / 2.0.
IPO
Difficulty: Hard Source: NeetCode
Problem
Suppose LeetCode will start its IPO soon. In order to sell a good price of its shares to venture capitalists, LeetCode would like to work on some projects to increase its capital before the IPO. Since it has limited resources, it can only finish at most
kdistinct projects before the IPO. Help LeetCode design the best way to maximize its total capital after finishing at mostkdistinct projects.You are given
nprojects where theith project has a pure profitprofits[i]and a minimum capital ofcapital[i]required to start it.Initially, you have
wcapital. When you finish a project, you will obtain its pure profit and the profit will be added to your total capital. Pick at mostkdistinct projects from given projects to maximize your final capital, and return the maximized capital.Example 1: Input:
k = 2,w = 0,profits = [1, 2, 3],capital = [0, 1, 1]Output:4Example 2: Input:
k = 3,w = 0,profits = [1, 2, 3],capital = [0, 1, 2]Output:6Constraints:
1 <= k <= 10^50 <= w <= 10^9n == profits.length == capital.length1 <= n <= 10^50 <= profits[i] <= 10^40 <= capital[i] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — always taking the most profitable currently affordable project
- Two heaps pattern — one heap for locked projects (min by capital), one for available projects (max by profit)
- Min-heap and max-heap — using Python’s
heapqwith negation for max-heap behavior
1. Brute Force — Scan All Projects Each Round
Intuition
For each of the k rounds, scan all projects, find all that we can currently afford, pick the one with the highest profit, collect it, and add the profit to our capital. This is correct but slow for large inputs.
Algorithm
- Repeat at most
ktimes: a. Find all projects wherecapital[i] <= w. b. If none exist, break. c. Pick the project with the maximum profit. Add its profit tow. Mark it as done. - Return
w.
Solution
def findMaximizedCapital(k, w, profits, capital):
n = len(profits)
done = [False] * n
for _ in range(k):
best_profit = -1
best_idx = -1
for i in range(n):
if not done[i] and capital[i] <= w:
if profits[i] > best_profit:
best_profit = profits[i]
best_idx = i
if best_idx == -1:
break # can't afford any project
done[best_idx] = True
w += best_profit
return w
print(findMaximizedCapital(2, 0, [1, 2, 3], [0, 1, 1])) # 4
print(findMaximizedCapital(3, 0, [1, 2, 3], [0, 1, 2])) # 6
print(findMaximizedCapital(1, 0, [1, 2, 3], [1, 1, 2])) # 0 (can't afford any)
Complexity
- Time:
O(n * k)— scan all n projects for each of k rounds - Space:
O(n)
2. Two Heaps — Greedy Optimal
Intuition
We want to greedily pick the highest-profit affordable project in each round. The key insight: sort projects by capital requirement. Use a min-heap by capital (locked) to efficiently release projects as our wallet grows. Each round, push all newly affordable projects (capital ≤ w) onto a max-heap by profit (available). Then pop the most profitable available project, collect its profit, and repeat.
This is essentially a “sweep” — as w grows, more projects unlock and flow from the locked heap to the available heap.
Algorithm
- Build
locked = [(capital[i], profits[i]) for i in range(n)]as a min-heap. availableis a max-heap (negate profits) of projects we can currently afford.- Repeat at most
ktimes: a. Whilelockedis non-empty andlocked[0][0] <= w, pop it and push-profitontoavailable. b. Ifavailableis empty, break (can’t afford anything). c. Pop the most profitable fromavailable, add its profit tow. - Return
w.
Solution
import heapq
def findMaximizedCapital(k, w, profits, capital):
# Min-heap of locked projects sorted by capital requirement
locked = list(zip(capital, profits))
heapq.heapify(locked)
available = [] # Max-heap of profits for affordable projects (negated)
for _ in range(k):
# Unlock all projects we can now afford
while locked and locked[0][0] <= w:
cap, profit = heapq.heappop(locked)
heapq.heappush(available, -profit)
if not available:
break # Can't unlock any new project
# Take the highest-profit available project
w += -heapq.heappop(available)
return w
print(findMaximizedCapital(2, 0, [1, 2, 3], [0, 1, 1])) # 4
print(findMaximizedCapital(3, 0, [1, 2, 3], [0, 1, 2])) # 6
print(findMaximizedCapital(1, 0, [1, 2, 3], [1, 1, 2])) # 0
print(findMaximizedCapital(2, 1, [2, 3, 5], [0, 1, 2])) # 9: pick profit 3 (w=4), then profit 5 (w=9)
Complexity
- Time:
O(n log n + k log n)— heapify is O(n log n); each of k rounds does O(log n) heap ops - Space:
O(n)
Common Pitfalls
Not unlocking all affordable projects before picking. In each round, you should push every project with capital <= w onto the available heap before selecting. If you push only one and then select, you might miss a higher-profit project that was also affordable.
Confusing which heap uses negation. The locked heap is a min-heap by capital (no negation needed). The available heap is a max-heap by profit (negate profits). Keep this straight or you’ll pick the wrong project each round.
Stopping too early when locked is empty but available is not. Just because there are no more projects to unlock doesn’t mean we’re done — we might still have profitable projects sitting in available from previous rounds. Only break when available is empty.
Hashing
Python dictionaries offer O(1) lookup, insert, and delete. How? Magic? No — hash tables.
A hash table is one of the most practically useful data structures ever invented. It underpins Python dicts, Python sets, database indexes, caches, spell checkers, and much more. Understanding it will make you a dramatically better programmer.
The Core Idea: Mapping Keys to Array Indices
Arrays give you O(1) access by index: arr[42] is instant. The challenge is that real-world keys are strings, objects, or other non-integers — not tidy array indices.
A hash function solves this: it converts any key into a number, which is then used as an array index.
flowchart LR
K1["key: 'apple'"] --> HF["hash function"]
K2["key: 'banana'"] --> HF
K3["key: 'cherry'"] --> HF
HF --> I1["index 3"]
HF --> I2["index 7"]
HF --> I3["index 1"]
I1 --> B["Bucket array\n[_, cherry, _, apple, _, _, _, banana, _]"]
I2 --> B
I3 --> B
The lookup is O(1) because:
- Compute
hash(key)→ get an index. - Go directly to
array[index].
No scanning. No searching. Just one jump.
The Problem: Collisions
Different keys can produce the same index. This is called a collision and is unavoidable — there are infinitely many possible keys but only a finite-size array.
flowchart LR
K1["key: 'cat'"] --> HF["hash('cat') % 10 = 4"]
K2["key: 'act'"] --> HF2["hash('act') % 10 = 4"]
HF --> Bucket["Bucket 4 — collision!"]
HF2 --> Bucket
Good hash table implementations handle collisions gracefully. The two main strategies are chaining (each bucket holds a list of entries) and open addressing (find the next empty slot). You will implement chaining in the Hash Implementation chapter.
A Quick Taste
# Python dict is a hash table
phone_book = {}
phone_book["Alice"] = "555-1234"
phone_book["Bob"] = "555-5678"
phone_book["Carol"] = "555-9999"
# O(1) lookup — no matter how large the dict gets
print(phone_book["Alice"])
print("Bob" in phone_book) # O(1) membership check
print(phone_book.get("Dave", "not found"))
# Python set is a hash table that stores only keys (no values)
unique_visitors = {"alice", "bob", "alice", "carol", "bob"}
print(unique_visitors) # duplicates removed automatically
print("alice" in unique_visitors) # O(1)
What You Will Learn
- Hash Usage — the everyday patterns that make hash maps and sets so powerful: frequency counting, two-sum, anagram detection, caching, and more.
- Hash Implementation — how hash tables actually work under the hood: hash functions, collision handling with chaining, load factor, and resizing.
Hash Usage
Count word frequencies in a book in O(n) time. Detect whether two strings are anagrams in O(n). Solve the two-sum problem in a single pass. All of these become trivial with a hash map.
This chapter is about the patterns — the recurring ways that hash maps and sets appear in real problems.
Pattern 1: Frequency Counter
Count how many times each element appears. The canonical use is word counting, but the same pattern applies to characters, numbers, or any hashable value.
from collections import Counter
text = "the quick brown fox jumps over the lazy dog the fox"
# Manual approach
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
print("Manual frequency count:")
for word, count in sorted(freq.items(), key=lambda x: -x[1]):
print(f" {word}: {count}")
# Python shortcut: Counter
counter = Counter(text.split())
print("\nTop 3 words:", counter.most_common(3))
Real-world use: search engines count term frequencies to rank pages. Spam filters count word patterns to detect junk email.
Pattern 2: Two-Sum (Complement Lookup)
Given an array and a target, find two numbers that add up to the target. The naive approach is O(n²) — check every pair. The hash map approach is O(n).
def two_sum(nums, target):
"""Return indices of two numbers that add to target. O(n) time, O(n) space."""
seen = {} # value -> index
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
print(two_sum([2, 7, 11, 15], 9)) # [0, 1] (2 + 7 = 9)
print(two_sum([3, 2, 4], 6)) # [1, 2] (2 + 4 = 6)
print(two_sum([1, 5, 3, 7], 10)) # [1, 3] (5 + 7 = 10... wait: 3 + 7 = 10)
# Walkthrough of the algorithm
print("\nStep-by-step for [2, 7, 11, 15], target=9:")
nums, target = [2, 7, 11, 15], 9
seen = {}
for i, num in enumerate(nums):
complement = target - num
print(f" i={i} num={num} need {complement} seen={seen} found={complement in seen}")
seen[num] = i
The key insight: for each number x, instead of scanning the array for target - x, you look it up in the hash map in O(1).
Pattern 3: Anagram Detection
Two strings are anagrams if they contain the same characters with the same frequencies.
from collections import Counter
def is_anagram(s, t):
"""Check if s and t are anagrams. O(n) time."""
return Counter(s) == Counter(t)
def group_anagrams(words):
"""Group a list of words by their anagram signature. O(n * k) where k = max word length."""
groups = {}
for word in words:
key = tuple(sorted(word)) # canonical form: sorted characters
groups.setdefault(key, []).append(word)
return list(groups.values())
print(is_anagram("listen", "silent")) # True
print(is_anagram("hello", "world")) # False
word_list = ["eat", "tea", "tan", "ate", "nat", "bat"]
print("\nAnagram groups:", group_anagrams(word_list))
Pattern 4: Duplicate Detection and Uniqueness
A hash set answers “have I seen this before?” in O(1).
def contains_duplicate(nums):
return len(nums) != len(set(nums))
def first_duplicate(nums):
seen = set()
for num in nums:
if num in seen:
return num
seen.add(num)
return None
def longest_unique_substring(s):
"""Sliding window + set: O(n)."""
char_set = set()
left = 0
best = 0
for right in range(len(s)):
while s[right] in char_set:
char_set.remove(s[left])
left += 1
char_set.add(s[right])
best = max(best, right - left + 1)
return best
print(contains_duplicate([1, 2, 3, 1])) # True
print(contains_duplicate([1, 2, 3, 4])) # False
print(first_duplicate([3, 1, 4, 1, 5, 9])) # 1
print(longest_unique_substring("abcabcbb")) # 3 ("abc")
print(longest_unique_substring("pwwkew")) # 3 ("wke")
Hash vs List: A Speed Comparison
import time
import random
n = 100_000
data = list(range(n))
data_set = set(data)
lookups = [random.randint(0, n * 2) for _ in range(10_000)]
# List: O(n) per lookup
start = time.perf_counter()
for val in lookups:
_ = val in data
list_time = time.perf_counter() - start
# Set: O(1) per lookup
start = time.perf_counter()
for val in lookups:
_ = val in data_set
set_time = time.perf_counter() - start
print(f"List lookup (10k queries in {n} items): {list_time * 1000:.1f} ms")
print(f"Set lookup (10k queries in {n} items): {set_time * 1000:.1f} ms")
print(f"Speedup: {list_time / set_time:.0f}x faster with a set")
Pattern 5: Memoization (Caching)
Store the results of expensive function calls in a dict so you never compute the same input twice.
import time
# Without memoization — exponential time
def fib_slow(n):
if n <= 1:
return n
return fib_slow(n - 1) + fib_slow(n - 2)
# With memoization — O(n) time
def fib_fast(n, cache={}):
if n in cache:
return cache[n]
if n <= 1:
return n
cache[n] = fib_fast(n - 1) + fib_fast(n - 2)
return cache[n]
# Python provides @functools.lru_cache as a decorator
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
start = time.perf_counter()
result_slow = fib_slow(35)
slow_time = time.perf_counter() - start
start = time.perf_counter()
result_fast = fib(35)
fast_time = time.perf_counter() - start
print(f"fib(35) = {result_slow} (slow: {slow_time*1000:.0f} ms)")
print(f"fib(35) = {result_fast} (fast: {fast_time*1000:.2f} ms)")
print(f"Speedup: {slow_time / fast_time:.0f}x")
Common Hash Map Operations at a Glance
d = {}
# Insert / update
d["key"] = "value"
# Lookup (raises KeyError if missing)
# val = d["key"]
# Safe lookup with default
val = d.get("key", "default")
print("get:", val)
# Check membership
print("in:", "key" in d)
# Delete
d["temp"] = 42
del d["temp"]
# Iterate
d = {"a": 1, "b": 2, "c": 3}
print("keys: ", list(d.keys()))
print("values:", list(d.values()))
print("items: ", list(d.items()))
# setdefault: insert only if key absent
d.setdefault("d", 0)
d["d"] += 1
print("setdefault:", d)
# defaultdict: auto-initialise missing keys
from collections import defaultdict
graph = defaultdict(list)
graph["A"].append("B")
graph["A"].append("C")
print("defaultdict graph:", dict(graph))
Hash Implementation
You have been using Python dicts for years. Now it’s time to understand what happens inside them. Building a hash table from scratch is one of the best exercises in computer science — it connects arrays, linked lists, and probability in one elegant structure.
Step 1: The Hash Function
A hash function converts a key into an integer, which is then mapped to a bucket index using the modulo operator.
def simple_hash(key, capacity):
"""Sum the ASCII values of all characters, then take modulo."""
total = 0
for ch in str(key):
total += ord(ch)
return total % capacity
capacity = 10
print("Hash function results (capacity=10):")
for key in ["cat", "dog", "apple", "tac", "act", "hello"]:
index = simple_hash(key, capacity)
print(f" '{key}' -> {index}")
print("\nNotice: 'cat', 'tac', 'act' all hash to the same index — that's a collision!")
A good hash function has two properties:
- Deterministic: the same key always produces the same index.
- Uniform distribution: keys spread evenly across buckets, minimising collisions.
The simple sum-of-chars function above is weak (anagrams always collide). Python’s built-in hash() uses a much better algorithm with randomised seeds.
Step 2: Collision Handling with Chaining
When two keys hash to the same bucket, chaining stores both entries in a linked list (or Python list) at that bucket. Lookup scans the short list at the bucket, which stays close to O(1) when the table is not too full.
flowchart TD
subgraph Buckets["Bucket array (capacity = 5)"]
B0["[0]: → ('apple', 1)"]
B1["[1]: empty"]
B2["[2]: → ('banana', 2) → ('grape', 5)"]
B3["[3]: → ('cherry', 3)"]
B4["[4]: → ('date', 4)"]
end
'banana' and 'grape' both hashed to bucket 2 — they form a chain there.
Full HashTable Implementation
class HashTable:
def __init__(self, capacity=8):
self.capacity = capacity
self.size = 0
self.buckets = [[] for _ in range(capacity)] # each bucket is a list of (key, value) pairs
def _hash(self, key):
total = 0
for ch in str(key):
total += ord(ch)
return total % self.capacity
def put(self, key, value):
index = self._hash(key)
bucket = self.buckets[index]
# Update existing key if present
for i, (k, v) in enumerate(bucket):
if k == key:
bucket[i] = (key, value)
return
# Otherwise append a new entry
bucket.append((key, value))
self.size += 1
# Resize if load factor exceeds 0.75
if self.size / self.capacity > 0.75:
self._resize()
def get(self, key, default=None):
index = self._hash(key)
for k, v in self.buckets[index]:
if k == key:
return v
return default
def remove(self, key):
index = self._hash(key)
bucket = self.buckets[index]
for i, (k, v) in enumerate(bucket):
if k == key:
del bucket[i]
self.size -= 1
return True
return False
def _resize(self):
"""Double the capacity and rehash all entries."""
old_buckets = self.buckets
self.capacity *= 2
self.buckets = [[] for _ in range(self.capacity)]
self.size = 0
for bucket in old_buckets:
for key, value in bucket:
self.put(key, value) # rehash into new buckets
def load_factor(self):
return self.size / self.capacity
def __repr__(self):
entries = []
for bucket in self.buckets:
for k, v in bucket:
entries.append(f"{k!r}: {v!r}")
return "{" + ", ".join(entries) + "}"
# --- Demo ---
ht = HashTable()
ht.put("name", "Alice")
ht.put("age", 30)
ht.put("city", "Sydney")
ht.put("job", "Engineer")
print("After 4 inserts:", ht)
print("get('name'):", ht.get("name"))
print("get('missing', 'N/A'):", ht.get("missing", "N/A"))
print("load factor:", round(ht.load_factor(), 2))
ht.remove("age")
print("After removing 'age':", ht)
# Update existing key
ht.put("name", "Bob")
print("After updating 'name':", ht)
Load Factor and Resizing
The load factor is number_of_entries / capacity. It measures how full the table is.
flowchart LR
LF0["load factor = 0.0\n(empty)"] --> LF5["load factor = 0.5\n(half full)"]
LF5 --> LF75["load factor = 0.75\n(resize threshold)"]
LF75 --> LF1["capacity doubled,\nall entries rehashed"]
LF1 --> LF38["load factor ≈ 0.38\n(half full again)"]
- Below ~0.75: collisions are rare, most lookups are O(1).
- Above ~0.75: chains grow, performance degrades toward O(n).
- Python’s dict resizes at 2/3 capacity to keep things fast.
class HashTable:
def __init__(self, capacity=8):
self.capacity = capacity
self.size = 0
self.buckets = [[] for _ in range(capacity)]
self._resize_count = 0
def _hash(self, key):
total = sum(ord(ch) for ch in str(key))
return total % self.capacity
def put(self, key, value):
index = self._hash(key)
for i, (k, v) in enumerate(self.buckets[index]):
if k == key:
self.buckets[index][i] = (key, value)
return
self.buckets[index].append((key, value))
self.size += 1
if self.size / self.capacity > 0.75:
self._resize()
def get(self, key, default=None):
for k, v in self.buckets[self._hash(key)]:
if k == key:
return v
return default
def _resize(self):
old_buckets = self.buckets
self.capacity *= 2
self.buckets = [[] for _ in range(self.capacity)]
self.size = 0
self._resize_count += 1
for bucket in old_buckets:
for key, value in bucket:
self.put(key, value)
# Watch the table grow
ht = HashTable(capacity=4)
print(f"{'entries':>8} {'capacity':>9} {'load_factor':>12} {'resizes':>8}")
for i in range(20):
ht.put(f"key{i}", i)
lf = ht.size / ht.capacity
print(f"{ht.size:>8} {ht.capacity:>9} {lf:>12.2f} {ht._resize_count:>8}")
How Real Systems Use Hashing
Python dict: uses open addressing (not chaining) with a probing sequence. Keys must be hashable (immutable). Python randomises the hash seed per process run to prevent hash collision attacks (a security feature called hash randomisation).
Redis: stores all keys in a global hash table. Each database is one giant dict. Redis uses incremental rehashing — when it resizes, it migrates entries a few at a time instead of all at once, so no single operation is slow.
Blockchain: uses cryptographic hash functions (SHA-256) to link blocks. A change in any block changes its hash, which invalidates all subsequent hashes — making tampering detectable. This is a completely different use of hashing (for integrity, not lookup), but the core idea is the same: deterministic mapping from data to a fixed-size output.
Complexity Summary
| Operation | Average | Worst case |
|---|---|---|
| put | O(1) | O(n) |
| get | O(1) | O(n) |
| remove | O(1) | O(n) |
| resize | O(n) | O(n) |
The worst case occurs only when every key collides into the same bucket — which requires a genuinely terrible hash function or a deliberate attack. With a good hash function and a load factor below 0.75, average-case O(1) is reliable in practice.
Graphs
Google Maps, Facebook friends, the internet itself — all graphs. The most powerful data structure ever invented.
Every time your phone finds a route to a coffee shop, every time Instagram suggests someone you might know, every time you click a hyperlink — a graph algorithm is running behind the scenes. Graphs are everywhere, and once you learn to see them, you’ll spot them in everything.
What is a Graph?
A graph is a collection of vertices (also called nodes) connected by edges (also called links or connections).
- Vertex — a single entity (a city, a person, a webpage, a router)
- Edge — a relationship between two vertices (a road, a friendship, a hyperlink, a cable)
That’s it. Simple concept, infinite power.
Undirected Graphs
In an undirected graph, edges go both ways. If city A connects to city B, you can travel in either direction. Think of roads on a map.
graph LR
London["London"]
Paris["Paris"]
Berlin["Berlin"]
Madrid["Madrid"]
Rome["Rome"]
London --- Paris
London --- Berlin
Paris --- Madrid
Paris --- Rome
Berlin --- Rome
Every edge here is a two-way road. London connects to Paris, and Paris connects back to London.
Directed Graphs
In a directed graph (also called a digraph), edges have a direction — an arrow pointing one way. Think of Twitter follows: you can follow a celebrity without them following you back.
graph LR
Alice["Alice"]
Bob["Bob"]
Carol["Carol"]
Dave["Dave"]
Alice --> Bob
Alice --> Carol
Bob --> Dave
Carol --> Bob
Dave --> Alice
Here, Alice follows Bob and Carol. Bob follows Dave. Dave follows Alice back — but Carol does not follow Dave.
Weighted vs Unweighted
Edges can carry a weight — a number representing cost, distance, time, or strength.
- Unweighted: all edges are equal (friendships on Facebook)
- Weighted: each edge has a value (road distances, flight prices, signal strength)
In the city map above, imagine each edge also has a number: the distance in kilometres. That’s a weighted graph, and it’s how GPS navigation works.
Directed vs Undirected — Quick Reference
| Property | Undirected | Directed |
|---|---|---|
| Edge direction | Both ways | One way |
| Real example | Friendships, roads | Twitter follows, hyperlinks |
| Notation | A — B | A → B |
What’s Coming in This Section
| Topic | What You’ll Learn |
|---|---|
| Intro to Graphs | Adjacency matrix and adjacency list representations |
| Matrix DFS | Depth-first search on a 2D grid — flood fill and island counting |
| Matrix BFS | Breadth-first search on a 2D grid — shortest path guaranteed |
| Adjacency List | DFS and BFS on real graphs, cycle detection |
By the end of this section, you’ll be able to solve problems that stump most programmers — because most programmers never took the time to truly understand graphs.
Introduction to Graphs
Six degrees of separation — you’re connected to any person on Earth through at most 6 people. Your friend knows someone who knows someone… and within six hops you can reach the President, a rice farmer in Vietnam, or a shepherd in Mongolia. Graphs prove it, and they explain exactly why it works.
Core Vocabulary
Before writing any code, get these terms locked in — they come up everywhere:
| Term | Definition | Example |
|---|---|---|
| Vertex (node) | A single entity in the graph | A person, city, or webpage |
| Edge | A connection between two vertices | A friendship, road, or hyperlink |
| Path | A sequence of vertices connected by edges | London → Paris → Rome |
| Cycle | A path that starts and ends at the same vertex | A → B → C → A |
| Connected | Every vertex can reach every other vertex | A fully connected road network |
| Weighted | Edges carry a numeric value (cost, distance) | Road distances in km |
Real-World Graphs
Graphs are not just a textbook concept — they model the most important systems in the world:
- Social networks: people are vertices, friendships are edges. Facebook has ~3 billion vertices.
- Road maps: cities are vertices, roads are edges, distances are weights. This is exactly what Google Maps uses.
- Package managers: packages are vertices, dependencies are directed edges. When you run
pip install numpy, Python resolves a dependency graph. - The Web: web pages are vertices, hyperlinks are directed edges. Google’s PageRank algorithm ranks pages by analysing this graph.
Representing a Graph in Code
You’ve drawn the graph on paper. Now how do you store it in memory? Two main approaches:
Adjacency Matrix
A 2D grid where matrix[i][j] = 1 means there’s an edge from vertex i to vertex j.
Consider this graph — 4 people and their friendships:
graph LR
0["Alice (0)"]
1["Bob (1)"]
2["Carol (2)"]
3["Dave (3)"]
0 --- 1
0 --- 2
1 --- 3
2 --- 3
The adjacency matrix for this graph looks like:
Alice Bob Carol Dave
Alice [ 0, 1, 1, 0 ]
Bob [ 1, 0, 0, 1 ]
Carol [ 1, 0, 0, 1 ]
Dave [ 0, 1, 1, 0 ]
A 1 at position [row][col] means those two people are friends.
Adjacency List
A dictionary where each vertex maps to a list of its neighbours.
graph LR
A["Alice"] --> AL["[Bob, Carol]"]
B["Bob"] --> BL["[Alice, Dave]"]
C["Carol"] --> CL["[Alice, Dave]"]
D["Dave"] --> DL["[Bob, Carol]"]
For sparse graphs (where most vertices connect to only a few others), the adjacency list uses far less memory than a matrix.
Implementing Both in Python
# The same graph represented two ways
# 4 people: 0=Alice, 1=Bob, 2=Carol, 3=Dave
# Friendships: Alice-Bob, Alice-Carol, Bob-Dave, Carol-Dave
# --- Adjacency Matrix ---
adj_matrix = [
[0, 1, 1, 0], # Alice connects to Bob(1) and Carol(2)
[1, 0, 0, 1], # Bob connects to Alice(0) and Dave(3)
[1, 0, 0, 1], # Carol connects to Alice(0) and Dave(3)
[0, 1, 1, 0], # Dave connects to Bob(1) and Carol(2)
]
names = ["Alice", "Bob", "Carol", "Dave"]
print("=== Adjacency Matrix ===")
print(" ", " ".join(f"{n[:3]:>3}" for n in names))
for i, row in enumerate(adj_matrix):
print(f"{names[i][:5]:>5}", row)
# Check if Alice(0) and Bob(1) are friends
print(f"\nAre Alice and Bob friends? {bool(adj_matrix[0][1])}")
# Check if Alice(0) and Dave(3) are friends
print(f"Are Alice and Dave friends? {bool(adj_matrix[0][3])}")
# --- Adjacency List ---
adj_list = {
"Alice": ["Bob", "Carol"],
"Bob": ["Alice", "Dave"],
"Carol": ["Alice", "Dave"],
"Dave": ["Bob", "Carol"],
}
print("\n=== Adjacency List ===")
for person, friends in adj_list.items():
print(f" {person} -> {friends}")
# Check if Alice and Bob are friends
print(f"\nAre Alice and Bob friends? {'Bob' in adj_list['Alice']}")
# Check if Alice and Dave are friends
print(f"Are Alice and Dave friends? {'Dave' in adj_list['Alice']}")
Matrix vs List — When to Use Which
| Adjacency Matrix | Adjacency List | |
|---|---|---|
| Space | O(V²) — always allocates V×V | O(V + E) — only stores real edges |
| Check if edge exists | O(1) — instant lookup | O(degree) — scan neighbour list |
| List all neighbours | O(V) — scan entire row | O(degree) — direct access |
| Best for | Dense graphs (many edges) | Sparse graphs (few edges per node) |
Most real-world graphs are sparse. Facebook users average ~338 friends out of 3 billion possible connections. The adjacency list wins almost every time.
Building a Weighted Graph
For a road map with distances, just store the weight alongside the neighbour:
# Weighted adjacency list — road distances in km
road_map = {
"London": [("Paris", 344), ("Berlin", 932)],
"Paris": [("London", 344), ("Madrid", 1054), ("Rome", 1421)],
"Berlin": [("London", 932), ("Rome", 1181)],
"Madrid": [("Paris", 1054)],
"Rome": [("Paris", 1421), ("Berlin", 1181)],
}
print("Road map (weighted adjacency list):")
for city, connections in road_map.items():
for neighbour, distance in connections:
print(f" {city} --[{distance} km]--> {neighbour}")
# Find the shortest direct road from London
london_connections = road_map["London"]
nearest = min(london_connections, key=lambda x: x[1])
print(f"\nNearest city to London by direct road: {nearest[0]} ({nearest[1]} km)")
Now you have the foundations. The next sections will show you how to actually traverse these graphs — visiting every vertex systematically to find paths, count components, and solve real problems.
Matrix DFS
Think of it as flood-fill — the paint bucket tool in MS Paint uses this exact algorithm. Click on a white area and it spreads to every connected white pixel, stopping only at edges. That’s depth-first search on a grid.
Grids Are Graphs in Disguise
A 2D grid is just a graph where each cell is a vertex, and edges connect adjacent cells (up, down, left, right). The moment you see a grid problem, think: graph problem.
Here’s a simple grid — 0 is water, 1 is land:
1 1 0 0
1 0 0 1
0 0 1 1
0 1 1 1
An island is a group of 1 cells all connected horizontally or vertically. The grid above has 3 islands. Can you spot them?
graph TD
subgraph "Grid (row,col)"
A["(0,0) Land"] --- B["(0,1) Land"]
A --- C["(1,0) Land"]
D["(1,3) Land"] --- E["(2,2) Land"]
E --- F["(2,3) Land"]
F --- G["(3,3) Land"]
G --- H["(3,2) Land"]
H --- I["(3,1) Land"]
E --- G
end
Island 1: (0,0), (0,1), (1,0) — top-left cluster Island 2: (1,3), (2,2), (2,3), (3,1), (3,2), (3,3) — right side Island 3 would appear if we had an isolated cell — let’s count them with code.
How DFS Flood-Fill Works
Starting from a land cell, DFS explores as far as possible in one direction before backtracking — like exploring a maze by always turning left until you hit a wall.
The four neighbours of any cell (r, c):
- Up:
(r-1, c) - Down:
(r+1, c) - Left:
(r, c-1) - Right:
(r, c+1)
The rules:
- If the cell is out of bounds — stop.
- If the cell is water (
0) — stop. - If we’ve already visited this cell — stop.
- Otherwise: mark as visited, then recurse into all four neighbours.
Visualising DFS Traversal Order
Starting DFS from cell (0,0) on a small grid:
graph LR
S["Start (0,0)"] --> A["(1,0)"]
A --> B["Back to (0,0)"]
B --> C["(0,1)"]
C --> D["Dead end — all neighbours water or visited"]
D --> E["Backtrack to (0,0)"]
E --> F["Island size = 3"]
DFS goes deep first — it follows one path all the way to a dead end before trying another direction. This is very different from BFS, which explores level by level.
Counting All Islands
The classic grid DFS problem: count the number of distinct islands.
def count_islands(grid):
if not grid:
return 0
rows = len(grid)
cols = len(grid[0])
visited = set()
island_count = 0
def dfs(r, c):
# Stop conditions: out of bounds, water, or already visited
if (
r < 0 or r >= rows or
c < 0 or c >= cols or
grid[r][c] == 0 or
(r, c) in visited
):
return
# Mark this cell as visited
visited.add((r, c))
# Recurse into all four directions
dfs(r + 1, c) # down
dfs(r - 1, c) # up
dfs(r, c + 1) # right
dfs(r, c - 1) # left
for r in range(rows):
for c in range(cols):
# Found unvisited land — it's a new island!
if grid[r][c] == 1 and (r, c) not in visited:
dfs(r, c) # flood-fill the entire island
island_count += 1
return island_count
# Test it
grid = [
[1, 1, 0, 0],
[1, 0, 0, 1],
[0, 0, 1, 1],
[0, 1, 1, 1],
]
print("Grid:")
for row in grid:
print(" ", row)
result = count_islands(grid)
print(f"\nNumber of islands: {result}")
# Expected: 3
Measuring Island Size
Instead of counting islands, count the size of a single island:
def count_island_size(grid, start_r, start_c):
rows = len(grid)
cols = len(grid[0])
visited = set()
def dfs(r, c):
if (
r < 0 or r >= rows or
c < 0 or c >= cols or
grid[r][c] == 0 or
(r, c) in visited
):
return 0
visited.add((r, c))
# This cell counts as 1, plus all reachable neighbours
return (
1 +
dfs(r + 1, c) +
dfs(r - 1, c) +
dfs(r, c + 1) +
dfs(r, c - 1)
)
return dfs(start_r, start_c)
grid = [
[1, 1, 0, 0],
[1, 0, 0, 1],
[0, 0, 1, 1],
[0, 1, 1, 1],
]
# The top-left island starts at (0,0)
size = count_island_size(grid, 0, 0)
print(f"Island starting at (0,0) has size: {size}") # Expected: 3
# The bottom-right island starts at (1,3)
size2 = count_island_size(grid, 1, 3)
print(f"Island starting at (1,3) has size: {size2}") # Expected: 6
Finding the Largest Island
Combine both ideas — find the maximum island size:
def largest_island(grid):
rows = len(grid)
cols = len(grid[0])
visited = set()
max_size = 0
def dfs(r, c):
if (
r < 0 or r >= rows or
c < 0 or c >= cols or
grid[r][c] == 0 or
(r, c) in visited
):
return 0
visited.add((r, c))
return 1 + dfs(r+1,c) + dfs(r-1,c) + dfs(r,c+1) + dfs(r,c-1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1 and (r, c) not in visited:
size = dfs(r, c)
max_size = max(max_size, size)
return max_size
grid = [
[1, 1, 0, 0],
[1, 0, 0, 1],
[0, 0, 1, 1],
[0, 1, 1, 1],
]
print(f"Largest island size: {largest_island(grid)}") # Expected: 6
Time and Space Complexity
| Complexity | |
|---|---|
| Time | O(rows × cols) — each cell visited at most once |
| Space | O(rows × cols) — the visited set + recursion call stack |
Real-World Applications
- MS Paint flood fill — exactly this algorithm, on a pixel grid
- Maze solving — DFS explores every possible path until it finds the exit
- Connected regions in maps — satellite imagery analysis counts lakes, forests, urban areas
- Game development — finding connected game tiles (Minesweeper, Tetris boards)
- Medical imaging — identifying connected tissue regions in MRI scans
The key insight: any time you have a 2D grid where you need to explore connected regions, think DFS flood-fill.
Matrix BFS
Find the shortest path through a maze — BFS guarantees the shortest route. Not “a” short route. The absolute shortest. DFS will find some path; BFS finds the best one. That single guarantee makes BFS one of the most useful algorithms in existence.
The Key Insight: BFS Expands Like a Ripple
Imagine dropping a stone into still water. The ripple expands outward in all directions simultaneously, reaching every point at the same distance at the same time. That’s BFS.
DFS is like a rat in a maze — it charges down one corridor until it hits a dead end, then backtracks. Fast, but no guarantee of finding the shortest route.
BFS processes cells in order of their distance from the start. When it reaches the destination, it has taken the fewest possible steps.
The Grid Setup
0 = open path, 1 = wall. Goal: get from top-left (0,0) to bottom-right (2,2).
Start
↓
[0, 0, 1]
[1, 0, 1]
[0, 0, 0]
↑
End
BFS Expanding Level by Level
graph TD
S["(0,0) Distance=0"]
S --> A["(0,1) Distance=1"]
A --> B["(1,1) Distance=2"]
B --> C["(2,1) Distance=3"]
C --> D["(2,2) Distance=4 — GOAL!"]
C --> E["(2,0) Distance=4"]
BFS explores all cells at distance 1 before any cell at distance 2, and all cells at distance 2 before any at distance 3. The moment it reaches the destination, it’s guaranteed to have taken the minimum number of steps.
Implementing BFS Shortest Path
from collections import deque
def shortest_path(grid):
rows = len(grid)
cols = len(grid[0])
# Can't start or end on a wall
if grid[0][0] == 1 or grid[rows-1][cols-1] == 1:
return -1
# Queue stores (row, col, distance_so_far)
queue = deque([(0, 0, 0)])
visited = {(0, 0)}
directions = [(1, 0), (-1, 0), (0, 1), (0, -1)] # down, up, right, left
while queue:
r, c, dist = queue.popleft()
# Reached the destination!
if (r, c) == (rows - 1, cols - 1):
return dist
for dr, dc in directions:
nr, nc = r + dr, c + dc
if (
0 <= nr < rows and # within bounds (rows)
0 <= nc < cols and # within bounds (cols)
grid[nr][nc] == 0 and # not a wall
(nr, nc) not in visited # not already visited
):
visited.add((nr, nc))
queue.append((nr, nc, dist + 1))
return -1 # No path exists
# Test 1: a solvable maze
grid1 = [
[0, 0, 1],
[1, 0, 1],
[0, 0, 0],
]
print(f"Shortest path (grid1): {shortest_path(grid1)} steps") # Expected: 4
# Test 2: an unsolvable maze (walls block every route)
grid2 = [
[0, 1, 0],
[1, 1, 0],
[0, 0, 0],
]
print(f"Shortest path (grid2): {shortest_path(grid2)} steps") # Expected: -1
# Test 3: a wide-open grid
grid3 = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
]
print(f"Shortest path (grid3): {shortest_path(grid3)} steps") # Expected: 4
BFS vs DFS — Why DFS Cannot Guarantee Shortest Path
This is the most important thing to understand. Let’s prove it with a concrete example:
# Watch DFS and BFS find paths through the same grid
# and compare the results
def bfs_path(grid):
"""BFS — always finds shortest path"""
rows, cols = len(grid), len(grid[0])
queue = deque([(0, 0, [(0, 0)])]) # (r, c, path_so_far)
visited = {(0, 0)}
while queue:
r, c, path = queue.popleft()
if (r, c) == (rows-1, cols-1):
return path
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r+dr, c+dc
if 0<=nr<rows and 0<=nc<cols and grid[nr][nc]==0 and (nr,nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc, path + [(nr, nc)]))
return []
def dfs_path(grid):
"""DFS — finds A path, not necessarily the shortest"""
rows, cols = len(grid), len(grid[0])
visited = set()
result = []
def dfs(r, c, path):
if (r,c) == (rows-1, cols-1):
result.append(path[:])
return
if r<0 or r>=rows or c<0 or c>=cols or grid[r][c]==1 or (r,c) in visited:
return
visited.add((r, c))
# DFS tries down first, which may lead to a longer path
for dr, dc in [(1,0),(0,1),(-1,0),(0,-1)]:
dfs(r+dr, c+dc, path + [(r+dr, c+dc)])
visited.remove((r, c))
dfs(0, 0, [(0, 0)])
# Return the first path DFS found (not necessarily shortest)
return result[0] if result else []
from collections import deque
# A grid where DFS will find a longer path than BFS
grid = [
[0, 0, 0, 0],
[0, 1, 1, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]
bfs_result = bfs_path(grid)
dfs_result = dfs_path(grid)
print(f"BFS path length: {len(bfs_result)-1} steps")
print(f"BFS path: {bfs_result}")
print()
print(f"DFS path length: {len(dfs_result)-1} steps")
print(f"DFS path: {dfs_result}")
print()
print(f"BFS finds shortest? {'YES' if len(bfs_result) <= len(dfs_result) else 'NO'}")
Tracking the Actual Path (Not Just Distance)
Sometimes you need the path itself, not just the length. Store the path in the queue:
from collections import deque
def shortest_path_with_route(grid):
rows, cols = len(grid), len(grid[0])
if grid[0][0] == 1 or grid[rows-1][cols-1] == 1:
return None
# Store (row, col, path) — path is a list of coordinates
queue = deque([(0, 0, [(0, 0)])])
visited = {(0, 0)}
while queue:
r, c, path = queue.popleft()
if (r, c) == (rows-1, cols-1):
return path # Return the full path!
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r+dr, c+dc
if 0<=nr<rows and 0<=nc<cols and grid[nr][nc]==0 and (nr,nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc, path + [(nr, nc)]))
return None
grid = [
[0, 0, 1, 0],
[1, 0, 1, 0],
[1, 0, 0, 0],
[1, 1, 0, 0],
]
route = shortest_path_with_route(grid)
if route:
print(f"Shortest path has {len(route)-1} steps:")
for step, (r, c) in enumerate(route):
print(f" Step {step}: row={r}, col={c}")
else:
print("No path exists")
Time and Space Complexity
| Complexity | |
|---|---|
| Time | O(rows × cols) — each cell visited at most once |
| Space | O(rows × cols) — the queue can hold every cell in the worst case |
Real-World Applications
- GPS navigation — finding the shortest route between two points (weighted BFS becomes Dijkstra’s algorithm)
- Game pathfinding — enemies finding the shortest route to the player (Pac-Man, Tower Defence)
- Network routing — finding the fewest hops between two routers on the internet
- COVID contact tracing radius — BFS finds everyone within N contacts of an infected person
- Social networks — LinkedIn’s “2nd degree connections” uses BFS up to depth 2
The rule: whenever you see “shortest path”, “minimum steps”, or “fewest moves” in a grid problem — reach for BFS.
Adjacency List — DFS and BFS
Real graphs are sparse — most nodes connect to only a few others. Your Facebook profile has ~338 friends out of 3 billion users. A webpage links to ~40 pages out of billions. Adjacency lists are built for this: they only store connections that actually exist, making them memory-efficient and fast for real-world graph problems.
A Social Network as a Graph
Let’s model a small friend network. Each person is a vertex, each friendship is an undirected edge.
graph LR
Alice --- Bob
Alice --- Carol
Bob --- Dave
Carol --- Dave
Carol --- Eve
Dave --- Eve
As an adjacency list:
graph = {
"Alice": ["Bob", "Carol"],
"Bob": ["Alice", "Dave"],
"Carol": ["Alice", "Dave", "Eve"],
"Dave": ["Bob", "Carol", "Eve"],
"Eve": ["Carol", "Dave"],
}
This is the starting point for almost every graph problem you’ll encounter.
DFS on an Adjacency List (Iterative)
DFS explores as deeply as possible before backtracking. Use a stack — last in, first out.
def dfs(graph, start):
visited = set()
stack = [start]
order = []
while stack:
node = stack.pop() # Take from the top of the stack
if node in visited:
continue
visited.add(node)
order.append(node)
# Push all unvisited neighbours onto the stack
for neighbour in graph[node]:
if neighbour not in visited:
stack.append(neighbour)
return order
graph = {
"Alice": ["Bob", "Carol"],
"Bob": ["Alice", "Dave"],
"Carol": ["Alice", "Dave", "Eve"],
"Dave": ["Bob", "Carol", "Eve"],
"Eve": ["Carol", "Dave"],
}
result = dfs(graph, "Alice")
print(f"DFS from Alice: {result}")
# DFS also works for checking reachability
def can_reach(graph, start, target):
visited = set()
stack = [start]
while stack:
node = stack.pop()
if node == target:
return True
if node in visited:
continue
visited.add(node)
for neighbour in graph[node]:
if neighbour not in visited:
stack.append(neighbour)
return False
print(f"Can Alice reach Eve? {can_reach(graph, 'Alice', 'Eve')}")
print(f"Can Alice reach herself? {can_reach(graph, 'Alice', 'Alice')}")
BFS on an Adjacency List (Iterative)
BFS explores level by level — all direct friends, then friends-of-friends, and so on. Use a queue — first in, first out. This finds shortest paths in unweighted graphs.
from collections import deque
def bfs(graph, start):
visited = set([start])
queue = deque([start])
order = []
while queue:
node = queue.popleft() # Take from the front of the queue
order.append(node)
for neighbour in graph[node]:
if neighbour not in visited:
visited.add(neighbour)
queue.append(neighbour)
return order
def bfs_shortest_path(graph, start, target):
"""Find shortest path (fewest hops) between two nodes"""
visited = set([start])
# Queue stores (current_node, path_to_here)
queue = deque([(start, [start])])
while queue:
node, path = queue.popleft()
if node == target:
return path
for neighbour in graph[node]:
if neighbour not in visited:
visited.add(neighbour)
queue.append((neighbour, path + [neighbour]))
return [] # No path found
graph = {
"Alice": ["Bob", "Carol"],
"Bob": ["Alice", "Dave"],
"Carol": ["Alice", "Dave", "Eve"],
"Dave": ["Bob", "Carol", "Eve"],
"Eve": ["Carol", "Dave"],
}
print(f"BFS from Alice: {bfs(graph, 'Alice')}")
path = bfs_shortest_path(graph, "Alice", "Eve")
print(f"Shortest path Alice → Eve: {path}")
print(f"Degrees of separation: {len(path) - 1}")
Cycle Detection Using a Visited Set
A cycle is a path that loops back to itself: A → B → C → A. Detecting cycles is critical for dependency resolution — if package A needs B, B needs C, and C needs A, you have a circular dependency and nothing can ever install.
For an undirected graph, a cycle exists if DFS visits a node that’s already in the visited set (but not the immediate parent — since every undirected edge looks like a “back edge” to the parent).
def has_cycle(graph):
"""Detect if an undirected graph contains a cycle"""
visited = set()
def dfs(node, parent):
visited.add(node)
for neighbour in graph[node]:
if neighbour not in visited:
if dfs(neighbour, node): # Recurse
return True
elif neighbour != parent:
# We've visited this neighbour, and it's NOT our direct parent
# That means we found a back edge — a cycle!
return True
return False
# Run DFS from each unvisited node (handles disconnected graphs)
for node in graph:
if node not in visited:
if dfs(node, None):
return True
return False
# Graph WITH a cycle: Alice - Bob - Dave - Carol - Alice
cyclic_graph = {
"Alice": ["Bob", "Carol"],
"Bob": ["Alice", "Dave"],
"Carol": ["Alice", "Dave"],
"Dave": ["Bob", "Carol"],
}
# Graph WITHOUT a cycle: a simple tree
acyclic_graph = {
"Alice": ["Bob", "Carol"],
"Bob": ["Alice"],
"Carol": ["Alice", "Dave"],
"Dave": ["Carol"],
}
print(f"Cyclic graph has cycle: {has_cycle(cyclic_graph)}") # True
print(f"Acyclic graph has cycle: {has_cycle(acyclic_graph)}") # False
Putting It All Together — A Web Crawler
Web crawling is graph traversal: pages are vertices, hyperlinks are directed edges. BFS finds all pages reachable within N hops.
from collections import deque
def web_crawler(graph, start, max_depth=2):
"""
Crawl a web graph using BFS up to max_depth hops.
Returns all pages reachable within max_depth clicks.
"""
# Queue stores (page, depth)
visited = set([start])
queue = deque([(start, 0)])
pages_found = {0: [start], 1: [], 2: []}
while queue:
page, depth = queue.popleft()
if depth >= max_depth:
continue
for linked_page in graph.get(page, []):
if linked_page not in visited:
visited.add(linked_page)
queue.append((linked_page, depth + 1))
if depth + 1 <= max_depth:
pages_found[depth + 1].append(linked_page)
return pages_found
# Simulated web graph (directed — links only go one way)
web = {
"homepage": ["about", "blog", "shop"],
"about": ["homepage", "team"],
"blog": ["homepage", "post1", "post2"],
"shop": ["homepage", "product1", "cart"],
"team": ["about"],
"post1": ["blog", "post2"],
"post2": ["blog"],
"product1": ["shop", "cart"],
"cart": ["shop", "checkout"],
"checkout": ["cart"],
}
pages = web_crawler(web, "homepage", max_depth=2)
print("Pages reachable from homepage:")
for depth, page_list in sorted(pages.items()):
label = "Start" if depth == 0 else f"{depth} click{'s' if depth > 1 else ''} away"
print(f" {label}: {page_list}")
DFS vs BFS — Quick Reference
| DFS (Stack) | BFS (Queue) | |
|---|---|---|
| Data structure | Stack (LIFO) | Queue (FIFO) |
| Traversal style | Goes deep first | Goes wide first |
| Finds shortest path? | No | Yes (unweighted) |
| Use for | Cycle detection, connected components, topological sort | Shortest path, level-order traversal, nearest neighbour |
| Memory (sparse graph) | O(depth) | O(breadth) |
Real-World Applications
- Social network friend recommendations — BFS finds people 2 hops away (“People You May Know”)
- Package dependency resolution — DFS with cycle detection (used by
pip,npm,cargo) - Web crawlers — BFS indexes the web layer by layer (Google’s original crawler)
- Network analysis — finding all devices reachable from a router
- Git history — commits are a directed acyclic graph;
git logis a graph traversal
The adjacency list plus DFS/BFS is the foundation for nearly every advanced graph algorithm — Dijkstra’s, Bellman-Ford, topological sort, and strongly connected components all build on these exact concepts.
Dijkstra’s Algorithm
Google Maps finds the fastest route between any two cities in milliseconds. It handles millions of roads, traffic conditions, and detours — and it still gives you an answer before you’ve buckled your seatbelt. The core idea powering that feat: Dijkstra’s algorithm.
The Problem: Shortest Path in a Weighted Graph
In an unweighted graph, “shortest path” just means fewest edges. But real roads have distances. Real networks have latency. Real flight routes have costs. Once edges carry weights, BFS no longer works — a path with two long edges can be worse than one with five short ones.
Dijkstra’s algorithm finds the shortest path from a single source vertex to every other vertex in a weighted graph, as long as all edge weights are non-negative.
The City Map
Imagine you’re a delivery driver starting at city A. You want the cheapest route to every other city.
graph LR
A -->|4| B
A -->|2| C
B -->|3| D
B -->|1| C
C -->|5| B
C -->|8| D
C -->|10| E
D -->|2| E
B -->|6| E
Each number is the travel cost (distance, time, fuel — pick your metaphor).
The Core Idea: Greedy + Priority Queue
Dijkstra’s is a greedy algorithm. At every step it asks: “Which unvisited city do I know the cheapest path to right now?” It processes that city next, then updates costs for its neighbours.
The insight is that once you’ve settled on the cheapest path to a city, you can never find a cheaper one later — because all weights are non-negative. This means you never need to revisit a settled node.
The Algorithm
- Set
dist[source] = 0,dist[all others] = infinity. - Push
(0, source)into a min-heap (priority queue). - Pop the node with the smallest distance.
- For each neighbour, if
dist[current] + edge_weight < dist[neighbour], updatedist[neighbour]and push to the heap. - Repeat until the heap is empty.
Step-by-Step Trace
Starting from A:
| Step | Process | A | B | C | D | E |
|---|---|---|---|---|---|---|
| Init | — | 0 | ∞ | ∞ | ∞ | ∞ |
| 1 | Pop A (0) | 0 | 4 | 2 | ∞ | ∞ |
| 2 | Pop C (2) | 0 | 4→3 | 2 | 10 | 12 |
| 3 | Pop B (3) | 0 | 3 | 2 | 6 | 9 |
| 4 | Pop D (6) | 0 | 3 | 2 | 6 | 9→8 |
| 5 | Pop E (8) | 0 | 3 | 2 | 6 | 8 |
Final shortest distances from A: B=3, C=2, D=6, E=8.
Notice step 2: when we process C, we find that going A→C→B costs 2+1=3, which beats the direct A→B cost of 4. The algorithm catches this because we re-check neighbours whenever we find a cheaper path.
Implementation
import heapq
def dijkstra(graph, source):
# graph: { node: [(neighbour, weight), ...] }
dist = {node: float('inf') for node in graph}
dist[source] = 0
prev = {node: None for node in graph}
# min-heap entries: (distance, node)
heap = [(0, source)]
visited = set()
while heap:
current_dist, current = heapq.heappop(heap)
# Skip if we already found a shorter path to this node
if current in visited:
continue
visited.add(current)
for neighbour, weight in graph[current]:
new_dist = current_dist + weight
if new_dist < dist[neighbour]:
dist[neighbour] = new_dist
prev[neighbour] = current
heapq.heappush(heap, (new_dist, neighbour))
return dist, prev
def reconstruct_path(prev, source, target):
path = []
node = target
while node is not None:
path.append(node)
node = prev[node]
path.reverse()
if path[0] == source:
return path
return [] # no path exists
# City map from the diagram above
graph = {
'A': [('B', 4), ('C', 2)],
'B': [('D', 3), ('C', 1), ('E', 6)],
'C': [('B', 5), ('D', 8), ('E', 10)],
'D': [('E', 2)],
'E': [],
}
distances, prev = dijkstra(graph, 'A')
print("Shortest distances from A:")
for city, d in sorted(distances.items()):
print(f" A -> {city}: {d}")
print()
print("Shortest paths:")
for city in sorted(graph.keys()):
path = reconstruct_path(prev, 'A', city)
print(f" A -> {city}: {' -> '.join(path)} (cost: {distances[city]})")
Why heapq is Essential
Without a priority queue, finding the next unvisited minimum-distance node would cost O(V) per step, making the total O(V²). With a binary min-heap, each push/pop is O(log V), bringing the overall complexity to O((V + E) log V) — a massive win for sparse graphs.
Handling the “Lazy Deletion” Pattern
You’ll notice the if current in visited: continue check. When we find a better path to a node, we push a new entry into the heap rather than updating the old one (Python’s heapq doesn’t support efficient decrease-key). This means stale entries may linger in the heap. The visited check discards them cheaply.
What Dijkstra’s Cannot Do
Dijkstra’s breaks with negative edge weights. Consider: if an edge has weight -5, processing a neighbour earlier doesn’t guarantee the shortest path — a later route through the negative edge might be cheaper. For graphs with negative weights, use Bellman-Ford instead.
Complexity Analysis
| Complexity | |
|---|---|
| Time (binary heap) | O((V + E) log V) |
| Time (Fibonacci heap) | O(E + V log V) — theoretical optimum |
| Space | O(V + E) |
Where V = number of vertices, E = number of edges.
For dense graphs (E ≈ V²), the simpler O(V²) array-based version can actually outperform the heap version. In practice, the heap version is standard.
A Larger Example: Finding One Specific Route
import heapq
def dijkstra(graph, source):
dist = {node: float('inf') for node in graph}
dist[source] = 0
prev = {node: None for node in graph}
heap = [(0, source)]
visited = set()
while heap:
current_dist, current = heapq.heappop(heap)
if current in visited:
continue
visited.add(current)
for neighbour, weight in graph[current]:
new_dist = current_dist + weight
if new_dist < dist[neighbour]:
dist[neighbour] = new_dist
prev[neighbour] = current
heapq.heappush(heap, (new_dist, neighbour))
return dist, prev
# Airline route network (flight times in minutes)
flights = {
'Sydney': [('Melbourne', 90), ('Brisbane', 100), ('Dubai', 840)],
'Melbourne': [('Sydney', 90), ('Singapore', 480)],
'Brisbane': [('Sydney', 100), ('Singapore', 420)],
'Singapore': [('Melbourne', 480), ('Brisbane', 420), ('London', 780), ('Dubai', 420)],
'Dubai': [('Sydney', 840), ('Singapore', 420), ('London', 420)],
'London': [('Singapore', 780), ('Dubai', 420)],
}
dist, prev = dijkstra(flights, 'Sydney')
# Reconstruct path to London
path = []
node = 'London'
while node:
path.append(node)
node = prev[node]
path.reverse()
print(f"Fastest route Sydney -> London: {dist['London']} minutes")
print(f"Route: {' -> '.join(path)}")
print()
print("All routes from Sydney:")
for city, d in sorted(dist.items(), key=lambda x: x[1]):
print(f" {city}: {d} min")
Real-World Applications
| Domain | Application |
|---|---|
| GPS Navigation | Google Maps, Apple Maps, Waze — find fastest/shortest route |
| Network Routing | OSPF protocol routes internet packets through the cheapest path |
| Game Pathfinding | A* algorithm is Dijkstra’s with a heuristic added for speed |
| Airline Optimisation | Minimum-cost flight connections between hubs |
| Social Networks | Degrees of separation (with uniform weights = BFS, weighted = Dijkstra) |
| Robot Navigation | Autonomous vehicles planning routes through a cost map |
Dijkstra vs BFS vs A*
- BFS: unweighted graphs only — finds fewest hops
- Dijkstra: weighted graphs, no negative weights — finds cheapest cost
- A*: Dijkstra + heuristic — finds cheapest cost faster when you have a goal
Key Takeaways
- Dijkstra’s finds the shortest path from one source to all vertices in a weighted graph.
- It’s greedy: always process the cheapest unvisited node next.
- A min-heap (priority queue) is what makes it efficient — O((V + E) log V).
- It requires non-negative edge weights. Use Bellman-Ford for negative weights.
- Path reconstruction works by storing a
prevpointer at each node and tracing backwards.
Prim’s Algorithm
An ISP needs to connect 10 cities with fibre-optic cable. They can’t afford to lay cable on every possible route — they just need every city to be reachable from every other city, using the least total cable. Prim’s algorithm finds exactly that minimum-cost network.
What is a Minimum Spanning Tree?
A spanning tree of a graph is a subgraph that:
- Connects all vertices (it “spans” the graph)
- Has no cycles (it’s a tree)
- Has exactly V - 1 edges for V vertices
A Minimum Spanning Tree (MST) is the spanning tree with the smallest possible total edge weight.
graph LR
A -->|4| B
A -->|2| C
B -->|3| D
B -->|1| C
C -->|5| D
C -->|6| E
D -->|7| E
B -->|8| E
The MST of this graph uses edges: C-B(1), A-C(2), B-D(3), C-E(6) — total weight 12.
Notice: we skip A-B(4), C-D(5), D-E(7), B-E(8) because they’d either create cycles or cost more.
Prim’s Approach: Grow the Tree Greedily
Prim’s algorithm starts from any vertex and grows the MST one edge at a time:
- Start with any vertex in the tree (the “visited” set).
- Look at all edges that cross from the visited set to the unvisited set.
- Pick the cheapest such edge.
- Add the new vertex to the visited set.
- Repeat until all vertices are in the tree.
Think of it like spreading ink on paper: you start at one point, and the ink always flows along the cheapest available channel to reach uncoloured territory.
Step-by-Step Trace
Starting from vertex A:
graph LR
A -->|4| B
A -->|2| C
B -->|3| D
B -->|1| C
C -->|5| D
C -->|6| E
D -->|7| E
B -->|8| E
| Step | Visited | Candidate Edges | Chosen |
|---|---|---|---|
| Start | {A} | A-B(4), A-C(2) | A-C(2) |
| 1 | {A, C} | A-B(4), C-B(1), C-D(5), C-E(6) | C-B(1) |
| 2 | {A, C, B} | A-B(4)*, B-D(3), B-E(8), C-D(5), C-E(6) | B-D(3) |
| 3 | {A, C, B, D} | B-E(8), C-E(6), D-E(7) | C-E(6) |
| 4 | {A, C, B, D, E} | — | Done ✓ |
*A-B is skipped because B is already in the tree — adding it would create a cycle.
MST edges: A-C(2), C-B(1), B-D(3), C-E(6) → Total: 12
Implementation
import heapq
def prims(graph, start):
"""
graph: { node: [(neighbour, weight), ...] }
Returns: list of MST edges, total MST weight
"""
visited = set()
mst_edges = []
total_weight = 0
# Min-heap: (weight, from_node, to_node)
heap = [(0, start, start)]
while heap and len(visited) < len(graph):
weight, from_node, to_node = heapq.heappop(heap)
if to_node in visited:
continue
visited.add(to_node)
total_weight += weight
# Don't record the initial dummy edge (start -> start, weight 0)
if from_node != to_node:
mst_edges.append((from_node, to_node, weight))
# Explore neighbours of the newly added vertex
for neighbour, edge_weight in graph[to_node]:
if neighbour not in visited:
heapq.heappush(heap, (edge_weight, to_node, neighbour))
return mst_edges, total_weight
# The graph from the diagram
graph = {
'A': [('B', 4), ('C', 2)],
'B': [('A', 4), ('C', 1), ('D', 3), ('E', 8)],
'C': [('A', 2), ('B', 1), ('D', 5), ('E', 6)],
'D': [('B', 3), ('C', 5), ('E', 7)],
'E': [('C', 6), ('D', 7), ('B', 8)],
}
mst_edges, total = prims(graph, 'A')
print("Minimum Spanning Tree edges:")
for u, v, w in mst_edges:
print(f" {u} -- {v} (weight: {w})")
print(f"\nTotal MST weight: {total}")
print(f"Number of edges: {len(mst_edges)} (should be V-1 = {len(graph)-1})")
A Real-World Example: Connecting Cities with Fibre
import heapq
def prims(graph, start):
visited = set()
mst_edges = []
total_weight = 0
heap = [(0, start, start)]
while heap and len(visited) < len(graph):
weight, from_node, to_node = heapq.heappop(heap)
if to_node in visited:
continue
visited.add(to_node)
total_weight += weight
if from_node != to_node:
mst_edges.append((from_node, to_node, weight))
for neighbour, edge_weight in graph[to_node]:
if neighbour not in visited:
heapq.heappush(heap, (edge_weight, to_node, neighbour))
return mst_edges, total_weight
# Fibre cable costs (km) between Australian cities
cities = {
'Sydney': [('Canberra', 286), ('Melbourne', 878), ('Brisbane', 924)],
'Canberra': [('Sydney', 286), ('Melbourne', 654)],
'Melbourne': [('Sydney', 878), ('Canberra', 654), ('Adelaide', 727)],
'Brisbane': [('Sydney', 924), ('GoldCoast', 78)],
'GoldCoast': [('Brisbane', 78), ('Sydney', 840)],
'Adelaide': [('Melbourne', 727), ('Perth', 2706)],
'Perth': [('Adelaide', 2706)],
}
mst, total_km = prims(cities, 'Sydney')
print("Minimum fibre network layout:")
for city1, city2, km in mst:
print(f" {city1} <-> {city2}: {km} km")
print(f"\nTotal cable needed: {total_km} km")
all_cities = list(cities.keys())
print(f"\nVerification: {len(mst)} edges for {len(all_cities)} cities (need {len(all_cities)-1})")
How the Min-Heap Makes This Efficient
At each step, we need the cheapest edge crossing from visited to unvisited territory. A naive scan through all edges is O(V²). With a min-heap:
- Pushing a new edge candidate: O(log E)
- Popping the minimum: O(log E)
- Total across all edges: O(E log E), which simplifies to O(E log V)
Complexity Analysis
| Complexity | |
|---|---|
| Time (binary heap) | O(E log V) |
| Time (Fibonacci heap) | O(E + V log V) — theoretical optimum |
| Time (simple array) | O(V²) — better for very dense graphs |
| Space | O(V + E) |
Prim’s vs Kruskal’s
Both algorithms find the MST, but they approach it differently:
| Prim’s | Kruskal’s | |
|---|---|---|
| Strategy | Grow one connected tree from a start node | Sort all edges, add non-cycle edges greedily |
| Data structure | Min-heap + visited set | Union-Find |
| Best for | Dense graphs (many edges) | Sparse graphs (few edges) |
| Starting point | Requires a start vertex | Works globally on all edges |
| Connectivity | Works only on connected graphs | Naturally handles disconnected graphs (gives a forest) |
| Implementation | Similar to Dijkstra’s | Requires Union-Find |
The crossover point is roughly E ≈ V log V. For dense graphs (E close to V²), Prim’s O(V²) array version beats Kruskal’s O(E log E). For sparse graphs, Kruskal’s is simpler and fast.
Edge Case: Disconnected Graphs
If the graph is disconnected, Prim’s will only span the component containing the start node. You can detect this by checking len(visited) < len(graph) after the algorithm completes.
import heapq
def prims(graph, start):
visited = set()
mst_edges = []
total_weight = 0
heap = [(0, start, start)]
while heap and len(visited) < len(graph):
weight, from_node, to_node = heapq.heappop(heap)
if to_node in visited:
continue
visited.add(to_node)
total_weight += weight
if from_node != to_node:
mst_edges.append((from_node, to_node, weight))
for neighbour, edge_weight in graph[to_node]:
if neighbour not in visited:
heapq.heappush(heap, (edge_weight, to_node, neighbour))
if len(visited) < len(graph):
missing = set(graph.keys()) - visited
print(f"Warning: graph is disconnected! Cannot reach: {missing}")
return None, None
return mst_edges, total_weight
# Disconnected graph: islands A-B-C and D-E with no bridge
disconnected = {
'A': [('B', 2), ('C', 5)],
'B': [('A', 2), ('C', 3)],
'C': [('A', 5), ('B', 3)],
'D': [('E', 1)],
'E': [('D', 1)],
}
result, weight = prims(disconnected, 'A')
if result is None:
print("No MST exists for the full graph.")
Real-World Applications
| Domain | Application |
|---|---|
| Telecommunications | Minimum cable to connect all buildings/cities in a network |
| Electrical Grid | Minimum wire to connect all substations |
| Water Distribution | Minimum pipe to connect all households |
| Road Networks | Minimum road construction to connect all towns |
| Machine Learning | Single-linkage clustering uses MST structure |
| Image Segmentation | MST-based methods for grouping similar pixels |
Key Takeaways
- Prim’s finds the Minimum Spanning Tree — the lowest-weight set of edges that connects all vertices.
- It’s greedy: always add the cheapest edge from the current tree to an unvisited vertex.
- Uses a min-heap for O(E log V) efficiency — structurally identical to Dijkstra’s.
- Best suited for dense graphs; use Kruskal’s for sparse graphs.
- If the graph is disconnected, Prim’s will only span one component.
Kruskal’s Algorithm
Imagine you’re planning roads between villages. Sort all possible roads by length, shortest first. Now go through the list: build each road, but skip any road that would create a loop (because loops are wasteful — you’ve already connected those villages another way). Stop when every village is connected. That’s Kruskal’s algorithm — elegantly simple, powerfully effective.
The Problem Recap: Minimum Spanning Tree
Like Prim’s algorithm, Kruskal’s solves the Minimum Spanning Tree (MST) problem: find the subset of edges that connects all vertices with the minimum total weight and no cycles.
The difference is in perspective:
- Prim’s thinks vertex-first: grow one connected tree outward.
- Kruskal’s thinks edge-first: greedily pick the cheapest edges globally.
The Graph
graph LR
A -->|4| B
A -->|2| C
B -->|3| D
B -->|1| C
C -->|5| D
C -->|6| E
D -->|7| E
B -->|8| E
All edges sorted by weight:
| Edge | Weight | Add? |
|---|---|---|
| B-C | 1 | Yes — connects B and C |
| A-C | 2 | Yes — connects A to {B,C} |
| B-D | 3 | Yes — connects D to {A,B,C} |
| A-B | 4 | Skip — A and B already connected |
| C-D | 5 | Skip — C and D already connected |
| C-E | 6 | Yes — connects E to {A,B,C,D} |
| D-E | 7 | Skip — D and E already connected |
| B-E | 8 | Skip — B and E already connected |
MST edges: B-C(1), A-C(2), B-D(3), C-E(6) → Total: 12
The Key Dependency: Union-Find
How do we efficiently check “would this edge create a cycle?” The answer is Union-Find (also called Disjoint Set Union).
Union-Find tracks which vertices are in the same connected component. Before adding an edge, we check: are both endpoints already in the same component? If yes — skip (it’s a cycle). If no — add the edge and merge the two components.
See the Union-Find tutorial for a full explanation. Here’s the key interface we need:
find(x)— returns the root/representative of x’s componentunion(x, y)— merges the components of x and y; returnsFalseif they’re already in the same component (would create a cycle)
Implementation
class UnionFind:
def __init__(self, nodes):
# Each node starts as its own parent (its own component)
self.parent = {node: node for node in nodes}
self.rank = {node: 0 for node in nodes}
def find(self, x):
# Path compression: flatten the tree while finding root
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False # Already in same component — adding this edge creates a cycle
# Union by rank: attach smaller tree under larger tree
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
return True # Successfully merged two components
def kruskals(vertices, edges):
"""
vertices: list of vertex names
edges: list of (weight, u, v) tuples
Returns: MST edge list, total weight
"""
uf = UnionFind(vertices)
mst_edges = []
total_weight = 0
# Step 1: Sort all edges by weight
sorted_edges = sorted(edges)
# Step 2: Greedily add edges that don't create cycles
for weight, u, v in sorted_edges:
if uf.union(u, v):
mst_edges.append((u, v, weight))
total_weight += weight
# MST is complete when we have V-1 edges
if len(mst_edges) == len(vertices) - 1:
break
return mst_edges, total_weight
# Graph from the diagram
vertices = ['A', 'B', 'C', 'D', 'E']
edges = [
(4, 'A', 'B'),
(2, 'A', 'C'),
(3, 'B', 'D'),
(1, 'B', 'C'),
(5, 'C', 'D'),
(6, 'C', 'E'),
(7, 'D', 'E'),
(8, 'B', 'E'),
]
mst, total = kruskals(vertices, edges)
print("Kruskal's MST:")
print(f"{'Edge':<12} {'Weight':>6}")
print("-" * 20)
for u, v, w in mst:
print(f"{u} -- {v} {w:>4}")
print("-" * 20)
print(f"{'Total':>17} {total:>4}")
print(f"\nVerification: {len(mst)} edges for {len(vertices)} vertices (need {len(vertices)-1})")
Step-by-Step Trace with Union-Find State
class UnionFind:
def __init__(self, nodes):
self.parent = {node: node for node in nodes}
self.rank = {node: 0 for node in nodes}
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
return True
def components(self):
# Group nodes by their root
groups = {}
for node in self.parent:
root = self.find(node)
groups.setdefault(root, []).append(node)
return [sorted(group) for group in groups.values()]
def kruskals_verbose(vertices, edges):
uf = UnionFind(vertices)
mst_edges = []
total_weight = 0
sorted_edges = sorted(edges)
print(f"{'Edge':<10} {'Weight':>6} {'Action':<30} Components")
print("-" * 75)
for weight, u, v in sorted_edges:
merged = uf.union(u, v)
if merged:
mst_edges.append((u, v, weight))
total_weight += weight
action = f"ADD -> MST cost now {total_weight}"
else:
action = "SKIP (would create cycle)"
components = uf.components()
comp_str = ', '.join(str(c) for c in components)
print(f"{u}-{v:<8} {weight:>5} {action:<30} {comp_str}")
if len(mst_edges) == len(vertices) - 1:
break
print(f"\nFinal MST weight: {total_weight}")
return mst_edges, total_weight
vertices = ['A', 'B', 'C', 'D', 'E']
edges = [
(4, 'A', 'B'), (2, 'A', 'C'), (3, 'B', 'D'),
(1, 'B', 'C'), (5, 'C', 'D'), (6, 'C', 'E'),
(7, 'D', 'E'), (8, 'B', 'E'),
]
mst, total = kruskals_verbose(vertices, edges)
Complexity Analysis
| Complexity | |
|---|---|
| Sorting edges | O(E log E) |
| Union-Find operations | O(E · α(V)) ≈ O(E) |
| Total time | O(E log E) |
| Space | O(V + E) |
Note: α(V) is the inverse Ackermann function — it grows so slowly it’s effectively constant (≤ 4 for any realistic input). So the sorting step dominates.
Since E ≤ V², we have log E ≤ 2 log V, so O(E log E) = O(E log V) — same asymptotic complexity as Prim’s with a binary heap.
Kruskal’s vs Prim’s: Full Comparison
| Criterion | Kruskal’s | Prim’s |
|---|---|---|
| Approach | Global: sort all edges, pick greedily | Local: grow one tree from a start vertex |
| Key data structure | Union-Find | Min-heap |
| Time complexity | O(E log E) | O(E log V) |
| Best for | Sparse graphs (E << V²) | Dense graphs (E ≈ V²) |
| Pre-sorted edges | Excellent — sorting is already done | No advantage |
| Disconnected graphs | Naturally produces a spanning forest | Only spans one component |
| Implementation | Slightly simpler to reason about | Similar to Dijkstra’s — reusable pattern |
| Parallelism | Harder to parallelise | Harder to parallelise |
Rule of thumb: if E is much smaller than V², use Kruskal’s. If E is close to V², use Prim’s with a simple array (O(V²)). For most competitive programming problems, both work fine.
Disconnected Graphs: Spanning Forest
If the graph is disconnected, Kruskal’s naturally produces a minimum spanning forest — one MST per connected component.
class UnionFind:
def __init__(self, nodes):
self.parent = {node: node for node in nodes}
self.rank = {node: 0 for node in nodes}
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
return True
def kruskals_forest(vertices, edges):
uf = UnionFind(vertices)
mst_edges = []
total_weight = 0
for weight, u, v in sorted(edges):
if uf.union(u, v):
mst_edges.append((u, v, weight))
total_weight += weight
# Count components in the result
roots = set(uf.find(v) for v in vertices)
num_components = len(roots)
return mst_edges, total_weight, num_components
# Two disconnected islands: {A,B,C} and {D,E}
vertices = ['A', 'B', 'C', 'D', 'E']
edges = [
(2, 'A', 'C'),
(1, 'B', 'C'),
(3, 'A', 'B'),
(1, 'D', 'E'),
]
mst, total, components = kruskals_forest(vertices, edges)
print("Minimum Spanning Forest:")
for u, v, w in mst:
print(f" {u} -- {v} (weight: {w})")
print(f"\nTotal weight: {total}")
print(f"Number of components (trees in the forest): {components}")
Real-World Applications
| Domain | Application |
|---|---|
| Network Design | Minimum cable/fibre for connecting buildings or cities |
| Electrical Grid | Minimum wire for power distribution |
| Water Pipes | Minimum pipe for municipal water networks |
| Pre-sorted Data | When edge list comes pre-sorted (e.g., from a distance database), Kruskal’s is immediately efficient |
| Cluster Analysis | Single-linkage hierarchical clustering builds an MST |
| Image Segmentation | Kruskal-based algorithms group similar pixels |
| Approximation Algorithms | MST gives a 2-approximation for the Travelling Salesman Problem |
Key Takeaways
- Kruskal’s finds the MST by sorting all edges and greedily adding the cheapest non-cycle-forming edge.
- The critical helper is Union-Find: it detects and prevents cycles in near-constant time.
- Time complexity is O(E log E), dominated by sorting.
- Kruskal’s excels on sparse graphs and when edges are already sorted.
- On disconnected graphs, it produces a minimum spanning forest naturally.
- Both Kruskal’s and Prim’s always produce an MST with V - 1 edges (per component).
Topological Sort
Before you can take Algorithms, you must take Data Structures. Before Data Structures, you need Programming Fundamentals. Before that, you need nothing — it’s your starting point. Topological sort takes a graph of these dependency relationships and produces a valid order to complete them all.
The Problem: Ordering with Dependencies
Imagine you’re a university student planning your course sequence. Some courses have prerequisites — you can’t take them until you’ve completed others. You need a linear order of all courses such that every prerequisite comes before the course that requires it.
This is exactly what topological sort produces: a linear ordering of vertices in a directed graph such that for every directed edge u → v, vertex u appears before v in the ordering.
Directed Acyclic Graphs (DAGs)
Topological sort only works on DAGs — Directed Acyclic Graphs.
- Directed: edges have a direction (u → v means “u must come before v”)
- Acyclic: no cycles (if A depends on B which depends on A, there’s no valid ordering — it’s a deadlock)
graph TD
Maths101 --> DataStructures
Programming --> DataStructures
Programming --> Algorithms
DataStructures --> Algorithms
Algorithms --> MachineLearning
DataStructures --> Databases
Databases --> MachineLearning
One valid topological order: Maths101 → Programming → DataStructures → Algorithms → Databases → MachineLearning
Another valid order: Programming → Maths101 → DataStructures → Databases → Algorithms → MachineLearning
Both are correct — topological sort is not unique when multiple valid orderings exist.
Approach 1: Kahn’s Algorithm (BFS-based)
Kahn’s algorithm uses the concept of in-degree: the number of edges pointing into a vertex. A vertex with in-degree 0 has no prerequisites — it can be taken immediately.
The Idea
- Compute the in-degree of every vertex.
- Enqueue all vertices with in-degree 0 (no prerequisites).
- While the queue is not empty:
- Dequeue a vertex, add it to the result.
- For each of its neighbours, decrement their in-degree by 1.
- If a neighbour’s in-degree reaches 0, enqueue it.
- If the result contains all vertices — success. If not — the graph has a cycle.
Step-by-Step Trace
Using the course graph above:
| Step | Queue | Processed | In-degrees updated |
|---|---|---|---|
| Init | [Maths101, Programming] | — | All computed |
| 1 | [Programming] | Maths101 | DataStructures: 2→1 |
| 2 | [] | Programming | DataStructures: 1→0, Algorithms: 2→1 → enqueue DS |
| 3 | [DataStructures] | — | — |
| 4 | [Algorithms, Databases] | DataStructures | Algorithms: 1→0, Databases: 1→0 |
| 5 | [Databases] | Algorithms | MachineLearning: 2→1 |
| 6 | [] | Databases | MachineLearning: 1→0 → enqueue ML |
| 7 | [MachineLearning] | — | — |
| 8 | Done | MachineLearning | — |
Implementation: Kahn’s Algorithm
from collections import deque
def kahns_topological_sort(graph):
"""
graph: { node: [neighbours...] } — directed edges node -> neighbour
Returns: (order list, has_cycle bool)
"""
# Step 1: compute in-degree for every node
in_degree = {node: 0 for node in graph}
for node in graph:
for neighbour in graph[node]:
in_degree[neighbour] = in_degree.get(neighbour, 0) + 1
# Ensure all nodes exist in in_degree (including those with no outgoing edges)
for node in graph:
in_degree.setdefault(node, 0)
# Step 2: enqueue all nodes with in-degree 0
queue = deque()
for node in graph:
if in_degree[node] == 0:
queue.append(node)
result = []
# Step 3: process queue
while queue:
node = queue.popleft()
result.append(node)
for neighbour in graph[node]:
in_degree[neighbour] -= 1
if in_degree[neighbour] == 0:
queue.append(neighbour)
# Step 4: cycle detection
has_cycle = len(result) != len(graph)
return result, has_cycle
# Course prerequisite graph
courses = {
'Maths101': ['DataStructures'],
'Programming': ['DataStructures', 'Algorithms'],
'DataStructures': ['Algorithms', 'Databases'],
'Algorithms': ['MachineLearning'],
'Databases': ['MachineLearning'],
'MachineLearning': [],
}
order, cycle = kahns_topological_sort(courses)
if cycle:
print("Cycle detected — no valid course order exists!")
else:
print("Valid course order:")
for i, course in enumerate(order, 1):
print(f" {i}. {course}")
Approach 2: DFS-based Topological Sort
The DFS approach uses a key insight: in a DFS traversal, a vertex is “finished” only after all vertices reachable from it are finished. So the vertex that finishes last has no dependencies — it should come first in the topological order.
The Idea
- For each unvisited vertex, run a DFS.
- When a vertex finishes (all its descendants have been visited), push it onto a stack.
- Pop the stack to get the topological order (nodes with no dependencies come out first).
States
Each node has three states during DFS:
- Unvisited (WHITE): not yet seen
- In progress (GRAY): currently in the DFS call stack
- Done (BLACK): fully processed
If we encounter a GRAY node during DFS, we’ve found a back edge — which means there’s a cycle.
Implementation: DFS-based
def dfs_topological_sort(graph):
"""
graph: { node: [neighbours...] }
Returns: (order list, has_cycle bool)
"""
WHITE, GRAY, BLACK = 0, 1, 2
state = {node: WHITE for node in graph}
result = []
has_cycle = False
def dfs(node):
nonlocal has_cycle
if has_cycle:
return
state[node] = GRAY # Mark as "in progress"
for neighbour in graph[node]:
if state[neighbour] == GRAY:
# Back edge found — cycle!
has_cycle = True
return
if state[neighbour] == WHITE:
dfs(neighbour)
state[node] = BLACK # Mark as "done"
result.append(node) # Post-order: append when fully done
for node in graph:
if state[node] == WHITE:
dfs(node)
if has_cycle:
return [], True
# Reverse because we appended in post-order (leaves first)
result.reverse()
return result, False
# Same course graph
courses = {
'Maths101': ['DataStructures'],
'Programming': ['DataStructures', 'Algorithms'],
'DataStructures': ['Algorithms', 'Databases'],
'Algorithms': ['MachineLearning'],
'Databases': ['MachineLearning'],
'MachineLearning': [],
}
order, cycle = dfs_topological_sort(courses)
if cycle:
print("Cycle detected — no valid course order exists!")
else:
print("Valid course order (DFS-based):")
for i, course in enumerate(order, 1):
print(f" {i}. {course}")
Cycle Detection Demo
Both algorithms detect cycles, but Kahn’s is often preferred for this because the detection falls out naturally from counting processed nodes.
from collections import deque
def kahns_topological_sort(graph):
in_degree = {node: 0 for node in graph}
for node in graph:
for neighbour in graph[node]:
in_degree[neighbour] = in_degree.get(neighbour, 0) + 1
for node in graph:
in_degree.setdefault(node, 0)
queue = deque(node for node in graph if in_degree[node] == 0)
result = []
while queue:
node = queue.popleft()
result.append(node)
for neighbour in graph[node]:
in_degree[neighbour] -= 1
if in_degree[neighbour] == 0:
queue.append(neighbour)
has_cycle = len(result) != len(graph)
return result, has_cycle
# Valid DAG: A -> B -> C -> D
dag = {
'A': ['B'],
'B': ['C'],
'C': ['D'],
'D': [],
}
# Cyclic graph: A -> B -> C -> A (circular dependency!)
cyclic = {
'A': ['B'],
'B': ['C'],
'C': ['A'], # This creates the cycle
'D': ['A'], # D is a separate entry point into the cycle
}
order1, c1 = kahns_topological_sort(dag)
order2, c2 = kahns_topological_sort(cyclic)
print("DAG result:")
print(f" Order: {order1}")
print(f" Has cycle: {c1}")
print("\nCyclic graph result:")
print(f" Partial order: {order2} (only D was processed — A, B, C are stuck)")
print(f" Has cycle: {c2}")
print(f" Nodes in cycle: {set(cyclic.keys()) - set(order2)}")
Real-World Build System Example
from collections import deque
def build_order(tasks):
"""
tasks: { task: [dependencies...] }
Returns build order (dependencies first)
"""
# Convert to graph where edges go FROM dependency TO dependent
# If B depends on A, then A -> B (A must come before B)
graph = {task: [] for task in tasks}
in_degree = {task: 0 for task in tasks}
for task, deps in tasks.items():
for dep in deps:
graph[dep].append(task)
in_degree[task] += 1
queue = deque(t for t in tasks if in_degree[t] == 0)
order = []
while queue:
task = queue.popleft()
order.append(task)
for dependent in graph[task]:
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
queue.append(dependent)
if len(order) != len(tasks):
return None # Circular dependency
return order
# npm-style package dependencies
packages = {
'express': ['http-parser', 'path-to-regexp'],
'mongoose': ['bson', 'kareem'],
'bson': [],
'kareem': [],
'http-parser':['safer-buffer'],
'safer-buffer':[],
'path-to-regexp': [],
'my-app': ['express', 'mongoose'],
}
install_order = build_order(packages)
if install_order:
print("npm install order:")
for i, pkg in enumerate(install_order, 1):
deps = packages[pkg]
dep_str = f" (requires: {', '.join(deps)})" if deps else " (no dependencies)"
print(f" {i:>2}. {pkg}{dep_str}")
else:
print("Circular dependency detected!")
Comparing the Two Approaches
| Kahn’s (BFS) | DFS-based | |
|---|---|---|
| Mechanism | In-degree counting + queue | Post-order DFS + stack |
| Cycle detection | Yes — check len(result) != len(graph) | Yes — check for GRAY neighbours |
| Implementation | Iterative (no recursion depth issues) | Recursive (may hit stack limits on large graphs) |
| Which cycle | Tells you nodes involved (unprocessed nodes) | Detects existence on encounter |
| Order of output | Stable with consistent queue ordering | Depends on DFS traversal order |
| Intuition | “Keep taking courses with no remaining prereqs” | “Finish deeply nested tasks first” |
Both have O(V + E) time complexity and O(V) space complexity.
For most practical uses, Kahn’s is preferred — it’s iterative, naturally detects cycles, and is easier to reason about.
Complexity Analysis
| Complexity | |
|---|---|
| Time | O(V + E) |
| Space | O(V) for in-degree map / visited states + O(V) for queue/stack |
Both approaches process every vertex once and every edge once — hence O(V + E).
Real-World Applications
| Domain | Application |
|---|---|
| Build systems | Make, Bazel, Gradle — compile files in dependency order |
| Package managers | npm, pip, apt — install packages before those that need them |
| Course scheduling | University prerequisite planning |
| Spreadsheets | Evaluate formulas in dependency order (Excel, Google Sheets) |
| Data pipelines | Apache Airflow, dbt — run transforms in correct order |
| Git | Commit history forms a DAG; topological sort underlies git log --topo-order |
| Compiler internals | Resolve symbol dependencies, schedule instruction execution |
Key Takeaways
- Topological sort produces a linear ordering where all dependencies come before their dependents.
- It only works on DAGs (Directed Acyclic Graphs). Cycles make ordering impossible.
- Kahn’s algorithm (BFS + in-degrees): iterative, natural cycle detection, intuitive.
- DFS-based: recursive, append on finish, reverse at end.
- Both run in O(V + E) time.
- If not all vertices appear in the result, the graph contains a cycle — which is itself useful information (deadlock detection, dependency error reporting).
Graph Problems
Practice problems for traversal, shortest paths, connected components, and topological reasoning.
Island Perimeter
Difficulty: Easy Source: NeetCode
Problem
You are given a
row x colgridgridrepresenting a map wheregrid[i][j] = 1represents land andgrid[i][j] = 0represents water.Grid cells are connected horizontally/vertically (not diagonally). The grid is completely surrounded by water, and there is exactly one island (i.e., one or more connected land cells).
Return the perimeter of the island.
Example 1: Input:
grid = [[0,1,0,0],[1,1,1,0],[0,1,0,0],[1,1,0,0]]Output:16Constraints:
row == grid.length,col == grid[i].length1 <= row, col <= 100grid[i][j]is0or1- There is exactly one island in
grid
Prerequisites
Before attempting this problem, you should be comfortable with:
- 2D grid traversal — iterating over every cell with row/col indices
- Neighbor checking — looking at the four adjacent cells of a given cell
1. Brute Force
Intuition
For each land cell, start with 4 potential perimeter edges. Then for each of the four neighbors, if that neighbor is also land, the shared edge is interior and not part of the perimeter — subtract 1. Summing this over all land cells gives the total perimeter.
Algorithm
- Initialize
perimeter = 0. - For each cell
(r, c)wheregrid[r][c] == 1:- Add 4 to
perimeter. - For each of the 4 neighbors: if the neighbor is in bounds and is also
1, subtract 1.
- Add 4 to
- Return
perimeter.
Solution
def islandPerimeter(grid: list[list[int]]) -> int:
rows, cols = len(grid), len(grid[0])
perimeter = 0
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
perimeter += 4
for dr, dc in directions:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
perimeter -= 1
return perimeter
print(islandPerimeter([[0,1,0,0],[1,1,1,0],[0,1,0,0],[1,1,0,0]])) # 16
print(islandPerimeter([[1]])) # 4
print(islandPerimeter([[1,0]])) # 4
Complexity
- Time:
O(M * N)— visit every cell once - Space:
O(1)— no extra data structures
2. Formula Approach
Intuition
There’s a neat formula: count the total number of land cells (cells) and the total number of shared edges between adjacent land cells (neighbors). Each land cell contributes 4 edges. Each shared edge between two land cells removes 2 perimeter edges (one from each cell). So:
perimeter = cells * 4 - neighbors * 2
You only need to check right and down neighbors to avoid double-counting shared edges.
Algorithm
- Count
cells= number of1s in the grid. - Count
neighbors= number of pairs where a1is directly to the right or directly below another1. - Return
cells * 4 - neighbors * 2.
Solution
def islandPerimeter(grid: list[list[int]]) -> int:
rows, cols = len(grid), len(grid[0])
cells = 0
neighbors = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
cells += 1
# Check right neighbor
if c + 1 < cols and grid[r][c + 1] == 1:
neighbors += 1
# Check bottom neighbor
if r + 1 < rows and grid[r + 1][c] == 1:
neighbors += 1
return cells * 4 - neighbors * 2
print(islandPerimeter([[0,1,0,0],[1,1,1,0],[0,1,0,0],[1,1,0,0]])) # 16
print(islandPerimeter([[1]])) # 4
print(islandPerimeter([[1,1],[1,1]])) # 8
Complexity
- Time:
O(M * N) - Space:
O(1)
Common Pitfalls
Counting neighbors twice. If you check all 4 directions for neighbors and subtract 1 per adjacent land cell, you’re effectively subtracting 2 per shared edge (once from each side) — which is exactly what the formula approach expresses explicitly.
Checking out-of-bounds neighbors. A cell on the grid border has neighbors outside the grid, which count as water. Always bounds-check before checking grid[nr][nc].
Verifying an Alien Dictionary
Difficulty: Easy Source: NeetCode
Problem
In an alien language, surprisingly, they also use English lowercase letters, but possibly in a different order. The order of the alphabet is some permutation of lowercase English letters.
Given a list of
wordswritten in the alien language, and theorderof the alphabet, returntrueif and only if the givenwordsare sorted lexicographically in this alien language.Example 1: Input:
words = ["hello","leetcode"],order = "hlabcdefgijkmnopqrstuvwxyz"Output:trueExample 2: Input:
words = ["word","world","row"],order = "worldabcefghijkmnpqstuvxyz"Output:falseExample 3: Input:
words = ["apple","app"],order = "abcdefghijklmnopqrstuvwxyz"Output:falseConstraints:
1 <= words.length <= 1001 <= words[i].length <= 20order.length == 26- All characters in
words[i]andorderare English lowercase letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Maps — mapping characters to their rank for O(1) lookup
- String comparison — comparing two strings character by character
1. Brute Force
Intuition
Compare every adjacent pair of words. For each pair, go character by character: if the characters are the same, keep going; if they differ, the pair is ordered correctly only if the first word’s character comes before the second’s in the alien order; if one word is a prefix of the other, the shorter one must come first. If any adjacent pair is out of order, return False.
Algorithm
- Build a rank map:
rank[order[i]] = ifor each indexi. - For each adjacent pair
(words[i], words[i+1]):- Compare character by character up to the shorter length.
- If chars differ: check
rank[c1] < rank[c2]; if not, returnFalse. - If one is a prefix of the other:
words[i]must not be longer thanwords[i+1].
- Return
Trueif all pairs pass.
Solution
def isAlienSorted(words: list[str], order: str) -> bool:
rank = {ch: i for i, ch in enumerate(order)}
for i in range(len(words) - 1):
w1, w2 = words[i], words[i + 1]
for j in range(len(w1)):
if j == len(w2):
# w2 is a prefix of w1 → wrong order
return False
if w1[j] != w2[j]:
if rank[w1[j]] > rank[w2[j]]:
return False
break # this pair is correctly ordered; move on
return True
print(isAlienSorted(["hello","leetcode"], "hlabcdefgijkmnopqrstuvwxyz")) # True
print(isAlienSorted(["word","world","row"], "worldabcefghijkmnpqstuvxyz")) # False
print(isAlienSorted(["apple","app"], "abcdefghijklmnopqrstuvwxyz")) # False
Complexity
- Time:
O(N * L)where N = number of words, L = average word length - Space:
O(1)— the rank map is always exactly 26 entries
2. Sorted Key Comparison
Intuition
Python’s sorted() with a custom key can do this elegantly. Map each word to a tuple of alien ranks, then check if words already equals the sorted version. This is more Pythonic but has the same asymptotic complexity.
Algorithm
- Build
rankmap as before. - Define
alien_key(word)= tuple ofrank[ch]for each char in word. - Return
words == sorted(words, key=alien_key).
Solution
def isAlienSorted(words: list[str], order: str) -> bool:
rank = {ch: i for i, ch in enumerate(order)}
def alien_key(word):
return [rank[ch] for ch in word]
return words == sorted(words, key=alien_key)
print(isAlienSorted(["hello","leetcode"], "hlabcdefgijkmnopqrstuvwxyz")) # True
print(isAlienSorted(["word","world","row"], "worldabcefghijkmnpqstuvxyz")) # False
print(isAlienSorted(["apple","app"], "abcdefghijklmnopqrstuvwxyz")) # False
print(isAlienSorted(["z","x"], "zyxwvutsrqponmlkjihgfedcba")) # True
Complexity
- Time:
O(N * L * log N)due to sorting - Space:
O(N * L)for the key tuples
Common Pitfalls
Prefix case: longer word first. If words[i] is a prefix of words[i+1] (e.g., “apple” before “app”), the words are out of order. Check j == len(w2) inside the character loop — if you exhaust w2 before finding a difference, that’s wrong order.
Breaking after the first differing character. Once you find the first character that differs between two words, that single comparison decides the pair’s order. Don’t continue comparing further characters — break after the if rank[w1[j]] > rank[w2[j]]: return False check.
Building the rank map correctly. rank = {ch: i for i, ch in enumerate(order)} — make sure the index is the rank (position in the alien alphabet), not the character value.
Find the Town Judge
Difficulty: Easy Source: NeetCode
Problem
In a town, there are
npeople labeled from1ton. There is a rumor that one of these people is secretly the town judge.If the town judge exists, then:
- The town judge trusts nobody.
- Everybody else (except possibly the judge) trusts the town judge.
- There is exactly one person who satisfies properties 1 and 2.
You are given an array
trustwheretrust[i] = [a, b]means personatrusts personb. Return the label of the town judge if the town judge exists and can be identified, or return-1otherwise.Example 1: Input:
trust = [[1,3],[2,3]], n = 3Output:3Example 2: Input:
trust = [[1,3],[2,3],[3,1]], n = 3Output:-1Example 3: Input:
trust = [[1,2]], n = 2Output:2Constraints:
1 <= n <= 10000 <= trust.length <= 10^4trust[i].length == 2- All the pairs in
trustare uniquea != b
Prerequisites
Before attempting this problem, you should be comfortable with:
- Graph in-degree / out-degree — counting how many edges point into or out of a node
- Hash Maps / Arrays — tracking counts per person
1. Brute Force
Intuition
For each person, check two things: do they trust anyone (out-degree > 0)? And is trusted by everyone else (in-degree == n-1)? The judge is the person with out-degree 0 and in-degree n-1. We can gather this by building the full trust sets and counting.
Algorithm
- Build
trusts_set[person]= set of people they trust. - Build
trusted_by_count[person]= how many people trust them. - For each person
p: iflen(trusts_set[p]) == 0andtrusted_by_count[p] == n - 1, returnp. - Return
-1.
Solution
def findJudge(n: int, trust: list[list[int]]) -> int:
trusts = [set() for _ in range(n + 1)]
trusted_by = [0] * (n + 1)
for a, b in trust:
trusts[a].add(b)
trusted_by[b] += 1
for p in range(1, n + 1):
if len(trusts[p]) == 0 and trusted_by[p] == n - 1:
return p
return -1
print(findJudge(3, [[1,3],[2,3]])) # 3
print(findJudge(3, [[1,3],[2,3],[3,1]])) # -1
print(findJudge(2, [[1,2]])) # 2
print(findJudge(1, [])) # 1
Complexity
- Time:
O(T + N)where T = number of trust pairs - Space:
O(N + T)
2. In-Degree / Out-Degree Score
Intuition
We can compress both conditions into a single score per person: score[p] = in_degree[p] - out_degree[p]. The judge trusts nobody (out-degree 0) and is trusted by everyone else (in-degree n-1), so score[judge] = (n-1) - 0 = n-1. Any non-judge who trusts someone has out-degree ≥ 1, so their score is at most n-2. We just need to find the person whose score equals n-1.
Algorithm
- Initialize
score[1..n] = 0. - For each
[a, b]intrust:score[a] -= 1(trusts someone),score[b] += 1(trusted by someone). - Return the person with
score[p] == n - 1, or-1if none.
Solution
def findJudge(n: int, trust: list[list[int]]) -> int:
score = [0] * (n + 1) # 1-indexed
for a, b in trust:
score[a] -= 1 # a trusts someone → out-degree
score[b] += 1 # b is trusted → in-degree
for p in range(1, n + 1):
if score[p] == n - 1:
return p
return -1
print(findJudge(3, [[1,3],[2,3]])) # 3
print(findJudge(3, [[1,3],[2,3],[3,1]])) # -1
print(findJudge(2, [[1,2]])) # 2
print(findJudge(1, [])) # 1 (single person trusts nobody and is trusted by 0 = n-1 = 0 others)
Complexity
- Time:
O(T + N)where T = number of trust pairs - Space:
O(N)
Common Pitfalls
The n=1 edge case. With one person and no trust pairs, that person is the judge — they trust nobody and are “trusted by everyone else” (vacuously true). The score approach handles this correctly since score[1] = 0 = n-1 = 0.
Mutual trust disqualifies both. If A trusts B and B trusts A, neither can be the judge. The score method handles this because both get a -1 for trusting someone.
1-indexed vs 0-indexed. People are labeled 1 to n. Allocate arrays of size n+1 and ignore index 0, or adjust accordingly.
Number of Islands
Difficulty: Medium Source: NeetCode
Problem
Given an
m x n2D binary gridgridwhich represents a map of'1's (land) and'0's (water), return the number of islands.An island is surrounded by water and is formed by connecting adjacent lands horizontally or vertically. You may assume all four edges of the grid are surrounded by water.
Example 1: Input:
grid = [["1","1","0","0","0"],["1","1","0","0","0"],["0","0","1","0","0"],["0","0","0","1","1"]]Output:3Constraints:
m == grid.length,n == grid[i].length1 <= m, n <= 300grid[i][j]is'0'or'1'
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS / BFS on grids — visiting cells and their neighbors systematically
- Flood fill — marking all cells belonging to the same connected component
- Union-Find — an alternative way to track connected components
1. DFS (Flood Fill)
Intuition
Scan the grid left to right, top to bottom. Whenever you hit an unvisited land cell ('1'), you’ve found a new island — increment the count and immediately “sink” the entire island by running a DFS that turns all connected '1's into '0's (or a visited marker). This way, you won’t count the same island twice.
Algorithm
- Initialize
count = 0. - For each cell
(r, c): ifgrid[r][c] == '1', incrementcountand calldfs(r, c). dfs(r, c): markgrid[r][c] = '0', then recursively calldfson each valid land neighbor.- Return
count.
Solution
def numIslands(grid: list[list[str]]) -> int:
rows, cols = len(grid), len(grid[0])
def dfs(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
return
grid[r][c] = '0' # sink the cell
dfs(r + 1, c)
dfs(r - 1, c)
dfs(r, c + 1)
dfs(r, c - 1)
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
dfs(r, c)
return count
grid = [["1","1","0","0","0"],["1","1","0","0","0"],
["0","0","1","0","0"],["0","0","0","1","1"]]
print(numIslands(grid)) # 3
grid2 = [["1","1","1","1","0"],["1","1","0","1","0"],
["1","1","0","0","0"],["0","0","0","0","0"]]
print(numIslands(grid2)) # 1
Complexity
- Time:
O(M * N)— each cell is visited at most once - Space:
O(M * N)worst-case recursion stack (grid full of land)
2. BFS
Intuition
Same idea as DFS — find an unvisited island cell, increment the count, then flood-fill using a queue instead of recursion. BFS avoids potential stack overflow on very large grids (Python’s recursion limit is 1000 by default).
Algorithm
- For each unvisited land cell
(r, c), incrementcountand enqueue(r, c). - While the queue is non-empty: dequeue
(r, c), mark it visited, enqueue all valid land neighbors. - Return
count.
Solution
from collections import deque
def numIslands(grid: list[list[str]]) -> int:
rows, cols = len(grid), len(grid[0])
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
queue = deque([(r, c)])
grid[r][c] = '0'
while queue:
row, col = queue.popleft()
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = row + dr, col + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1':
grid[nr][nc] = '0'
queue.append((nr, nc))
return count
grid = [["1","1","0","0","0"],["1","1","0","0","0"],
["0","0","1","0","0"],["0","0","0","1","1"]]
print(numIslands(grid)) # 3
Complexity
- Time:
O(M * N) - Space:
O(min(M, N))— queue size bounded by the BFS frontier width
3. Union-Find
Intuition
Model each land cell as a node in a Union-Find (disjoint set) structure. For each land cell, union it with its right and bottom neighbors if they are also land. The number of islands equals the number of distinct components (roots) among land cells at the end.
Algorithm
- Initialize Union-Find with each land cell as its own component.
- For each land cell, union with right and bottom neighbors if they are land.
- Count the number of unique roots among all land cells.
Solution
def numIslands(grid: list[list[str]]) -> int:
rows, cols = len(grid), len(grid[0])
parent = {}
rank = {}
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py:
return
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
parent[(r, c)] = (r, c)
rank[(r, c)] = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
for dr, dc in [(1,0),(0,1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1':
union((r, c), (nr, nc))
return len({find(node) for node in parent})
grid = [["1","1","0","0","0"],["1","1","0","0","0"],
["0","0","1","0","0"],["0","0","0","1","1"]]
print(numIslands(grid)) # 3
Complexity
- Time:
O(M * N * α(M * N))— nearly O(M * N) with path compression - Space:
O(M * N)for the parent/rank maps
Common Pitfalls
Modifying the original grid. If the grid must be preserved, use a separate visited set instead of overwriting '1' with '0'.
Stack overflow with DFS on large grids. Python’s default recursion limit is 1000. For very large grids, prefer BFS or increase the limit with sys.setrecursionlimit.
Marking cells before enqueuing (BFS). Mark the cell as visited before adding it to the queue, not when you dequeue it. Otherwise, the same cell can be enqueued multiple times before being processed.
Max Area of Island
Difficulty: Medium Source: NeetCode
Problem
You are given an
m x nbinary matrixgrid. An island is a group of1s (representing land) connected 4-directionally (horizontal or vertical). You may assume all four edges of the grid are surrounded by0s (water).The area of an island is the number of cells with value
1in the island.Return the maximum area of an island in
grid. If there is no island, return0.Example 1: Input:
grid = [[0,0,1,0,0,0,0,1,0,0,0,0,0],[0,0,0,0,0,0,0,1,1,1,0,0,0],[0,1,1,0,1,0,0,0,0,0,0,0,0],[0,1,0,0,1,1,0,0,1,0,1,0,0],[0,1,0,0,1,1,0,0,1,1,1,0,0],[0,0,0,0,0,0,0,0,0,0,1,0,0],[0,0,0,0,0,0,0,1,1,1,0,0,0],[0,0,0,0,0,0,0,1,1,0,0,0,0]]Output:6Constraints:
m == grid.length,n == grid[i].length1 <= m, n <= 50grid[i][j]is either0or1
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS / BFS on grids — Number of Islands (LeetCode 200) is the direct predecessor
- Flood fill — marking visited cells while counting them
1. Brute Force (DFS counting)
Intuition
This is Number of Islands with one extra step: instead of just counting islands, we count the area of each island and track the maximum. A DFS function that returns the size of the island it floods is all we need.
Algorithm
- Initialize
max_area = 0. - For each unvisited land cell
(r, c): rundfs(r, c)which returns the island’s area; updatemax_area. dfs(r, c): if out of bounds or water or already visited, return 0. Otherwise mark visited and return1 + dfs(neighbors).- Return
max_area.
Solution
def maxAreaOfIsland(grid: list[list[int]]) -> int:
rows, cols = len(grid), len(grid[0])
def dfs(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != 1:
return 0
grid[r][c] = 0 # mark visited by sinking
return 1 + dfs(r+1,c) + dfs(r-1,c) + dfs(r,c+1) + dfs(r,c-1)
max_area = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
max_area = max(max_area, dfs(r, c))
return max_area
grid = [[0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,1,1,0,1,0,0,0,0,0,0,0,0],
[0,1,0,0,1,1,0,0,1,0,1,0,0],
[0,1,0,0,1,1,0,0,1,1,1,0,0],
[0,0,0,0,0,0,0,0,0,0,1,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,0,0,0,0,0,0,1,1,0,0,0,0]]
print(maxAreaOfIsland(grid)) # 6
print(maxAreaOfIsland([[0,0,0,0,0,0,0,0]])) # 0
Complexity
- Time:
O(M * N)— each cell is visited at most once - Space:
O(M * N)worst-case recursion stack
2. BFS with Area Counter
Intuition
Same logic as DFS but using a queue. For each unvisited land cell, BFS floods the entire island while counting cells. This avoids Python’s recursion depth limit for large grids.
Algorithm
- For each unvisited land cell, start BFS, mark it visited immediately.
- Count cells dequeued per BFS run; update
max_area. - Return
max_area.
Solution
from collections import deque
def maxAreaOfIsland(grid: list[list[int]]) -> int:
rows, cols = len(grid), len(grid[0])
max_area = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
area = 0
queue = deque([(r, c)])
grid[r][c] = 0
while queue:
row, col = queue.popleft()
area += 1
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = row + dr, col + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
grid[nr][nc] = 0
queue.append((nr, nc))
max_area = max(max_area, area)
return max_area
grid = [[1,1,0,0,0],[1,1,0,0,0],[0,0,0,1,1],[0,0,0,1,1]]
print(maxAreaOfIsland(grid)) # 4
print(maxAreaOfIsland([[1]])) # 1
Complexity
- Time:
O(M * N) - Space:
O(min(M, N))— bounded by BFS frontier width
Common Pitfalls
Returning the wrong DFS result. In the DFS approach, each call returns 1 + sum of neighbors. Make sure to return 0 (not 1) for out-of-bounds or water cells — a water cell contributes nothing to area.
Updating max_area before or after DFS. max_area = max(max_area, dfs(r, c)) is correct. If you call dfs first and store the result, that’s fine too — but don’t call dfs twice.
Forgetting the no-island case. The problem says to return 0 if there’s no island. Initializing max_area = 0 handles this correctly.
Clone Graph
Difficulty: Medium Source: NeetCode
Problem
Given a reference of a node in a connected undirected graph, return a deep copy (clone) of the graph.
Each node in the graph contains a value (
int) and a list of its neighbors (List[Node]).class Node: def __init__(self, val=0, neighbors=None): self.val = val self.neighbors = neighbors if neighbors is not None else []Example 1: Input:
adjList = [[2,4],[1,3],[2,4],[1,3]](node 1 connects to 2 and 4, etc.) Output: A deep copy of the same graphExample 2: Input:
adjList = [[]]Output: A single node with no neighborsConstraints:
- The number of nodes is in range
[0, 100]1 <= Node.val <= 100Node.valis unique for each node- No repeated edges and no self-loops
- The graph is connected
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS / BFS — traversing all nodes in the graph
- Hash Maps — mapping original nodes to their clones to handle cycles
1. DFS with Hash Map
Intuition
The tricky part is cycles — if node A and node B are neighbors, cloning A means cloning B, which means cloning A again… infinite loop. The fix is a hash map that tracks {original_node → cloned_node}. Before recursing, store the clone in the map. When you encounter a node already in the map, return its clone immediately instead of creating another.
Algorithm
- If
nodeisNone, returnNone. - Create a hash map
visited = {}. - Define
clone(node):- If
nodeis already invisited, returnvisited[node]. - Create a new node with the same value, store in
visited. - For each neighbor, recursively clone it and add to the new node’s neighbors list.
- Return the new node.
- If
- Return
clone(node).
Solution
class Node:
def __init__(self, val=0, neighbors=None):
self.val = val
self.neighbors = neighbors if neighbors is not None else []
def cloneGraph(node: 'Node') -> 'Node':
if not node:
return None
visited = {}
def clone(n):
if n in visited:
return visited[n]
copy = Node(n.val)
visited[n] = copy # store BEFORE recursing to handle cycles
for neighbor in n.neighbors:
copy.neighbors.append(clone(neighbor))
return copy
return clone(node)
# Build test graph: 1 -- 2 -- 3 -- 4 -- 1 (cycle)
n1 = Node(1)
n2 = Node(2)
n3 = Node(3)
n4 = Node(4)
n1.neighbors = [n2, n4]
n2.neighbors = [n1, n3]
n3.neighbors = [n2, n4]
n4.neighbors = [n3, n1]
cloned = cloneGraph(n1)
print(cloned.val) # 1
print([n.val for n in cloned.neighbors]) # [2, 4]
print(cloned is n1) # False (deep copy)
print(cloned.neighbors[0] is n2) # False (deep copy)
Complexity
- Time:
O(V + E)— visits every node and edge once - Space:
O(V)— hash map plus recursion stack
2. BFS with Hash Map
Intuition
Same idea but iterative: use a queue for BFS traversal. When you first encounter a node, create its clone and add it to the map. When you process it from the queue, wire up its neighbors (creating clones for new neighbors as needed).
Algorithm
- Create a clone of the starting node and put it in
visited. - Enqueue the starting node.
- While the queue is non-empty: dequeue
node, for eachneighbor:- If not in
visited, create a clone and enqueue the original. - Add
visited[neighbor]tovisited[node].neighbors.
- If not in
- Return
visited[start].
Solution
from collections import deque
class Node:
def __init__(self, val=0, neighbors=None):
self.val = val
self.neighbors = neighbors if neighbors is not None else []
def cloneGraph(node: 'Node') -> 'Node':
if not node:
return None
visited = {node: Node(node.val)}
queue = deque([node])
while queue:
curr = queue.popleft()
for neighbor in curr.neighbors:
if neighbor not in visited:
visited[neighbor] = Node(neighbor.val)
queue.append(neighbor)
visited[curr].neighbors.append(visited[neighbor])
return visited[node]
# Test
n1 = Node(1)
n2 = Node(2)
n1.neighbors = [n2]
n2.neighbors = [n1]
cloned = cloneGraph(n1)
print(cloned.val) # 1
print([n.val for n in cloned.neighbors]) # [2]
print(cloned is n1) # False
print(cloneGraph(None)) # None
Complexity
- Time:
O(V + E) - Space:
O(V)for the visited map and queue
Common Pitfalls
Storing the clone after recursing. You must store visited[n] = copy before recursing into neighbors, not after. If you recurse first, cycles cause infinite recursion since the node isn’t in visited yet when you circle back to it.
Forgetting the None check. The problem says the input can be an empty graph (no nodes). Always handle if not node: return None at the start.
Checking identity vs equality. The visited map should use object identity (the node object itself as the key), not the node’s value. Since Node.val is unique here it would work, but in general use the node object as the dict key.
Walls and Gates
Difficulty: Medium Source: NeetCode
Problem
You are given an
m x n2D grid initialized with these three possible values:
-1— a wall or an obstacle0— a gate2147483647(INF) — an empty roomFill each empty room with the distance to its nearest gate. If it is impossible to reach a gate, it should remain
INF.Example 1: Input:
[[INF, -1, 0, INF], [INF, INF, INF, -1], [INF, -1, INF, -1], [0, -1, INF, INF]]Output:
[[3, -1, 0, 1], [2, 2, 1, -1], [1, -1, 2, -1], [0, -1, 3, 4]]Constraints:
m == grid.length,n == grid[i].length1 <= m, n <= 250grid[i][j]is one of-1,0, or2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Multi-source BFS — starting BFS from multiple source nodes simultaneously
- BFS level-by-level — BFS naturally computes shortest distances from sources
1. Brute Force (BFS from each empty room)
Intuition
For each empty room, run a BFS to find the nearest gate. This works but is wasteful — each room does its own BFS traversal, leading to O(M * N) BFS runs each costing O(M * N).
Algorithm
- For each empty room
(r, c), run BFS outward until you find a gate. - Record the distance to the nearest gate.
- This is correct but slow.
Solution
from collections import deque
def wallsAndGates(rooms: list[list[int]]) -> None:
INF = 2147483647
rows, cols = len(rooms), len(rooms[0])
def bfs_from(r, c):
queue = deque([(r, c, 0)])
visited = {(r, c)}
while queue:
row, col, dist = queue.popleft()
if rooms[row][col] == 0:
return dist
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = row + dr, col + dc
if 0 <= nr < rows and 0 <= nc < cols and (nr,nc) not in visited and rooms[nr][nc] != -1:
visited.add((nr, nc))
queue.append((nr, nc, dist + 1))
return INF
for r in range(rows):
for c in range(cols):
if rooms[r][c] == INF:
rooms[r][c] = bfs_from(r, c)
INF = 2147483647
rooms = [[INF, -1, 0, INF],[INF, INF, INF, -1],[INF, -1, INF, -1],[0, -1, INF, INF]]
wallsAndGates(rooms)
for row in rooms:
print(row)
# [[3, -1, 0, 1], [2, 2, 1, -1], [1, -1, 2, -1], [0, -1, 3, 4]]
Complexity
- Time:
O(M²N²)— one BFS per empty room - Space:
O(M * N)
2. Multi-Source BFS (Optimal)
Intuition
Flip the problem: instead of searching outward from each room to find a gate, start from all gates simultaneously and spread outward. In BFS, the first time you reach an empty room, you’ve found the shortest distance to any gate. Multi-source BFS handles this perfectly — enqueue all gates at distance 0, then BFS normally; each room gets assigned the correct distance the first time BFS reaches it.
Algorithm
- Collect all gate positions and enqueue them all at once.
- Run BFS: for each cell dequeued at distance
d, try all 4 neighbors.- If a neighbor is an empty room (INF), set its distance to
d + 1and enqueue it. - Walls (-1) and already-visited cells are skipped.
- If a neighbor is an empty room (INF), set its distance to
- In-place modification — update
roomsdirectly.
Solution
from collections import deque
def wallsAndGates(rooms: list[list[int]]) -> None:
INF = 2147483647
rows, cols = len(rooms), len(rooms[0])
queue = deque()
# Seed BFS with all gate positions
for r in range(rows):
for c in range(cols):
if rooms[r][c] == 0:
queue.append((r, c))
while queue:
r, c = queue.popleft()
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and rooms[nr][nc] == INF:
rooms[nr][nc] = rooms[r][c] + 1
queue.append((nr, nc))
INF = 2147483647
rooms = [[INF, -1, 0, INF],[INF, INF, INF, -1],[INF, -1, INF, -1],[0, -1, INF, INF]]
wallsAndGates(rooms)
for row in rooms:
print(row)
# [[3, -1, 0, 1], [2, 2, 1, -1], [1, -1, 2, -1], [0, -1, 3, 4]]
# Edge case: no gates
rooms2 = [[INF, INF], [INF, INF]]
wallsAndGates(rooms2)
print(rooms2) # [[2147483647, 2147483647], [2147483647, 2147483647]]
Complexity
- Time:
O(M * N)— each cell is processed at most once - Space:
O(M * N)— queue can hold all cells in the worst case
Common Pitfalls
Starting BFS from rooms instead of gates. The multi-source BFS must start from gates (cells with value 0), not from empty rooms. Gates are the sources of shortest distances.
Updating the distance using the current cell’s value. Since BFS propagates outward by 1 at each step, rooms[nr][nc] = rooms[r][c] + 1 correctly assigns the distance. This works because gates start at 0.
Checking INF before enqueuing. Only enqueue cells that are currently INF — this acts as the “visited” check. Walls (-1) and gates (0) should not be re-processed.
Rotting Oranges
Difficulty: Medium Source: NeetCode
Problem
You are given an
m x ngrid where each cell can have one of three values:
0— empty cell1— fresh orange2— rotten orangeEvery minute, any fresh orange that is 4-directionally adjacent to a rotten orange becomes rotten.
Return the minimum number of minutes that must elapse until no cell has a fresh orange. If it is impossible, return
-1.Example 1: Input:
grid = [[2,1,1],[1,1,0],[0,1,1]]Output:4Example 2: Input:
grid = [[2,1,1],[0,1,1],[1,0,1]]Output:-1Example 3: Input:
grid = [[0,2]]Output:0Constraints:
m == grid.length,n == grid[i].length1 <= m, n <= 10grid[i][j]is0,1, or2
Prerequisites
Before attempting this problem, you should be comfortable with:
- Multi-source BFS — spreading from multiple starting points simultaneously; same concept as Walls and Gates
- BFS for shortest distance — BFS levels naturally correspond to time steps
1. Brute Force (Simulate minute by minute)
Intuition
Simulate the process directly: in each minute, scan the entire grid and rot every fresh orange adjacent to a rotten one. Repeat until no fresh oranges remain or nothing changes. Count the rounds. This is O((M*N)²) in the worst case.
Algorithm
- Count initial
freshoranges. - Repeat: scan the grid, collect newly rotten oranges, rot them, decrement
fresh, incrementminutes. - Stop when
fresh == 0(returnminutes) or nothing changed (return-1).
Solution
def orangesRotting(grid: list[list[int]]) -> int:
rows, cols = len(grid), len(grid[0])
fresh = sum(grid[r][c] == 1 for r in range(rows) for c in range(cols))
minutes = 0
while fresh > 0:
to_rot = []
for r in range(rows):
for c in range(cols):
if grid[r][c] == 2:
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
to_rot.append((nr, nc))
if not to_rot:
return -1
for r, c in to_rot:
grid[r][c] = 2
fresh -= 1
minutes += 1
return minutes
print(orangesRotting([[2,1,1],[1,1,0],[0,1,1]])) # 4
print(orangesRotting([[2,1,1],[0,1,1],[1,0,1]])) # -1
print(orangesRotting([[0,2]])) # 0
Complexity
- Time:
O((M * N)²)— up to MN rounds, each scanning MN cells - Space:
O(M * N)forto_rotlist
2. Multi-Source BFS
Intuition
All rotten oranges spread simultaneously, so this is exactly a multi-source BFS problem. Seed the queue with all initially rotten oranges, then run BFS. Each BFS level represents one minute of spreading. Track fresh oranges remaining — if any are left after BFS exhausts, return -1.
Algorithm
- Count
freshoranges. Enqueue all initially rotten oranges. - Run BFS with
minutes = 0. At each level:- Process all cells currently in the queue.
- For each neighbor that is fresh: mark it rotten, decrement
fresh, enqueue. - If we processed any cells in this round, increment
minutes.
- Return
minutesiffresh == 0, else-1.
Solution
from collections import deque
def orangesRotting(grid: list[list[int]]) -> int:
rows, cols = len(grid), len(grid[0])
queue = deque()
fresh = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 2:
queue.append((r, c))
elif grid[r][c] == 1:
fresh += 1
minutes = 0
directions = [(1,0),(-1,0),(0,1),(0,-1)]
while queue and fresh > 0:
minutes += 1
for _ in range(len(queue)):
r, c = queue.popleft()
for dr, dc in directions:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
grid[nr][nc] = 2
fresh -= 1
queue.append((nr, nc))
return minutes if fresh == 0 else -1
print(orangesRotting([[2,1,1],[1,1,0],[0,1,1]])) # 4
print(orangesRotting([[2,1,1],[0,1,1],[1,0,1]])) # -1
print(orangesRotting([[0,2]])) # 0
print(orangesRotting([[1]])) # -1 (no rotten orange)
print(orangesRotting([[2]])) # 0 (no fresh oranges)
Complexity
- Time:
O(M * N)— each cell is processed at most once - Space:
O(M * N)— queue size
Common Pitfalls
Counting minutes incorrectly. Only increment minutes when you actually rot oranges in that round. The condition while queue and fresh > 0 ensures the last iteration (when fresh hits 0) still counted correctly.
Returning -1 prematurely. Check fresh == 0 after BFS completes, not during. Fresh oranges isolated behind walls or empty cells will never be reached, and you can only detect this after BFS finishes.
Marking oranges before enqueueing. Set grid[nr][nc] = 2 before appending to the queue. If you mark after dequeuing, the same fresh orange can be enqueued multiple times from different rotten neighbors in the same minute.
Pacific Atlantic Water Flow
Difficulty: Medium Source: NeetCode
Problem
There is an
m x nrectangular island that borders both the Pacific Ocean and Atlantic Ocean. The Pacific Ocean touches the island’s left and top edges and the Atlantic Ocean touches the island’s right and bottom edges.Water can only flow in four directions: up, down, left, right. Water flows from a cell to an adjacent one only if the adjacent cell’s height is less than or equal to the current cell.
Given an
m x ninteger matrixheightswhereheights[r][c]represents the height of cell(r, c), return a list of grid coordinates where water can flow to both the Pacific and Atlantic oceans.Example 1: Input:
heights = [[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]Output:[[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]Constraints:
m == heights.length,n == heights[i].length1 <= m, n <= 2000 <= heights[i][j] <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS / BFS on grids — visiting neighbors under a condition
- Multi-source traversal — starting from border cells rather than interior cells
- Set intersection — finding cells reachable from both oceans
1. Brute Force (DFS from each cell)
Intuition
For each cell, run two DFS searches: one to check if water can flow to the Pacific, and one to check if it can reach the Atlantic. A cell reaches the Pacific if it can reach the top or left border; Atlantic if it can reach the bottom or right border. Add the cell to results if both are reachable.
Algorithm
- For each cell
(r, c), run DFS flowing downhill (next cell height ≤ current height). - Check if Pacific border or Atlantic border is reachable.
- Collect cells where both are reachable.
Solution
def pacificAtlantic(heights: list[list[int]]) -> list[list[int]]:
rows, cols = len(heights), len(heights[0])
def can_reach(r, c, ocean):
visited = set()
def dfs(r, c):
if (r, c) in visited:
return False
visited.add((r, c))
# Check if we've reached the target ocean border
if ocean == 'P' and (r == 0 or c == 0):
return True
if ocean == 'A' and (r == rows - 1 or c == cols - 1):
return True
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and heights[nr][nc] <= heights[r][c]:
if dfs(nr, nc):
return True
return False
return dfs(r, c)
result = []
for r in range(rows):
for c in range(cols):
if can_reach(r, c, 'P') and can_reach(r, c, 'A'):
result.append([r, c])
return result
print(pacificAtlantic([[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]))
# [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]
Complexity
- Time:
O(M² * N²)— one DFS per cell per ocean - Space:
O(M * N)for visited sets
2. Reverse BFS/DFS from Ocean Borders
Intuition
Instead of checking each cell’s reachability, reverse the direction: start from the ocean borders and traverse uphill (next cell height ≥ current height). All cells reachable from Pacific borders going uphill can flow to the Pacific. Same for Atlantic. The answer is their intersection.
This avoids redundant work by sharing traversal across all cells — each cell is visited at most twice total (once per ocean traversal).
Algorithm
- Initialize Pacific reachable set with all top-row and left-column cells.
- Initialize Atlantic reachable set with all bottom-row and right-column cells.
- Run BFS (or DFS) from each set, expanding to neighbors with height ≥ current.
- Return the intersection of both sets.
Solution
from collections import deque
def pacificAtlantic(heights: list[list[int]]) -> list[list[int]]:
rows, cols = len(heights), len(heights[0])
def bfs(starts):
visited = set(starts)
queue = deque(starts)
while queue:
r, c = queue.popleft()
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols and
(nr, nc) not in visited and
heights[nr][nc] >= heights[r][c]):
visited.add((nr, nc))
queue.append((nr, nc))
return visited
pacific_starts = [(0, c) for c in range(cols)] + [(r, 0) for r in range(1, rows)]
atlantic_starts = [(rows-1, c) for c in range(cols)] + [(r, cols-1) for r in range(rows-1)]
pacific = bfs(pacific_starts)
atlantic = bfs(atlantic_starts)
return [[r, c] for r, c in pacific & atlantic]
print(sorted(pacificAtlantic([[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]])))
# [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]
print(pacificAtlantic([[1]])) # [[0,0]]
Complexity
- Time:
O(M * N)— each cell visited at most twice - Space:
O(M * N)for the visited sets and queue
Common Pitfalls
Going downhill vs uphill. In the forward direction (from cell to ocean), water flows downhill (to cells with equal or lower height). In the reverse direction (from ocean borders inward), we traverse uphill (to cells with equal or higher height). Make sure you use >= in the reverse BFS.
Including corner cells in both ocean starts. The top-left corner touches the Pacific and the bottom-right corner touches the Atlantic. Don’t double-count cells when building pacific_starts and atlantic_starts — use range(1, rows) for one of the directions to avoid duplicates (though duplicates don’t affect correctness, just efficiency).
Result ordering. The problem doesn’t require sorted output, but checking your result against expected output is easier if you sort both.
Surrounded Regions
Difficulty: Medium Source: NeetCode
Problem
Given an
m x nmatrixboardcontaining'X'and'O', capture all regions that are 4-directionally surrounded by'X'.A region is captured by flipping all
'O's into'X's in that surrounded region.An
'O'is not flipped if it is on the border or is connected to an'O'on the border (directly or indirectly).Example 1: Input:
board = [["X","X","X","X"],["X","O","O","X"],["X","X","O","X"],["X","O","X","X"]]Output:[["X","X","X","X"],["X","X","X","X"],["X","X","X","X"],["X","O","X","X"]]Constraints:
m == board.length,n == board[i].length1 <= m, n <= 200board[i][j]is'X'or'O'
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS / BFS on grids — flood fill from border cells
- Flood fill with markers — temporarily marking safe cells to distinguish them from surrounded ones
1. Brute Force (Check each O’s connectivity)
Intuition
For each 'O' cell, run DFS/BFS to check if it can reach the border. If it can, it’s safe; otherwise, flip it to 'X'. This is correct but redundant — many O’s share the same connected component and we’d recheck the same cells multiple times.
Algorithm
- For each
'O'cell: run BFS to see if the border is reachable via connected O’s. - If border is not reachable, flip to
'X'.
Solution
from collections import deque
def solve(board: list[list[str]]) -> None:
rows, cols = len(board), len(board[0])
def reaches_border(start_r, start_c):
queue = deque([(start_r, start_c)])
visited = {(start_r, start_c)}
while queue:
r, c = queue.popleft()
if r == 0 or r == rows-1 or c == 0 or c == cols-1:
return True
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r+dr, c+dc
if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == 'O' and (nr,nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc))
return False
for r in range(rows):
for c in range(cols):
if board[r][c] == 'O' and not reaches_border(r, c):
board[r][c] = 'X'
board = [["X","X","X","X"],["X","O","O","X"],["X","X","O","X"],["X","O","X","X"]]
solve(board)
for row in board:
print(row)
# ['X','X','X','X'], ['X','X','X','X'], ['X','X','X','X'], ['X','O','X','X']
Complexity
- Time:
O(M² * N²)— one BFS per O cell - Space:
O(M * N)
2. Border DFS / Mark-and-Flip
Intuition
Invert the thinking: instead of checking which O’s are surrounded, find which O’s are safe (connected to the border). Start DFS/BFS from every O on the border and mark all connected O’s as safe (e.g., temporarily replace with 'S'). After the traversal, anything still 'O' is surrounded — flip it to 'X'. Finally, restore 'S' back to 'O'.
Algorithm
- For every
'O'on the border, run DFS marking connected O’s as'S'(safe). - Scan the entire board:
'O'→'X','S'→'O','X'→'X'.
Solution
def solve(board: list[list[str]]) -> None:
rows, cols = len(board), len(board[0])
def dfs(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != 'O':
return
board[r][c] = 'S' # mark as safe
dfs(r+1, c); dfs(r-1, c); dfs(r, c+1); dfs(r, c-1)
# Mark all O's connected to any border O as safe
for r in range(rows):
for c in range(cols):
if (r == 0 or r == rows-1 or c == 0 or c == cols-1) and board[r][c] == 'O':
dfs(r, c)
# Flip: O → X (surrounded), S → O (safe), X stays X
for r in range(rows):
for c in range(cols):
if board[r][c] == 'O':
board[r][c] = 'X'
elif board[r][c] == 'S':
board[r][c] = 'O'
board = [["X","X","X","X"],["X","O","O","X"],["X","X","O","X"],["X","O","X","X"]]
solve(board)
for row in board:
print(row)
# ['X','X','X','X'], ['X','X','X','X'], ['X','X','X','X'], ['X','O','X','X']
board2 = [["X"]]
solve(board2)
print(board2) # [['X']]
Complexity
- Time:
O(M * N)— each cell visited at most once during DFS - Space:
O(M * N)worst-case recursion stack
Common Pitfalls
Forgetting to process all four borders. The border O’s are on all four sides: top row, bottom row, left column, right column. It’s easy to handle corners twice — that’s fine, the DFS will return immediately since the cell is already marked 'S'.
Processing interior O’s directly. Only trigger DFS from border O’s. Interior O’s get marked transitively if they’re connected to a border O.
Choosing a unique safe marker. Use a character that doesn’t appear in the original board (like 'S' or 'T') to avoid confusion. Restore it in the final pass.
Open the Lock
Difficulty: Medium Source: NeetCode
Problem
You have a lock in front of you with 4 circular wheels. Each wheel has 10 slots:
'0'through'9'. The wheels can rotate freely and wrap around (i.e.,'9'goes to'0'and vice versa).The lock initially starts at
"0000", representing all wheels at'0'.You are given a list of
deadendsand atargetstring. Return the minimum total number of turns required to open the lock, or return-1if it is impossible.Example 1: Input:
deadends = ["0201","0101","0102","1212","2002"],target = "0202"Output:6Example 2: Input:
deadends = ["8888"],target = "0009"Output:1Example 3: Input:
deadends = ["0000"],target = "8888"Output:-1Constraints:
1 <= deadends.length <= 500deadends[i].length == 4target.length == 4targetwill not be indeadendstarget != "0000"
Prerequisites
Before attempting this problem, you should be comfortable with:
- BFS for shortest path — minimum turns = minimum moves in a graph
- State-space search — each unique 4-digit string is a node in the graph
- Neighbor generation — at each state, there are 8 possible transitions (each of 4 wheels ±1)
1. Brute Force (BFS without optimization)
Intuition
Model this as a graph problem: each 4-digit string is a node, and two nodes are connected if they differ by exactly one digit’s rotation. We want the shortest path from "0000" to target. BFS gives shortest path in an unweighted graph. The only twist is avoiding deadends.
Algorithm
- If
"0000"is in deadends, return-1. - Use BFS starting from
"0000"with distance 0. - At each state, generate 8 neighbors (4 wheels × 2 directions).
- Skip visited states and deadends.
- Return the distance when
targetis reached, or-1if queue empties.
Solution
from collections import deque
def openLock(deadends: list[str], target: str) -> int:
dead = set(deadends)
if "0000" in dead:
return -1
if target == "0000":
return 0
visited = {"0000"}
queue = deque([("0000", 0)])
while queue:
state, turns = queue.popleft()
if state == target:
return turns
for i in range(4):
digit = int(state[i])
for delta in [1, -1]:
new_digit = (digit + delta) % 10
new_state = state[:i] + str(new_digit) + state[i+1:]
if new_state not in visited and new_state not in dead:
visited.add(new_state)
queue.append((new_state, turns + 1))
return -1
print(openLock(["0201","0101","0102","1212","2002"], "0202")) # 6
print(openLock(["8888"], "0009")) # 1
print(openLock(["0000"], "8888")) # -1
Complexity
- Time:
O(10^4 * 4 * 2)— at most 10,000 states, each generating 8 neighbors - Space:
O(10^4)for visited set and queue
2. Bidirectional BFS
Intuition
Standard BFS expands outward from the start. Bidirectional BFS expands simultaneously from both the start ("0000") and the goal (target). When the two frontiers meet, we’ve found the shortest path. In practice this can halve the search space — instead of exploring a ball of radius d, you explore two balls of radius d/2.
Algorithm
- Maintain two frontiers:
begin(from start) andend(from target). - At each step, expand the smaller frontier: generate all neighbors, skip visited/dead.
- If a new neighbor appears in the other frontier, return the current turn count + 1.
- Use two separate visited sets
visited_beginandvisited_end.
Solution
def openLock(deadends: list[str], target: str) -> int:
dead = set(deadends)
if "0000" in dead or target in dead:
return -1
if target == "0000":
return 0
def neighbors(state):
result = []
for i in range(4):
d = int(state[i])
for delta in [1, -1]:
result.append(state[:i] + str((d + delta) % 10) + state[i+1:])
return result
begin = {"0000"}
end = {target}
visited = {"0000", target}
turns = 0
while begin and end:
# Always expand the smaller frontier
if len(begin) > len(end):
begin, end = end, begin
next_begin = set()
for state in begin:
for nb in neighbors(state):
if nb in end:
return turns + 1
if nb not in visited and nb not in dead:
visited.add(nb)
next_begin.add(nb)
begin = next_begin
turns += 1
return -1
print(openLock(["0201","0101","0102","1212","2002"], "0202")) # 6
print(openLock(["8888"], "0009")) # 1
print(openLock(["0000"], "8888")) # -1
print(openLock([], "0001")) # 1
Complexity
- Time:
O(10^4)but with smaller constant due to bidirectional expansion - Space:
O(10^4)
Common Pitfalls
Starting state in deadends. If "0000" is a deadend, we can’t even begin — return -1 immediately.
Neighbor generation with wrapping. Digit 9 + 1 = 0 and digit 0 - 1 = 9. Use (digit + delta) % 10 to handle wrapping correctly.
Marking visited before enqueuing. Add new states to visited when generating neighbors, not when dequeuing. This prevents the same state from being enqueued multiple times.
Course Schedule
Difficulty: Medium Source: NeetCode
Problem
There are a total of
numCoursescourses you have to take, labeled from0tonumCourses - 1. You are given an arrayprerequisiteswhereprerequisites[i] = [ai, bi]indicates that you must take coursebifirst if you want to take courseai.Return
trueif you can finish all courses. Otherwise, returnfalse.Example 1: Input:
numCourses = 2, prerequisites = [[1,0]]Output:trueExample 2: Input:
numCourses = 2, prerequisites = [[1,0],[0,1]]Output:falseConstraints:
1 <= numCourses <= 20000 <= prerequisites.length <= 5000prerequisites[i].length == 2- All prerequisite pairs are unique
- No self-loops
Prerequisites
Before attempting this problem, you should be comfortable with:
- Directed graphs — modeling courses as nodes and prerequisites as directed edges
- Cycle detection in directed graphs — the core problem reduces to this
- Topological sort — a valid ordering only exists if no cycle is present
1. DFS with Three-Color Cycle Detection
Intuition
Build a directed graph from prerequisites (edge from b to a means “take b before a”). The courses can be completed if and only if this graph has no cycle. DFS can detect cycles using three states: WHITE (unvisited), GRAY (currently in the DFS stack — part of the current path), and BLACK (fully processed). If DFS reaches a GRAY node, we’ve found a cycle.
Algorithm
- Build adjacency list from prerequisites.
- Mark all nodes as
WHITE(0). - For each unvisited node, run DFS:
- Mark it
GRAY(1) when entering. - For each neighbor: if
GRAY, cycle found — returnFalse; ifWHITE, recurse. - Mark it
BLACK(2) when done.
- Mark it
- Return
Trueif no cycle found.
Solution
def canFinish(numCourses: int, prerequisites: list[list[int]]) -> bool:
graph = [[] for _ in range(numCourses)]
for a, b in prerequisites:
graph[b].append(a) # b must come before a
# 0 = unvisited, 1 = in-stack (GRAY), 2 = done (BLACK)
state = [0] * numCourses
def dfs(node):
if state[node] == 1: # cycle detected
return False
if state[node] == 2: # already processed, safe
return True
state[node] = 1 # mark as in-stack
for neighbor in graph[node]:
if not dfs(neighbor):
return False
state[node] = 2 # mark as done
return True
for course in range(numCourses):
if not dfs(course):
return False
return True
print(canFinish(2, [[1,0]])) # True
print(canFinish(2, [[1,0],[0,1]])) # False
print(canFinish(5, [[1,0],[2,1],[3,2],[4,3]])) # True (chain, no cycle)
print(canFinish(3, [[1,0],[2,1],[0,2]])) # False (cycle)
Complexity
- Time:
O(V + E)where V = numCourses, E = len(prerequisites) - Space:
O(V + E)for graph and recursion stack
2. Topological Sort (Kahn’s BFS Algorithm)
Intuition
A directed graph is acyclic (a DAG) if and only if it has a valid topological ordering. Kahn’s algorithm builds this ordering using in-degrees: repeatedly pick nodes with in-degree 0 (no remaining prerequisites), remove them, and decrement their neighbors’ in-degrees. If we can process all numCourses nodes this way, no cycle exists. If the queue empties before processing all nodes, there’s a cycle.
Algorithm
- Compute in-degree for each node.
- Enqueue all nodes with in-degree 0.
- While queue is non-empty: dequeue a node, increment
processedcount, decrement neighbors’ in-degrees. Enqueue neighbors that reach in-degree 0. - Return
processed == numCourses.
Solution
from collections import deque
def canFinish(numCourses: int, prerequisites: list[list[int]]) -> bool:
graph = [[] for _ in range(numCourses)]
in_degree = [0] * numCourses
for a, b in prerequisites:
graph[b].append(a)
in_degree[a] += 1
queue = deque(course for course in range(numCourses) if in_degree[course] == 0)
processed = 0
while queue:
course = queue.popleft()
processed += 1
for neighbor in graph[course]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return processed == numCourses
print(canFinish(2, [[1,0]])) # True
print(canFinish(2, [[1,0],[0,1]])) # False
print(canFinish(1, [])) # True
print(canFinish(3, [[1,0],[2,1],[0,2]])) # False
Complexity
- Time:
O(V + E) - Space:
O(V + E)
Common Pitfalls
Edge direction. prerequisites[i] = [a, b] means b → a (take b first). When building the adjacency list, make sure you add an edge from b to a, not the other way around.
Rerunning DFS on already-processed nodes. If state[node] == 2 (BLACK), the node was already confirmed cycle-free — return True immediately without processing it again. Skipping this check causes redundant work and potential stack overflows.
Kahn’s: missing isolated nodes. Nodes with no prerequisites start with in-degree 0 and must be seeded into the initial queue. If you forget them, processed won’t reach numCourses even when no cycle exists.
Course Schedule II
Difficulty: Medium Source: NeetCode
Problem
There are a total of
numCoursescourses you have to take, labeled from0tonumCourses - 1. You are given an arrayprerequisiteswhereprerequisites[i] = [ai, bi]indicates that you must take coursebifirst if you want to take courseai.Return the ordering of courses you should take to finish all courses. If there are many valid answers, return any of them. If it is impossible to finish all courses, return an empty array.
Example 1: Input:
numCourses = 2, prerequisites = [[1,0]]Output:[0,1]Example 2: Input:
numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]Output:[0,2,1,3]or[0,1,2,3]Constraints:
1 <= numCourses <= 20000 <= prerequisites.length <= 5000- All pairs are unique, no self-loops
Prerequisites
Before attempting this problem, you should be comfortable with:
- Course Schedule I (LeetCode 207) — the cycle detection version of this problem
- Topological sort — ordering nodes in a DAG such that all prerequisites come before dependents
- Kahn’s algorithm (BFS) — the standard iterative approach to topological sorting
1. DFS Topological Sort
Intuition
DFS-based topological sort: run DFS on each node, and after fully exploring all of a node’s descendants, add it to the front of the result. This is because in a topological order, a node must come before all nodes it points to — so once we know all “after” nodes are processed, we can place the current node.
Use three states to detect cycles: 0 = unvisited, 1 = in-stack, 2 = done. If we hit a 1 state, there’s a cycle — return [].
Algorithm
- Build the adjacency list.
- For each unvisited node, run DFS. Append to
orderafter all children are processed. - If a cycle is detected at any point, return
[]. - Return
orderreversed (since we append post-order).
Solution
def findOrder(numCourses: int, prerequisites: list[list[int]]) -> list[int]:
graph = [[] for _ in range(numCourses)]
for a, b in prerequisites:
graph[b].append(a)
state = [0] * numCourses # 0=unvisited, 1=in-stack, 2=done
order = []
def dfs(node):
if state[node] == 1:
return False # cycle
if state[node] == 2:
return True # already processed
state[node] = 1
for neighbor in graph[node]:
if not dfs(neighbor):
return False
state[node] = 2
order.append(node)
return True
for course in range(numCourses):
if not dfs(course):
return []
return order[::-1] # reverse post-order = topological order
print(findOrder(2, [[1,0]])) # [0, 1]
print(findOrder(4, [[1,0],[2,0],[3,1],[3,2]])) # [0, 1, 2, 3] or [0, 2, 1, 3]
print(findOrder(2, [[1,0],[0,1]])) # [] (cycle)
print(findOrder(1, [])) # [0]
Complexity
- Time:
O(V + E) - Space:
O(V + E)for graph, state array, and order list
2. Kahn’s Algorithm (BFS Topological Sort)
Intuition
Kahn’s algorithm builds the topological order iteratively. Nodes with in-degree 0 have no prerequisites — they can be taken first. Remove them, decrement their neighbors’ in-degrees, and enqueue any neighbor that now has in-degree 0. The order in which nodes are dequeued is a valid topological ordering. If we can’t process all nodes, there’s a cycle.
Algorithm
- Compute in-degrees. Enqueue all nodes with in-degree 0.
- While queue is non-empty: dequeue
course, add toorder. For each neighbor, decrement in-degree; if 0, enqueue. - Return
orderiflen(order) == numCourses, else[].
Solution
from collections import deque
def findOrder(numCourses: int, prerequisites: list[list[int]]) -> list[int]:
graph = [[] for _ in range(numCourses)]
in_degree = [0] * numCourses
for a, b in prerequisites:
graph[b].append(a)
in_degree[a] += 1
queue = deque(c for c in range(numCourses) if in_degree[c] == 0)
order = []
while queue:
course = queue.popleft()
order.append(course)
for neighbor in graph[course]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return order if len(order) == numCourses else []
print(findOrder(2, [[1,0]])) # [0, 1]
print(findOrder(4, [[1,0],[2,0],[3,1],[3,2]])) # [0, 2, 1, 3] (one valid order)
print(findOrder(2, [[1,0],[0,1]])) # []
print(findOrder(3, [])) # [0, 1, 2] (any order)
Complexity
- Time:
O(V + E) - Space:
O(V + E)
Common Pitfalls
DFS: appending before all children are processed. In DFS topological sort, you append the node to order after the recursive call returns (post-order), not before. Pre-order would give you the wrong order.
Reversing the DFS result. DFS appends nodes in reverse topological order (last course first), so you need order[::-1]. Kahn’s BFS naturally gives the correct forward order.
Checking len(order) == numCourses. This is how you detect cycles in Kahn’s algorithm. If a cycle exists, some nodes will never reach in-degree 0 and will never be added to order.
Graph Valid Tree
Difficulty: Medium Source: NeetCode
Problem
Given
nnodes labeled from0ton - 1and a list of undirected edges (each edge is a pair of nodes), write a function to check whether these edges make up a valid tree.A valid tree must be: connected (all nodes reachable from any node) and acyclic (no cycles).
Example 1: Input:
n = 5, edges = [[0,1],[0,2],[0,3],[1,4]]Output:trueExample 2: Input:
n = 5, edges = [[0,1],[1,2],[2,3],[1,3],[1,4]]Output:falseConstraints:
1 <= n <= 20000 <= edges.length <= 5000edges[i].length == 20 <= ai, bi < n- No self-loops or repeated edges
Prerequisites
Before attempting this problem, you should be comfortable with:
- Tree properties — a tree with n nodes has exactly n-1 edges and is connected
- Union-Find — for cycle detection in undirected graphs
- DFS / BFS — for checking connectivity
1. DFS (Check Connected + Acyclic)
Intuition
A valid tree with n nodes has exactly n-1 edges (necessary but not sufficient alone — you also need connectivity). So first check len(edges) == n - 1. If that passes, just check connectivity: run DFS from node 0 and verify all nodes are reachable. If both conditions hold, it’s a valid tree.
Why does n-1 edges + connectivity imply no cycles? Because a connected graph with n nodes and n-1 edges is always a tree.
Algorithm
- If
len(edges) != n - 1, returnFalse. - Build adjacency list for the undirected graph.
- Run DFS from node 0, tracking visited nodes.
- Return
Trueif allnnodes were visited.
Solution
def validTree(n: int, edges: list[list[int]]) -> bool:
if len(edges) != n - 1:
return False
graph = [[] for _ in range(n)]
for a, b in edges:
graph[a].append(b)
graph[b].append(a)
visited = set()
def dfs(node):
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs(neighbor)
dfs(0)
return len(visited) == n
print(validTree(5, [[0,1],[0,2],[0,3],[1,4]])) # True
print(validTree(5, [[0,1],[1,2],[2,3],[1,3],[1,4]])) # False (cycle)
print(validTree(1, [])) # True (single node)
print(validTree(2, [])) # False (disconnected)
Complexity
- Time:
O(V + E) - Space:
O(V + E)for graph and visited set
2. Union-Find (Cycle Detection)
Intuition
Union-Find is a natural fit for undirected cycle detection. For each edge (a, b): if a and b are already in the same component, adding this edge would create a cycle — return False. Otherwise, merge their components. After processing all edges, check that we ended up with exactly one component (connectivity check).
Algorithm
- Initialize Union-Find with
nnodes. - For each edge
(a, b):- If
find(a) == find(b), cycle detected — returnFalse. - Otherwise,
union(a, b).
- If
- Return
Trueif all edges processed successfully (andlen(edges) == n-1ensures connectivity).
Solution
def validTree(n: int, edges: list[list[int]]) -> bool:
parent = list(range(n))
rank = [0] * n
def find(x):
if parent[x] != x:
parent[x] = find(parent[x]) # path compression
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False # cycle!
# Union by rank
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
return True
for a, b in edges:
if not union(a, b):
return False
# Check connectivity: exactly one component
roots = {find(i) for i in range(n)}
return len(roots) == 1
print(validTree(5, [[0,1],[0,2],[0,3],[1,4]])) # True
print(validTree(5, [[0,1],[1,2],[2,3],[1,3],[1,4]])) # False
print(validTree(1, [])) # True
print(validTree(3, [[0,1]])) # False (disconnected)
Complexity
- Time:
O(E * α(N))— nearly O(E) with path compression and union by rank - Space:
O(N)for parent and rank arrays
Common Pitfalls
Necessary vs sufficient conditions. Having n-1 edges alone doesn’t guarantee a tree (could be disconnected + have a cycle). Connectivity alone doesn’t guarantee a tree (could have cycles). You need both. The cleanest check: n-1 edges + connected = tree.
Undirected DFS: tracking parent. In standard DFS cycle detection for undirected graphs, you need to track the parent node to avoid incorrectly flagging the edge you came from as a “back edge”. However, the n-1 edges shortcut avoids this entirely.
Union-Find: checking one component. After Union-Find, make sure all nodes belong to a single component. The len(edges) == n-1 check is equivalent but checking roots explicitly handles edge cases like n=1, edges=[] more clearly.
Course Schedule IV
Difficulty: Medium Source: NeetCode
Problem
There are a total of
numCoursescourses you have to take, labeled from0tonumCourses - 1. You are given an arrayprerequisiteswhereprerequisites[i] = [ai, bi]indicates that you must take courseaifirst if you want to take coursebi.You are also given an array
querieswherequeries[j] = [uj, vj]. For thej-th query, you should answer whether courseujis a prerequisite of coursevjor not.Return a boolean array
answer, whereanswer[j]is the answer to thej-th query.Example 1: Input:
numCourses = 2, prerequisites = [[1,0]], queries = [[0,1],[1,0]]Output:[false, true]Example 2: Input:
numCourses = 3, prerequisites = [[1,2],[1,0],[2,0]], queries = [[1,0],[1,2]]Output:[true, true]Constraints:
2 <= numCourses <= 1000 <= prerequisites.length <= (numCourses * (numCourses - 1) / 2)prerequisites[i].length == 2, no duplicates, no self-loops1 <= queries.length <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Graph reachability — can node u reach node v via directed edges?
- Topological sort — processing nodes in dependency order
- Floyd-Warshall — all-pairs reachability in a directed graph
1. BFS for Each Query
Intuition
For each query (u, v), run BFS from u on the directed graph and check if v is reachable. Simple and correct, but O(Q * (V + E)) which can be slow for many queries.
Algorithm
- Build adjacency list from prerequisites.
- For each query
(u, v), run BFS fromu. - Return
Trueifvwas reached, elseFalse.
Solution
from collections import deque
def checkIfPrerequisite(numCourses: int, prerequisites: list[list[int]], queries: list[list[int]]) -> list[bool]:
graph = [[] for _ in range(numCourses)]
for a, b in prerequisites:
graph[a].append(b)
def can_reach(src, dst):
visited = {src}
queue = deque([src])
while queue:
node = queue.popleft()
if node == dst:
return True
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return False
return [can_reach(u, v) for u, v in queries]
print(checkIfPrerequisite(2, [[1,0]], [[0,1],[1,0]])) # [False, True]
print(checkIfPrerequisite(3, [[1,2],[1,0],[2,0]], [[1,0],[1,2]])) # [True, True]
Complexity
- Time:
O(Q * (V + E))where Q = number of queries - Space:
O(V + E)
2. Floyd-Warshall Reachability (Optimal for Many Queries)
Intuition
Precompute all-pairs reachability using a boolean matrix reach[i][j] = True if course i is a prerequisite (direct or indirect) of course j. This is essentially Floyd-Warshall adapted for reachability instead of distance.
Build the matrix: first mark direct prerequisites. Then, for each intermediate node k, update: if i can reach k and k can reach j, then i can reach j. After precomputation, each query is answered in O(1).
Algorithm
- Initialize
reach[i][j] = Falsefor all pairs. - For each direct prerequisite
(a, b): setreach[a][b] = True. - For each
kfrom 0 to n-1: for eachi, for eachj: ifreach[i][k]andreach[k][j], setreach[i][j] = True. - Answer each query
(u, v)withreach[u][v].
Solution
def checkIfPrerequisite(numCourses: int, prerequisites: list[list[int]], queries: list[list[int]]) -> list[bool]:
n = numCourses
reach = [[False] * n for _ in range(n)]
# Direct prerequisites
for a, b in prerequisites:
reach[a][b] = True
# Floyd-Warshall for transitive reachability
for k in range(n):
for i in range(n):
for j in range(n):
if reach[i][k] and reach[k][j]:
reach[i][j] = True
return [reach[u][v] for u, v in queries]
print(checkIfPrerequisite(2, [[1,0]], [[0,1],[1,0]])) # [False, True]
print(checkIfPrerequisite(3, [[1,2],[1,0],[2,0]], [[1,0],[1,2]])) # [True, True]
print(checkIfPrerequisite(4, [[0,1],[1,2],[2,3]], [[0,3],[3,0]])) # [True, False]
Complexity
- Time:
O(V³ + Q)— V³ for Floyd-Warshall, Q for answering queries - Space:
O(V²)for the reachability matrix
3. Topological Sort + DFS Reachability
Intuition
Process nodes in topological order. For each node, compute its full reachability set (the set of nodes it can reach). A node’s reachability is the union of all its direct prerequisites’ reachability sets, plus those direct prerequisites themselves. Processing in topological order ensures that when we process a node, all its prerequisites are already fully computed.
Solution
from collections import deque
def checkIfPrerequisite(numCourses: int, prerequisites: list[list[int]], queries: list[list[int]]) -> list[bool]:
n = numCourses
graph = [[] for _ in range(n)]
in_degree = [0] * n
prereqs = [set() for _ in range(n)] # prereqs[v] = set of courses that are prerequisites of v
for a, b in prerequisites:
graph[a].append(b)
in_degree[b] += 1
# Kahn's topological sort
queue = deque(i for i in range(n) if in_degree[i] == 0)
while queue:
node = queue.popleft()
for neighbor in graph[node]:
prereqs[neighbor].add(node)
prereqs[neighbor] |= prereqs[node] # propagate transitive prerequisites
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return [u in prereqs[v] for u, v in queries]
print(checkIfPrerequisite(2, [[1,0]], [[0,1],[1,0]])) # [False, True]
print(checkIfPrerequisite(3, [[1,2],[1,0],[2,0]], [[1,0],[1,2]])) # [True, True]
Complexity
- Time:
O(V² + E + Q)— set union at each node can be O(V) - Space:
O(V²)for the prereqs sets
Common Pitfalls
Edge direction in prerequisites. prerequisites[i] = [a, b] means a must come before b — so the edge goes from a to b. Don’t accidentally reverse it when building the graph.
Floyd-Warshall loop order. The k (intermediate) loop must be the outermost. If you put i or j first, you’ll miss some transitive paths because you’ll be checking for intermediates that haven’t been fully processed yet.
Self-reachability. A node is not considered a prerequisite of itself unless explicitly stated. reach[i][i] = False by default, which is correct for this problem.
Number of Connected Components in an Undirected Graph
Difficulty: Medium Source: NeetCode
Problem
There is an undirected graph with
nnodes. There is also anedgesarray, whereedges[i] = [a, b]means that there is an edge between nodeaand nodebin the graph.Return the total number of connected components in that graph.
Example 1: Input:
n = 3, edges = [[0,1],[0,2]]Output:1Example 2: Input:
n = 6, edges = [[0,1],[1,2],[2,3],[4,5]]Output:2Constraints:
1 <= n <= 1000 <= edges.length <= n * (n - 1) / 2
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS / BFS — for traversing and marking nodes in the same component
- Union-Find — for efficiently merging connected components
1. DFS Marking
Intuition
For each unvisited node, start a DFS that visits all connected nodes and marks them. Each DFS call from an unvisited node represents a new connected component. Count those calls.
Algorithm
- Build adjacency list.
- Track
visitedset. - For each node from 0 to n-1: if unvisited, increment count and run DFS to mark all connected nodes.
- Return count.
Solution
def countComponents(n: int, edges: list[list[int]]) -> int:
graph = [[] for _ in range(n)]
for a, b in edges:
graph[a].append(b)
graph[b].append(a)
visited = set()
def dfs(node):
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs(neighbor)
count = 0
for node in range(n):
if node not in visited:
dfs(node)
count += 1
return count
print(countComponents(3, [[0,1],[0,2]])) # 1
print(countComponents(6, [[0,1],[1,2],[2,3],[4,5]])) # 2
print(countComponents(5, [])) # 5 (all isolated)
print(countComponents(1, [])) # 1
Complexity
- Time:
O(V + E) - Space:
O(V + E)
2. Union-Find
Intuition
Initialize each node as its own component. For each edge, union the two endpoints. If they were in different components, the total component count decreases by 1. Track the count directly during union operations.
Algorithm
- Initialize
parent[i] = i,count = n. - For each edge
(a, b): iffind(a) != find(b), union them and decrementcount. - Return
count.
Solution
def countComponents(n: int, edges: list[list[int]]) -> int:
parent = list(range(n))
rank = [0] * n
count = n
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
nonlocal count
px, py = find(x), find(y)
if px == py:
return
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
count -= 1
for a, b in edges:
union(a, b)
return count
print(countComponents(3, [[0,1],[0,2]])) # 1
print(countComponents(6, [[0,1],[1,2],[2,3],[4,5]])) # 2
print(countComponents(5, [])) # 5
print(countComponents(5, [[0,1],[1,2],[3,4],[2,3]])) # 1
Complexity
- Time:
O(E * α(N))— nearly O(E) with path compression + union by rank - Space:
O(N)
Common Pitfalls
Undirected edges: add both directions. In the adjacency list, each edge (a, b) must add b to graph[a] and a to graph[b]. Forgetting one direction splits connected components incorrectly.
Starting count at n. In Union-Find, start with count = n (every node is its own component) and decrement each time you successfully merge two distinct components.
DFS on isolated nodes. Nodes with no edges are still valid components. Iterating for node in range(n) ensures every node gets counted, even isolated ones.
Redundant Connection
Difficulty: Medium Source: NeetCode
Problem
In this problem, a tree is an undirected graph that is connected and has no cycles.
You are given a graph that started as a tree with
nnodes labeled from1ton, with one additional edge added. The added edge has two different vertices chosen from1ton, and was not an edge that already existed. The graph is represented as an arrayedgeswhereedges[i] = [ai, bi]indicates that there is an edge between nodesaiandbiin the graph.Return an edge that can be removed so that the resulting graph is a tree of
nnodes. If there are multiple answers, return the answer that occurs last in the input.Example 1: Input:
edges = [[1,2],[1,3],[2,3]]Output:[2,3]Example 2: Input:
edges = [[1,2],[2,3],[3,4],[1,4],[1,5]]Output:[1,4]Constraints:
n == edges.length3 <= n <= 1000edges[i].length == 21 <= ai < bi <= edges.length- No repeated edges, no self-loops
Prerequisites
Before attempting this problem, you should be comfortable with:
- Union-Find — the most natural and efficient solution
- Cycle detection in undirected graphs — DFS can also find the redundant edge
1. Brute Force (DFS Cycle Detection)
Intuition
Process edges one by one. Before adding each edge, check if the two nodes are already connected (via existing edges). If they are, this edge would form a cycle — return it. We can check connectivity with DFS on the current graph.
Algorithm
- Maintain the current edge set as an adjacency list.
- For each new edge
(a, b): run DFS fromato check ifbis already reachable.- If yes, return
[a, b](this edge is redundant). - If no, add the edge to the graph.
- If yes, return
Solution
def findRedundantConnection(edges: list[list[int]]) -> list[int]:
n = len(edges)
graph = [[] for _ in range(n + 1)]
def is_connected(src, dst, visited):
if src == dst:
return True
visited.add(src)
for neighbor in graph[src]:
if neighbor not in visited:
if is_connected(neighbor, dst, visited):
return True
return False
for a, b in edges:
if is_connected(a, b, set()):
return [a, b]
graph[a].append(b)
graph[b].append(a)
return []
print(findRedundantConnection([[1,2],[1,3],[2,3]])) # [2, 3]
print(findRedundantConnection([[1,2],[2,3],[3,4],[1,4],[1,5]])) # [1, 4]
Complexity
- Time:
O(N²)— one DFS per edge, each O(N) - Space:
O(N)
2. Union-Find (Optimal)
Intuition
Union-Find is perfect here. Process edges in order. For each edge (a, b), try to union a and b. If they already belong to the same component (i.e., find(a) == find(b)), this edge creates a cycle — return it. Since the problem guarantees only one extra edge, the first such edge is the answer. Because we process in order and return the first problematic edge, the returned edge is automatically the last one in input order that creates a cycle (since there’s only one cycle, any approach returns the same edge).
Algorithm
- Initialize Union-Find with nodes 1 to n.
- For each edge
(a, b)in order:- If
find(a) == find(b), return[a, b]. - Otherwise,
union(a, b).
- If
Solution
def findRedundantConnection(edges: list[list[int]]) -> list[int]:
n = len(edges)
parent = list(range(n + 1))
rank = [0] * (n + 1)
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False # already connected — cycle!
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
return True
for a, b in edges:
if not union(a, b):
return [a, b]
return []
print(findRedundantConnection([[1,2],[1,3],[2,3]])) # [2, 3]
print(findRedundantConnection([[1,2],[2,3],[3,4],[1,4],[1,5]])) # [1, 4]
print(findRedundantConnection([[1,2],[2,3],[1,3]])) # [1, 3]
Complexity
- Time:
O(N * α(N))— nearly O(N) with path compression - Space:
O(N)
Common Pitfalls
1-indexed nodes. The problem labels nodes from 1 to n, so allocate arrays of size n+1 and ignore index 0.
Returning the last occurrence. The problem says to return the answer that occurs last in the input. With only one extra edge, there’s exactly one edge that creates a cycle — returning it directly satisfies this requirement.
Trying to union after detecting the cycle. Once you find that find(a) == find(b), return immediately — don’t union. The edge that creates the cycle is the redundant one; unioning would corrupt the structure.
Accounts Merge
Difficulty: Medium Source: NeetCode
Problem
Given a list of
accountswhere each elementaccounts[i]is a list of strings, whereaccounts[i][0]is a name, and the rest of the elements are emails representing emails of the account.Now, we would like to merge these accounts. Two accounts definitely belong to the same person if there is some common email to both accounts. Note that even if two accounts have the same name, they may belong to different people as people could have the same name. A person can have any number of accounts initially, but all of their accounts definitely have the same name.
After merging the accounts, return the accounts in any order. Each account should be in the format
[name, email1, email2, ...]where emails are sorted in alphabetical order.Example 1: Input:
accounts = [["John","johnsmith@mail.com","john_newyork@mail.com"],["John","johnnybravo@mail.com"],["John","johnsmith@mail.com","john00@mail.com"],["Mary","mary@mail.com"]]Output:[["John","john00@mail.com","john_newyork@mail.com","johnsmith@mail.com"],["John","johnnybravo@mail.com"],["Mary","mary@mail.com"]]Constraints:
1 <= accounts.length <= 10002 <= accounts[i].length <= 101 <= accounts[i][j].length <= 30accounts[i][0]consists of English lettersaccounts[i][j](for j > 0) is a valid email
Prerequisites
Before attempting this problem, you should be comfortable with:
- Union-Find — merging connected email groups efficiently
- DFS on a graph — treating emails as nodes connected by shared accounts
- Hash Maps — mapping emails to accounts or Union-Find parents
1. DFS (Graph of Emails)
Intuition
Build a graph where emails are nodes, and two emails are connected by an edge if they appear in the same account. Then, each connected component in this graph represents one person. For each component, collect all emails, sort them, and prepend the account name.
Algorithm
- Build an adjacency list: for each account, connect all its emails to the first email of that account (acts as a hub). Also track
email_to_name. - Run DFS from each unvisited email, collecting the full connected component.
- Sort each component’s emails and prepend the name.
Solution
from collections import defaultdict
def accountsMerge(accounts: list[list[str]]) -> list[list[str]]:
graph = defaultdict(set)
email_to_name = {}
for account in accounts:
name = account[0]
first_email = account[1]
for email in account[1:]:
email_to_name[email] = name
graph[first_email].add(email)
graph[email].add(first_email)
visited = set()
result = []
def dfs(email, component):
visited.add(email)
component.append(email)
for neighbor in graph[email]:
if neighbor not in visited:
dfs(neighbor, component)
for email in email_to_name:
if email not in visited:
component = []
dfs(email, component)
result.append([email_to_name[email]] + sorted(component))
return result
accounts = [["John","johnsmith@mail.com","john_newyork@mail.com","john00@mail.com"],
["John","johnnybravo@mail.com"],
["John","johnsmith@mail.com","john_newyork@mail.com"],
["Mary","mary@mail.com"]]
for row in sorted(accountsMerge(accounts)):
print(row)
Complexity
- Time:
O(N * K * log(N * K))where N = accounts, K = avg emails per account (sorting dominates) - Space:
O(N * K)for graph and visited set
2. Union-Find
Intuition
Use Union-Find on email addresses. For each account, union all emails with the first email (or with each other). After processing all accounts, group emails by their root (component representative). Sort each group and prepend the account name.
Algorithm
- Map each unique email to an integer index.
- Initialize Union-Find on indices.
- For each account, union all email indices with the first email’s index.
- Group email indices by their
findroot. - For each group, sort emails and prepend the name.
Solution
from collections import defaultdict
def accountsMerge(accounts: list[list[str]]) -> list[list[str]]:
parent = {}
email_to_name = {}
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
parent[find(x)] = find(y)
# Initialize Union-Find for all emails
for account in accounts:
name = account[0]
for email in account[1:]:
if email not in parent:
parent[email] = email
email_to_name[email] = name
# Union emails within the same account
for account in accounts:
first = account[1]
for email in account[2:]:
union(first, email)
# Group emails by root
components = defaultdict(list)
for email in parent:
components[find(email)].append(email)
result = []
for root, emails in components.items():
result.append([email_to_name[root]] + sorted(emails))
return result
accounts = [["John","johnsmith@mail.com","john_newyork@mail.com","john00@mail.com"],
["John","johnnybravo@mail.com"],
["John","johnsmith@mail.com","john_newyork@mail.com"],
["Mary","mary@mail.com"]]
for row in sorted(accountsMerge(accounts)):
print(row)
Complexity
- Time:
O(N * K * α(N * K) + N * K * log(N * K))— nearly O(N * K * log(N * K)) due to sorting - Space:
O(N * K)for Union-Find and grouping
Common Pitfalls
Same name ≠ same person. Two accounts with the same name are only merged if they share an email. Don’t use the name as the merge key.
Email appears in multiple accounts. An email can link two accounts that otherwise have no other email in common. The graph/Union-Find approach handles this naturally since the shared email creates the connection.
Sorting emails after merging. The problem requires emails sorted alphabetically within each account. Don’t forget to sort after grouping.
Evaluate Division
Difficulty: Medium Source: NeetCode
Problem
You are given an array of variable pairs
equationsand an array of real numbersvalues, whereequations[i] = [Ai, Bi]andvalues[i]represent the equationAi / Bi = values[i]. EachAiorBiis a string that represents a single variable.You are also given some
queries, wherequeries[j] = [Cj, Dj]represents thej-th query where you must find the result ofCj / Dj.Return the results of all queries. If the answer to a query does not exist, return
-1.0.Example 1: Input:
equations = [["a","b"],["b","c"]],values = [2.0,3.0],queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]]Output:[6.0, 0.5, -1.0, 1.0, -1.0]Constraints:
1 <= equations.length <= 20equations[i].length == 21 <= Ai.length, Bi.length <= 5values[i] > 0.01 <= queries.length <= 20
Prerequisites
Before attempting this problem, you should be comfortable with:
- Weighted directed graphs — edges have a value (the division result)
- BFS / DFS for path finding — multiplying weights along a path gives the query answer
- Graph modeling — translating a math problem into a graph traversal
1. BFS for Each Query
Intuition
Model the variables as nodes and the equations as directed weighted edges. If a / b = 2, add edges a → b with weight 2.0 and b → a with weight 1/2.0 = 0.5. To answer a / c, find a path from a to c and multiply all edge weights along the path. BFS is perfect for this since any path gives the correct answer (the graph is consistent).
Algorithm
- Build a weighted adjacency list from equations and values.
- For each query
(src, dst):- If either is unknown, return
-1.0. - If
src == dst, return1.0. - BFS from
src, tracking the accumulated product. Stop whendstis found.
- If either is unknown, return
- Return
-1.0ifdstis unreachable.
Solution
from collections import defaultdict, deque
def calcEquation(equations: list[list[str]], values: list[float], queries: list[list[str]]) -> list[float]:
graph = defaultdict(dict)
for (a, b), val in zip(equations, values):
graph[a][b] = val
graph[b][a] = 1.0 / val
def bfs(src, dst):
if src not in graph or dst not in graph:
return -1.0
if src == dst:
return 1.0
visited = {src}
queue = deque([(src, 1.0)])
while queue:
node, product = queue.popleft()
for neighbor, weight in graph[node].items():
if neighbor == dst:
return product * weight
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, product * weight))
return -1.0
return [bfs(src, dst) for src, dst in queries]
equations = [["a","b"],["b","c"]]
values = [2.0, 3.0]
queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]]
print(calcEquation(equations, values, queries))
# [6.0, 0.5, -1.0, 1.0, -1.0]
Complexity
- Time:
O(Q * (V + E))where Q = queries, V = unique variables, E = equations - Space:
O(V + E)for the graph
2. Floyd-Warshall on Division
Intuition
If we know a/b and b/c, we can compute a/c = (a/b) * (b/c). This is exactly the Floyd-Warshall transitive closure idea applied to multiplicative ratios. Precompute all pairwise division results using three nested loops, then answer each query in O(1).
Algorithm
- Initialize
dist[a][b] = valfor each equation,dist[a][a] = 1.0for all known variables,dist[b][a] = 1/val. - For each intermediate
k, for eachi, for eachj: ifdist[i][k]anddist[k][j]are known, setdist[i][j] = dist[i][k] * dist[k][j]. - Answer each query with
dist[src][dst]or-1.0if unknown.
Solution
from collections import defaultdict
def calcEquation(equations: list[list[str]], values: list[float], queries: list[list[str]]) -> list[float]:
# Collect all unique variables
variables = set()
for a, b in equations:
variables.add(a)
variables.add(b)
# Initialize ratio table
ratio = defaultdict(dict)
for var in variables:
ratio[var][var] = 1.0
for (a, b), val in zip(equations, values):
ratio[a][b] = val
ratio[b][a] = 1.0 / val
# Floyd-Warshall: compute transitive ratios
for k in variables:
for i in variables:
for j in variables:
if k in ratio[i] and j in ratio[k]:
ratio[i][j] = ratio[i][k] * ratio[k][j]
result = []
for src, dst in queries:
if src not in ratio or dst not in ratio[src]:
result.append(-1.0)
else:
result.append(ratio[src][dst])
return result
equations = [["a","b"],["b","c"]]
values = [2.0, 3.0]
queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]]
print(calcEquation(equations, values, queries))
# [6.0, 0.5, -1.0, 1.0, -1.0]
Complexity
- Time:
O(V³ + Q)— V³ for Floyd-Warshall, Q for queries - Space:
O(V²)for the ratio table
Common Pitfalls
Unknown variables in queries. If src or dst doesn’t appear in any equation, return -1.0. Check src not in graph before doing any traversal.
Self-queries. a / a = 1.0 if a is a known variable. But x / x = -1.0 if x is never mentioned in equations — it’s unknown, not 1.0.
Adding reverse edges. For each equation a / b = k, add both graph[a][b] = k and graph[b][a] = 1/k. Without reverse edges, many queries will fail even when the answer is computable.
Minimum Height Trees
Difficulty: Medium Source: NeetCode
Problem
A tree is an undirected graph in which any two vertices are connected by exactly one path. In other words, any connected graph without simple cycles is a tree.
Given a tree of
nnodes labeled from0ton - 1, and an array ofn - 1edges whereedges[i] = [ai, bi]indicates that there is an undirected edge between the two nodesaiandbiin the tree, you can choose any node of the tree as the root. When you select a nodexas the root, the resulting tree has heighth. Among all possible rooted trees, those with minimum height are called minimum height trees (MHTs).Return a list of all MHTs’ root labels.
Example 1: Input:
n = 4, edges = [[1,0],[1,2],[1,3]]Output:[1]Example 2: Input:
n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]Output:[3,4]Constraints:
1 <= n <= 2 * 10^4edges.length == n - 10 <= ai, bi < n- All the pairs
(ai, bi)are distinct
Prerequisites
Before attempting this problem, you should be comfortable with:
- Tree properties — the center(s) of a tree minimize the height
- Topological peeling (leaf removal) — iteratively removing leaf nodes finds the center
- BFS — used to process nodes level by level during peeling
1. Brute Force (BFS from every node)
Intuition
For each node, run BFS to compute the height of the tree rooted at that node. Track the minimum height and collect all nodes that achieve it. This is correct but O(N²) — fine for small trees but too slow for the constraints.
Algorithm
- For each node from 0 to n-1, run BFS to get the tree height rooted there.
- Collect all nodes with minimum height.
- Return them.
Solution
from collections import deque
def findMinHeightTrees(n: int, edges: list[list[int]]) -> list[int]:
if n == 1:
return [0]
graph = [[] for _ in range(n)]
for a, b in edges:
graph[a].append(b)
graph[b].append(a)
def bfs_height(root):
visited = {root}
queue = deque([root])
height = 0
while queue:
for _ in range(len(queue)):
node = queue.popleft()
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
height += 1
return height - 1
min_h = float('inf')
result = []
for node in range(n):
h = bfs_height(node)
if h < min_h:
min_h = h
result = [node]
elif h == min_h:
result.append(node)
return result
print(findMinHeightTrees(4, [[1,0],[1,2],[1,3]])) # [1]
print(findMinHeightTrees(6, [[3,0],[3,1],[3,2],[3,4],[5,4]])) # [3, 4]
print(findMinHeightTrees(1, [])) # [0]
Complexity
- Time:
O(N²)— BFS from each of N nodes - Space:
O(N)
2. Topological Peeling (Leaf Trimming)
Intuition
The key insight: the root(s) of the minimum height tree are always the center node(s) of the tree — there are at most 2 such centers. We find them by repeatedly “peeling” leaf nodes (degree 1) from the outside inward. This is like topological sort for trees. We stop when 1 or 2 nodes remain — those are the answer.
Think of it like finding the “middle” of a tree the same way you find the middle of a linked list — shrink from both ends simultaneously.
Algorithm
- Build adjacency list, compute degree for each node.
- Initialize a queue with all leaf nodes (degree == 1).
- Remaining = n. While remaining > 2:
- Process all current leaves, decrement remaining count.
- For each leaf’s neighbor, decrement their degree. If degree reaches 1, they’re the new leaves — enqueue.
- Return whatever nodes remain.
Solution
from collections import deque
def findMinHeightTrees(n: int, edges: list[list[int]]) -> list[int]:
if n == 1:
return [0]
if n == 2:
return [0, 1]
graph = [set() for _ in range(n)]
for a, b in edges:
graph[a].add(b)
graph[b].add(a)
# Start with all leaf nodes
leaves = deque(node for node in range(n) if len(graph[node]) == 1)
remaining = n
while remaining > 2:
remaining -= len(leaves)
new_leaves = deque()
while leaves:
leaf = leaves.popleft()
# There's exactly one neighbor for a leaf
neighbor = next(iter(graph[leaf]))
graph[neighbor].discard(leaf)
if len(graph[neighbor]) == 1:
new_leaves.append(neighbor)
leaves = new_leaves
return list(leaves)
print(findMinHeightTrees(4, [[1,0],[1,2],[1,3]])) # [1]
print(findMinHeightTrees(6, [[3,0],[3,1],[3,2],[3,4],[5,4]])) # [3, 4]
print(findMinHeightTrees(1, [])) # [0]
print(findMinHeightTrees(2, [[0,1]])) # [0, 1]
print(findMinHeightTrees(7, [[0,1],[1,2],[1,3],[2,4],[3,5],[4,6]])) # [1, 2]
Complexity
- Time:
O(N)— each node and edge processed at most once - Space:
O(N)
Common Pitfalls
Edge case n=1. A single node with no edges has height 0 and is trivially the answer. Handle it before building the adjacency list.
Stopping condition: remaining > 2. There can be at most 2 center nodes (a tree’s “diameter” has at most 2 midpoints). Stop when 1 or 2 nodes remain, not when the queue is empty — the queue might be empty but remaining could still be > 2 for pathological inputs (though this shouldn’t happen with valid input).
Using a set for adjacency. Using set instead of list for the adjacency lets you do graph[neighbor].discard(leaf) efficiently. With a list, removal is O(degree).
Word Ladder
Difficulty: Hard Source: NeetCode
Problem
A transformation sequence from word
beginWordto wordendWordusing a dictionarywordListis a sequence of wordsbeginWord -> s1 -> s2 -> ... -> sksuch that:
- Every adjacent pair of words differs by a single letter.
- Every
sifor1 <= i <= kis inwordList. Note thatbeginWorddoes not need to be inwordList.sk == endWordGiven two words,
beginWordandendWord, and a dictionarywordList, return the number of words in the shortest transformation sequence frombeginWordtoendWord, or0if no such sequence exists.Example 1: Input:
beginWord = "hit",endWord = "cog",wordList = ["hot","dot","dog","lot","log","cog"]Output:5(sequence: hit → hot → dot → dog → cog)Example 2: Input:
beginWord = "hit",endWord = "cog",wordList = ["hot","dot","dog","lot","log"]Output:0(cog not in wordList)Constraints:
1 <= beginWord.length <= 10endWord.length == beginWord.length1 <= wordList.length <= 5000- All words have the same length and consist of lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- BFS for shortest path — minimum transformation steps = shortest path in a graph
- State-space search — each word is a node; edges connect words differing by one letter
- Set lookups — converting wordList to a set for O(1) neighbor checking
1. Brute Force BFS (Generate all neighbors by comparison)
Intuition
BFS from beginWord. At each step, find all words in wordList that differ by exactly one letter from the current word. This is correct but O(N * L) per node for neighbor generation, where N = wordList size and L = word length.
Algorithm
- Convert
wordListto a set for fast lookup. - BFS with
(current_word, steps). Start at(beginWord, 1). - For each current word, check every word in the word set that differs by one letter.
- Return
stepswhenendWordis reached.
Solution
from collections import deque
def ladderLength(beginWord: str, endWord: str, wordList: list[str]) -> int:
word_set = set(wordList)
if endWord not in word_set:
return 0
def neighbors(word):
result = []
for candidate in word_set:
diff = sum(a != b for a, b in zip(word, candidate))
if diff == 1:
result.append(candidate)
return result
visited = {beginWord}
queue = deque([(beginWord, 1)])
while queue:
word, steps = queue.popleft()
for neighbor in neighbors(word):
if neighbor == endWord:
return steps + 1
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, steps + 1))
return 0
print(ladderLength("hit", "cog", ["hot","dot","dog","lot","log","cog"])) # 5
print(ladderLength("hit", "cog", ["hot","dot","dog","lot","log"])) # 0
Complexity
- Time:
O(N² * L)— for each of N nodes, compare against all N words - Space:
O(N)
2. BFS with Letter-by-Letter Neighbor Generation
Intuition
Instead of comparing every word in the list, generate candidate neighbors by replacing each letter of the current word with 'a' through 'z' and checking if the result is in the word set. This generates at most L * 26 candidates per word — much faster than scanning all N words.
Algorithm
- Convert
wordListto a set. - BFS from
beginWord. For each word, generate neighbors by replacing each of L positions with each of 26 letters. - Keep only neighbors that are in
word_setand not yet visited. - Return step count when
endWordis reached, or 0 if queue empties.
Solution
from collections import deque
def ladderLength(beginWord: str, endWord: str, wordList: list[str]) -> int:
word_set = set(wordList)
if endWord not in word_set:
return 0
visited = {beginWord}
queue = deque([(beginWord, 1)])
while queue:
word, steps = queue.popleft()
if word == endWord:
return steps
for i in range(len(word)):
for c in 'abcdefghijklmnopqrstuvwxyz':
if c == word[i]:
continue
candidate = word[:i] + c + word[i+1:]
if candidate in word_set and candidate not in visited:
visited.add(candidate)
queue.append((candidate, steps + 1))
return 0
print(ladderLength("hit", "cog", ["hot","dot","dog","lot","log","cog"])) # 5
print(ladderLength("hit", "cog", ["hot","dot","dog","lot","log"])) # 0
print(ladderLength("a", "c", ["a","b","c"])) # 2
Complexity
- Time:
O(N * L * 26)— for each of N words in the BFS, try L positions × 26 letters - Space:
O(N * L)for visited set and queue
3. Bidirectional BFS
Intuition
Same as the Open the Lock trick: expand from both beginWord and endWord simultaneously. When the two frontiers collide, we’ve found the shortest path. This cuts the search space roughly in half in practice.
Solution
from collections import deque
def ladderLength(beginWord: str, endWord: str, wordList: list[str]) -> int:
word_set = set(wordList)
if endWord not in word_set:
return 0
def get_neighbors(word):
result = []
for i in range(len(word)):
for c in 'abcdefghijklmnopqrstuvwxyz':
if c != word[i]:
candidate = word[:i] + c + word[i+1:]
if candidate in word_set:
result.append(candidate)
return result
begin_visited = {beginWord: 1}
end_visited = {endWord: 1}
begin_queue = deque([beginWord])
end_queue = deque([endWord])
while begin_queue and end_queue:
# Expand begin frontier
word = begin_queue.popleft()
steps = begin_visited[word]
for neighbor in get_neighbors(word):
if neighbor in end_visited:
return steps + end_visited[neighbor]
if neighbor not in begin_visited:
begin_visited[neighbor] = steps + 1
begin_queue.append(neighbor)
# Expand end frontier
word = end_queue.popleft()
steps = end_visited[word]
for neighbor in get_neighbors(word):
if neighbor in begin_visited:
return steps + begin_visited[neighbor]
if neighbor not in end_visited:
end_visited[neighbor] = steps + 1
end_queue.append(neighbor)
return 0
print(ladderLength("hit", "cog", ["hot","dot","dog","lot","log","cog"])) # 5
print(ladderLength("hit", "cog", ["hot","dot","dog","lot","log"])) # 0
Complexity
- Time:
O(N * L * 26)with smaller constant due to bidirectional search - Space:
O(N * L)
Common Pitfalls
Checking endWord in wordList. Unlike beginWord, endWord must be in the word list. If it’s not, immediately return 0.
Counting the begin word. The problem asks for the number of words in the sequence, not the number of steps. The sequence hit → hot → dot → dog → cog has 5 words but 4 steps. Start BFS with steps = 1 (counting the starting word) or remember to add 1 at the end.
Marking visited before enqueuing. Add words to visited when enqueuing, not when dequeuing. Otherwise, the same word can be enqueued multiple times from different paths, causing redundant work or incorrect results.
Advanced Graph Problems
Practice problems for weighted shortest paths, minimum spanning trees, ordering constraints, and graph-heavy hybrid techniques.
Path with Minimum Effort
Difficulty: Medium Source: NeetCode
Problem
You are given an
m x ninteger matrixheightswhereheights[i][j]represents the height of cell(i, j). You are situated at the top-left cell(0, 0)and want to reach the bottom-right cell(m-1, n-1).The effort of a route is the maximum absolute difference in heights between two consecutive cells of the route. Return the minimum effort required to travel from
(0, 0)to(m-1, n-1).Example 1: Input:
heights = [[1,2,2],[3,8,2],[5,3,5]]Output:2Example 2: Input:
heights = [[1,2,3],[3,8,4],[5,3,5]]Output:1Constraints:
m == heights.lengthn == heights[i].length1 <= m, n <= 1001 <= heights[i][j] <= 10^6
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dijkstra’s Algorithm — shortest path in a weighted graph using a min-heap
- Graph on a Grid — treating cells as nodes and edges as the transitions between them
- Heapq — Python’s priority queue module
1. Brute Force
Intuition
Try every possible path from the top-left to the bottom-right using DFS. For each path, track the maximum absolute difference seen so far. After exploring all paths, return the minimum of all those maximum differences. This is correct but exponentially slow because the number of paths in a grid is huge.
Algorithm
- Use DFS from
(0, 0), passing the current maximum effort seen on this path. - At the destination, record the effort if it’s the best seen.
- Use a visited set to avoid revisiting cells on the same path.
- Return the global minimum.
Solution
def minimumEffortPath_brute(heights):
m, n = len(heights), len(heights[0])
min_effort = float('inf')
def dfs(r, c, current_max, visited):
nonlocal min_effort
if r == m - 1 and c == n - 1:
min_effort = min(min_effort, current_max)
return
visited.add((r, c))
for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n and (nr, nc) not in visited:
effort = abs(heights[nr][nc] - heights[r][c])
dfs(nr, nc, max(current_max, effort), visited)
visited.remove((r, c))
dfs(0, 0, 0, set())
return min_effort
print(minimumEffortPath_brute([[1, 2, 2], [3, 8, 2], [5, 3, 5]])) # 2
print(minimumEffortPath_brute([[1, 2, 3], [3, 8, 4], [5, 3, 5]])) # 1
print(minimumEffortPath_brute([[1, 2, 1, 1, 1]])) # 0
Complexity
- Time:
O(m * n * 2^(m*n))— exponential due to all paths - Space:
O(m * n)— recursion stack and visited set
2. Dijkstra’s Algorithm
Intuition
Think of the grid as a weighted graph. Each cell is a node, and moving from one cell to an adjacent cell has an edge weight equal to the absolute height difference. We want the path where the maximum edge weight is minimized — this is called the “minimax path” problem. Dijkstra works perfectly here: instead of minimizing the total cost, we minimize the maximum edge cost seen so far on any path to each cell.
The key insight is that dist[r][c] stores the minimum possible “max effort” to reach (r, c). When we pop a cell from the heap, the value we pop is the best effort to reach that cell, so we can use it exactly like standard Dijkstra.
Algorithm
- Initialize
distgrid with infinity; setdist[0][0] = 0. - Push
(0, 0, 0)—(effort, row, col)— into a min-heap. - While the heap is not empty:
- Pop the cell with the smallest current effort.
- If this is the destination, return the effort.
- If the popped effort is greater than
dist[r][c], skip (stale entry). - For each neighbor, compute
new_effort = max(current_effort, abs_diff). - If
new_effort < dist[nr][nc], update and push to heap.
- Return
dist[m-1][n-1].
Solution
import heapq
def minimumEffortPath(heights):
m, n = len(heights), len(heights[0])
dist = [[float('inf')] * n for _ in range(m)]
dist[0][0] = 0
heap = [(0, 0, 0)] # (effort, row, col)
while heap:
effort, r, c = heapq.heappop(heap)
if r == m - 1 and c == n - 1:
return effort
if effort > dist[r][c]:
continue
for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n:
new_effort = max(effort, abs(heights[nr][nc] - heights[r][c]))
if new_effort < dist[nr][nc]:
dist[nr][nc] = new_effort
heapq.heappush(heap, (new_effort, nr, nc))
return dist[m - 1][n - 1]
print(minimumEffortPath([[1, 2, 2], [3, 8, 2], [5, 3, 5]])) # 2
print(minimumEffortPath([[1, 2, 3], [3, 8, 4], [5, 3, 5]])) # 1
print(minimumEffortPath([[1, 2, 1, 1, 1]])) # 0
print(minimumEffortPath([[3]])) # 0
Complexity
- Time:
O(m * n * log(m * n))— each cell is pushed/popped from the heap at most once (in practice) - Space:
O(m * n)— dist grid and heap
Common Pitfalls
Using total sum instead of max difference. The effort of a route is the maximum absolute difference along the path, not the sum. Make sure you use max(current_effort, abs_diff) when updating, not current_effort + abs_diff.
Forgetting the stale entry check. In Dijkstra with lazy deletion, a cell can be pushed multiple times. Always check if effort > dist[r][c]: continue to skip outdated entries.
Off-by-one on grid bounds. Double-check 0 <= nr < m and 0 <= nc < n — a common source of index errors on grid problems.
Network Delay Time
Difficulty: Medium Source: NeetCode
Problem
You are given a network of
nnodes, labeled1ton. You are also giventimes, a list of travel times as directed edgestimes[i] = (ui, vi, wi), whereuiis the source node,viis the target node, andwiis the time it takes for a signal to travel from source to target.We will send a signal from a given node
k. Return the minimum time it takes for allnnodes to receive the signal. If it is impossible for all nodes to receive the signal, return-1.Example 1: Input:
times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2Output:2Example 2: Input:
times = [[1,2,1]], n = 2, k = 1Output:1Constraints:
1 <= k <= n <= 1001 <= times.length <= 6000times[i].length == 31 <= ui, vi <= n0 <= wi <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dijkstra’s Algorithm — finding shortest paths in a weighted directed graph
- Adjacency List — representing a graph efficiently
- Heapq — Python’s min-heap module
1. Brute Force (Bellman-Ford)
Intuition
Bellman-Ford relaxes all edges up to n-1 times. After all relaxations, dist[v] holds the shortest path from k to every node v. If any node still has infinite distance, it’s unreachable. This is simpler to reason about than Dijkstra but slower.
Algorithm
- Initialize
dist[k] = 0anddist[all others] = infinity. - Repeat
n-1times: for each edge(u, v, w), ifdist[u] + w < dist[v], updatedist[v]. - If any
dist[v]is still infinity, return-1. - Otherwise, return
max(dist.values()).
Solution
def networkDelayTime_bf(times, n, k):
dist = {i: float('inf') for i in range(1, n + 1)}
dist[k] = 0
for _ in range(n - 1):
for u, v, w in times:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
max_dist = max(dist.values())
return max_dist if max_dist < float('inf') else -1
print(networkDelayTime_bf([[2, 1, 1], [2, 3, 1], [3, 4, 1]], 4, 2)) # 2
print(networkDelayTime_bf([[1, 2, 1]], 2, 1)) # 1
print(networkDelayTime_bf([[1, 2, 1]], 2, 2)) # -1
Complexity
- Time:
O(n * E)where E is the number of edges - Space:
O(n)
2. Dijkstra’s Algorithm
Intuition
Since all edge weights are non-negative, Dijkstra is the right tool here. We want the shortest path from source k to every other node. The answer is simply the maximum of all those shortest distances — because the signal must reach the last node to arrive. If any node is unreachable, we return -1.
Think of it like water flowing through pipes: the time for all nodes to receive the signal is determined by the slowest (furthest) one.
Algorithm
- Build an adjacency list from the edge list.
- Initialize
dist = {k: 0}and push(0, k)into the min-heap. - While the heap is not empty:
- Pop
(time, node). - If
time > dist[node], skip (stale). - For each neighbor
(v, w), ifdist[node] + w < dist.get(v, inf), update and push.
- Pop
- If
len(dist) < n, return-1(some nodes unreachable). - Return
max(dist.values()).
Solution
import heapq
from collections import defaultdict
def networkDelayTime(times, n, k):
graph = defaultdict(list)
for u, v, w in times:
graph[u].append((v, w))
dist = {}
heap = [(0, k)] # (time, node)
while heap:
time, node = heapq.heappop(heap)
if node in dist:
continue # already found shortest path to this node
dist[node] = time
for v, w in graph[node]:
if v not in dist:
heapq.heappush(heap, (time + w, v))
return max(dist.values()) if len(dist) == n else -1
print(networkDelayTime([[2, 1, 1], [2, 3, 1], [3, 4, 1]], 4, 2)) # 2
print(networkDelayTime([[1, 2, 1]], 2, 1)) # 1
print(networkDelayTime([[1, 2, 1]], 2, 2)) # -1
print(networkDelayTime([[1, 2, 1], [2, 3, 2]], 3, 1)) # 3
Complexity
- Time:
O(E log V)where E is edges and V is nodes - Space:
O(V + E)for the graph and heap
Common Pitfalls
Returning max distance without checking reachability. If a node is disconnected from k, it will never appear in dist. Always check len(dist) == n before returning.
Using 0-indexed nodes when the problem uses 1-indexed. This problem uses nodes labeled 1 to n. Be careful not to accidentally use 0-indexed arrays.
Confusing “time to reach all nodes” with “time to reach destination”. The answer is max(dist.values()), not just the distance to a single target — every node needs the signal.
Reconstruct Itinerary
Difficulty: Hard Source: NeetCode
Problem
You are given a list of airline
ticketsrepresented by pairs of departure and arrival airports[from, to]. Reconstruct the itinerary in order. All of the tickets must be used once and the itinerary must begin with"JFK".If there are multiple valid itineraries, return the one with the smallest lexicographic order when read as a single string.
Example 1: Input:
tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]Output:["JFK","MUC","LHR","SFO","SJC"]Example 2: Input:
tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]Output:["JFK","ATL","JFK","SFO","ATL","SFO"]Constraints:
1 <= tickets.length <= 300tickets[i].length == 2tickets[i][0], tickets[i][1]consist of uppercase English letters- All tickets form at least one valid itinerary
Prerequisites
Before attempting this problem, you should be comfortable with:
- Eulerian Path — a path in a graph that visits every edge exactly once
- Hierholzer’s Algorithm — efficient algorithm for finding Eulerian paths
- DFS on a Graph — depth-first traversal used to build the path
1. Brute Force (Backtracking)
Intuition
Try all possible orderings of tickets using backtracking. Start from JFK, try each unused ticket from the current airport, recurse, and backtrack if we get stuck. Among all valid complete itineraries, return the lexicographically smallest. This works but is very slow because there are up to n! orderings to explore.
Algorithm
- Sort tickets so destinations are in lexicographic order.
- Use DFS/backtracking from “JFK”, marking tickets as used.
- If all tickets are used, record the itinerary.
- Return the lexicographically smallest result.
Solution
def findItinerary_brute(tickets):
from collections import defaultdict
tickets.sort()
graph = defaultdict(list)
for src, dst in tickets:
graph[src].append(dst)
result = []
path = ["JFK"]
def backtrack():
if len(path) == len(tickets) + 1:
result.append(path[:])
return True
src = path[-1]
for i, dst in enumerate(graph[src]):
if dst is not None:
graph[src][i] = None # mark used
path.append(dst)
if backtrack():
return True
path.pop()
graph[src][i] = dst # restore
return False
backtrack()
return result[0] if result else []
t1 = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
print(findItinerary_brute(t1)) # ["JFK","MUC","LHR","SFO","SJC"]
t2 = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
print(findItinerary_brute(t2)) # ["JFK","ATL","JFK","SFO","ATL","SFO"]
Complexity
- Time:
O(n! * n)worst case — exponential backtracking - Space:
O(n)— recursion depth
2. Hierholzer’s Algorithm (Eulerian Path)
Intuition
This problem is asking for an Eulerian path — a path that visits every edge (ticket) exactly once. Hierholzer’s algorithm finds Eulerian paths efficiently. The key insight is: do a DFS, and only add a node to the result after all its outgoing edges have been explored. This “post-order” addition produces the itinerary in reverse.
Why does this work? If we hit a dead end early (used all edges from a node), that node must come later in the final itinerary (or be the last stop). By reversing the post-order result, we get the correct forward itinerary.
To guarantee lexicographic order, sort the adjacency lists in reverse so we can use pop() from the end (which removes the smallest element).
Algorithm
- Build the adjacency list, sorting destinations in reverse order.
- Start DFS from “JFK”.
- While there are unvisited destinations from the current node, pop the next destination and recurse.
- After all neighbors are exhausted, append the current node to the result.
- Reverse the result at the end.
Solution
from collections import defaultdict
def findItinerary(tickets):
graph = defaultdict(list)
for src, dst in sorted(tickets, reverse=True):
graph[src].append(dst)
# Sorted in reverse so pop() gives lexicographically smallest first
result = []
def dfs(node):
while graph[node]:
next_node = graph[node].pop()
dfs(next_node)
result.append(node)
dfs("JFK")
return result[::-1]
t1 = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
print(findItinerary(t1)) # ["JFK","MUC","LHR","SFO","SJC"]
t2 = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
print(findItinerary(t2)) # ["JFK","ATL","JFK","SFO","ATL","SFO"]
t3 = [["JFK","KUL"],["JFK","NRT"],["NRT","JFK"]]
print(findItinerary(t3)) # ["JFK","NRT","JFK","KUL"]
Complexity
- Time:
O(E log E)— sorting the edges; DFS visits each edge once - Space:
O(E)— adjacency list and result
Common Pitfalls
Adding nodes to result too early. If you add a node when you first visit it (pre-order), you’ll get the wrong answer. The key to Hierholzer’s is post-order: only add a node after all its edges are used up.
Forgetting to reverse the result. Post-order DFS builds the path backwards. Always reverse before returning.
Not sorting in reverse before using pop(). Python’s list.pop() removes from the end. If you sort ascending and pop from the end, you get the largest element — the opposite of what you want. Sort in reverse so pop() gives the lexicographically smallest.
Min Cost to Connect All Points
Difficulty: Medium Source: NeetCode
Problem
You are given an array
pointsrepresenting integer coordinates of some points on a 2D-plane, wherepoints[i] = [xi, yi].The cost of connecting two points
[xi, yi]and[xj, yj]is the Manhattan distance between them:|xi - xj| + |yi - yj|.Return the minimum cost to make all points connected. All points are connected if there is exactly one simple path between any two points.
Example 1: Input:
points = [[0,0],[2,2],[3,10],[5,2],[7,0]]Output:20Example 2: Input:
points = [[3,12],[-2,5],[-4,1]]Output:18Constraints:
1 <= points.length <= 1000-10^6 <= xi, yi <= 10^6- All pairs
(xi, yi)are distinct
Prerequisites
Before attempting this problem, you should be comfortable with:
- Minimum Spanning Tree (MST) — a tree connecting all nodes with minimum total edge weight
- Prim’s Algorithm — greedy MST algorithm starting from a single node
- Kruskal’s Algorithm — MST by sorting edges and adding them greedily
- Union-Find — data structure for tracking connected components
1. Prim’s Algorithm
Intuition
This is a classic Minimum Spanning Tree problem. Every pair of points has an edge with cost equal to Manhattan distance, so we have a complete graph. We want the subset of edges that connects all points with minimum total cost — that’s the MST.
Prim’s algorithm builds the MST by starting from any node and greedily adding the cheapest edge that connects a new node to the current tree. We use a min-heap to always find the cheapest edge efficiently.
Algorithm
- Start from point
0. Mark it as visited. - Push all edges from point
0to the heap. - While not all points are in the MST:
- Pop the cheapest edge
(cost, next_point). - If
next_pointis already visited, skip. - Add
costto the total. Marknext_pointas visited. - Push all edges from
next_pointto unvisited points into the heap.
- Pop the cheapest edge
- Return total cost.
Solution
import heapq
def minCostConnectPoints_prim(points):
n = len(points)
visited = set()
heap = [(0, 0)] # (cost, point_index)
total = 0
while len(visited) < n:
cost, i = heapq.heappop(heap)
if i in visited:
continue
visited.add(i)
total += cost
for j in range(n):
if j not in visited:
dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])
heapq.heappush(heap, (dist, j))
return total
print(minCostConnectPoints_prim([[0,0],[2,2],[3,10],[5,2],[7,0]])) # 20
print(minCostConnectPoints_prim([[3,12],[-2,5],[-4,1]])) # 18
print(minCostConnectPoints_prim([[0,0],[1,1],[1,0],[-1,1]])) # 4
Complexity
- Time:
O(n² log n)— each of n² edges can be pushed/popped from the heap - Space:
O(n²)— heap can hold all edges
2. Kruskal’s Algorithm with Union-Find
Intuition
Kruskal’s takes a different approach: generate all possible edges, sort them by cost, then greedily add edges from cheapest to most expensive — but only if the two endpoints aren’t already connected (checked via Union-Find). Stop when all points are in one component.
Both Prim’s and Kruskal’s give the same MST weight, but Kruskal’s is more natural when you already have the edge list. Here we generate all n*(n-1)/2 edges upfront.
Algorithm
- Generate all pairs of points and compute Manhattan distances.
- Sort edges by distance.
- Initialize Union-Find with
ncomponents. - For each edge
(cost, i, j)in sorted order:- If
iandjare in different components, union them and addcost. - Stop when we’ve added
n-1edges (MST is complete).
- If
- Return total cost.
Solution
def minCostConnectPoints_kruskal(points):
n = len(points)
# Union-Find
parent = list(range(n))
rank = [0] * n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path compression
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
return True
# Generate all edges
edges = []
for i in range(n):
for j in range(i + 1, n):
dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])
edges.append((dist, i, j))
edges.sort()
total = 0
edges_used = 0
for cost, i, j in edges:
if union(i, j):
total += cost
edges_used += 1
if edges_used == n - 1:
break
return total
print(minCostConnectPoints_kruskal([[0,0],[2,2],[3,10],[5,2],[7,0]])) # 20
print(minCostConnectPoints_kruskal([[3,12],[-2,5],[-4,1]])) # 18
print(minCostConnectPoints_kruskal([[0,0],[1,1],[1,0],[-1,1]])) # 4
print(minCostConnectPoints_kruskal([[0,0]])) # 0
Complexity
- Time:
O(n² log n)— generating O(n²) edges and sorting them - Space:
O(n²)— edge list
Common Pitfalls
Forgetting that it’s a complete graph. Every point connects to every other point — you don’t need a predefined edge list. Compute Manhattan distances on the fly.
Double-counting edges in Kruskal’s. When generating pairs (i, j), only iterate j from i+1 to avoid adding each edge twice.
Stopping Prim’s too early. Check len(visited) < n, not len(visited) <= n. The loop should run until all n points are added.
Swim in Rising Water
Difficulty: Hard Source: NeetCode
Problem
You are given an
n x ninteger matrixgridwhere each valuegrid[i][j]represents the elevation at position(i, j).The rain starts to fall. At time
t, the depth of the water everywhere ist. You can swim from a square to another 4-directionally adjacent square if and only if the elevation of both squares individually is at mostt. You can swim infinite distances in zero time. Of course, you must stay within the boundaries of the grid during your swim.Return the least time until you can reach the bottom right square
(n - 1, n - 1)if you start at the top left square(0, 0).Example 1: Input:
grid = [[0,2],[1,3]]Output:3Example 2: Input:
grid = [[0,1,2,3,4],[24,23,22,21,5],[12,13,14,15,16],[11,17,18,19,20],[10,9,8,7,6]]Output:16Constraints:
n == grid.lengthn == grid[i].length1 <= n <= 500 <= grid[i][j] < n^2- Each value
grid[i][j]is unique
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dijkstra’s Algorithm — shortest path in a weighted graph
- Binary Search on Answer — searching for the optimal time value
- BFS/DFS — for checking connectivity at a given time
1. Binary Search + BFS
Intuition
Binary search on the answer: for a given time t, can we reach the bottom-right corner? A cell (r, c) is reachable at time t if grid[r][c] <= t. We need both the source and every cell along the path to have elevation <= t. Binary search over t from 0 to n*n - 1, and use BFS to check feasibility at each candidate.
Algorithm
- Binary search:
lo = grid[0][0],hi = n*n - 1. - For each
mid, run BFS from(0, 0)visiting only cells with value<= mid. - If
(n-1, n-1)is reachable, shrink tohi = mid. Elselo = mid + 1. - Return
lo.
Solution
from collections import deque
def swimInWater_binary(grid):
n = len(grid)
def can_reach(t):
if grid[0][0] > t:
return False
queue = deque([(0, 0)])
visited = {(0, 0)}
while queue:
r, c = queue.popleft()
if r == n - 1 and c == n - 1:
return True
for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n and (nr, nc) not in visited and grid[nr][nc] <= t:
visited.add((nr, nc))
queue.append((nr, nc))
return False
lo, hi = grid[0][0], n * n - 1
while lo < hi:
mid = (lo + hi) // 2
if can_reach(mid):
hi = mid
else:
lo = mid + 1
return lo
print(swimInWater_binary([[0, 2], [1, 3]])) # 3
print(swimInWater_binary([[0, 1, 2, 3, 4], [24, 23, 22, 21, 5],
[12, 13, 14, 15, 16], [11, 17, 18, 19, 20],
[10, 9, 8, 7, 6]])) # 16
Complexity
- Time:
O(n² log n²)=O(n² log n)— binary search × BFS - Space:
O(n²)— visited set and queue
2. Dijkstra’s Algorithm
Intuition
This is a minimax path problem — we want to minimize the maximum elevation we encounter along our path (since we need to wait until time t equals the max elevation in any cell we pass through). This maps perfectly to Dijkstra: the “cost” of moving to a neighbor is max(current_cost, grid[nr][nc]). We always expand the cell with the smallest current max elevation.
The answer is the cost to reach (n-1, n-1).
Algorithm
- Initialize
dist[0][0] = grid[0][0], all others to infinity. - Push
(grid[0][0], 0, 0)into the min-heap. - While heap is not empty:
- Pop
(cost, r, c). - If
(r, c)is the destination, returncost. - Skip if
cost > dist[r][c](stale). - For each neighbor, compute
new_cost = max(cost, grid[nr][nc]). - If
new_cost < dist[nr][nc], update and push.
- Pop
- Return
dist[n-1][n-1].
Solution
import heapq
def swimInWater(grid):
n = len(grid)
dist = [[float('inf')] * n for _ in range(n)]
dist[0][0] = grid[0][0]
heap = [(grid[0][0], 0, 0)] # (max_elevation, row, col)
while heap:
cost, r, c = heapq.heappop(heap)
if r == n - 1 and c == n - 1:
return cost
if cost > dist[r][c]:
continue
for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n:
new_cost = max(cost, grid[nr][nc])
if new_cost < dist[nr][nc]:
dist[nr][nc] = new_cost
heapq.heappush(heap, (new_cost, nr, nc))
return dist[n - 1][n - 1]
print(swimInWater([[0, 2], [1, 3]])) # 3
print(swimInWater([[0, 1, 2, 3, 4], [24, 23, 22, 21, 5],
[12, 13, 14, 15, 16], [11, 17, 18, 19, 20],
[10, 9, 8, 7, 6]])) # 16
print(swimInWater([[0]])) # 0
Complexity
- Time:
O(n² log n²)=O(n² log n)— each cell pushed/popped from heap at most once - Space:
O(n²)— dist grid and heap
Common Pitfalls
Confusing this with total-sum Dijkstra. The “cost” here is the maximum elevation seen so far, not the sum. Use max(cost, grid[nr][nc]) not addition.
Starting cost should be grid[0][0], not 0. You’re standing on cell (0, 0), so you need to wait until time grid[0][0] just to start. Initialize the heap with grid[0][0].
Checking destination mid-loop vs at the end. Both work, but checking when you pop from the heap (as done above) is slightly cleaner and can exit earlier.
Alien Dictionary
Difficulty: Hard Source: NeetCode
Problem
There is a new alien language that uses the English alphabet. However, the order among the letters is unknown to you.
You are given a list of strings
wordsfrom the alien language’s dictionary, where the strings inwordsare sorted lexicographically by the rules of this new language.Return a string of the unique letters in the new alien language sorted in the order as they appear in the alien language. If there is no solution, return
"". If there are multiple solutions, return any of them.Example 1: Input:
words = ["wrt","wrf","er","ett","rftt"]Output:"wertf"Example 2: Input:
words = ["z","x"]Output:"zx"Example 3: Input:
words = ["z","x","z"]Output:""(invalid — cycle detected)Constraints:
1 <= words.length <= 1001 <= words[i].length <= 100- All characters are lowercase English letters
- The dictionary is supposedly sorted
Prerequisites
Before attempting this problem, you should be comfortable with:
- Topological Sort — ordering nodes in a DAG such that all edges point forward
- Cycle Detection in Directed Graphs — using DFS with color states
- Graph Building from Constraints — inferring edges from pairwise comparisons
1. Brute Force (BFS Topological Sort — Kahn’s Algorithm)
Intuition
Compare adjacent words to discover ordering constraints: the first character where two consecutive words differ tells us which letter comes first in the alien alphabet. Build a directed graph from these constraints, then run a topological sort. If the graph has a cycle, no valid ordering exists.
Algorithm
- Collect all unique characters.
- Compare each pair of adjacent words: find the first differing character, add directed edge
(first[i] → second[i]). - If
words[i]is a prefix ofwords[i-1](and longer), return""— invalid. - Run Kahn’s BFS topological sort (by in-degree).
- If all nodes are in the result, return the order; else return
""(cycle).
Solution
from collections import defaultdict, deque
def alienOrder_kahn(words):
# Collect all unique characters
adj = defaultdict(set)
in_degree = {c: 0 for word in words for c in word}
for i in range(len(words) - 1):
w1, w2 = words[i], words[i + 1]
min_len = min(len(w1), len(w2))
# Edge case: w1 is longer and is a prefix of w2 — invalid
if len(w1) > len(w2) and w1[:min_len] == w2[:min_len]:
return ""
for j in range(min_len):
if w1[j] != w2[j]:
if w2[j] not in adj[w1[j]]:
adj[w1[j]].add(w2[j])
in_degree[w2[j]] += 1
break
# Kahn's BFS topological sort
queue = deque([c for c in in_degree if in_degree[c] == 0])
result = []
while queue:
c = queue.popleft()
result.append(c)
for neighbor in adj[c]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return "".join(result) if len(result) == len(in_degree) else ""
print(alienOrder_kahn(["wrt","wrf","er","ett","rftt"])) # "wertf"
print(alienOrder_kahn(["z","x"])) # "zx"
print(alienOrder_kahn(["z","x","z"])) # ""
print(alienOrder_kahn(["abc","ab"])) # ""
Complexity
- Time:
O(C)where C is total number of characters across all words - Space:
O(U + E)where U is unique characters and E is edges
2. DFS Topological Sort with Cycle Detection
Intuition
Same graph-building step, but use DFS for the topological sort. DFS-based topological sort appends a node to the result after all its descendants are processed (post-order). To detect cycles, track three states: unvisited, currently in stack (being visited), and fully processed.
If we ever reach a node that is “currently in stack”, we found a cycle — return "".
Algorithm
- Build the same directed graph as above.
- For each unvisited node, run DFS.
- Color nodes:
False= in current path (visiting),True= done. - If we visit a node that is in the current path, there’s a cycle.
- Reverse the post-order result for the correct topological order.
Solution
from collections import defaultdict
def alienOrder(words):
adj = defaultdict(set)
chars = {c for word in words for c in word}
for i in range(len(words) - 1):
w1, w2 = words[i], words[i + 1]
min_len = min(len(w1), len(w2))
if len(w1) > len(w2) and w1[:min_len] == w2[:min_len]:
return ""
for j in range(min_len):
if w1[j] != w2[j]:
adj[w1[j]].add(w2[j])
break
# visited: None = not seen, False = in current DFS path, True = done
visited = {}
result = []
def dfs(node):
if node in visited:
return visited[node] # True = no cycle, False = cycle found
visited[node] = False # mark as in current path
for neighbor in adj[node]:
if not dfs(neighbor):
return False
visited[node] = True
result.append(node)
return True
for c in chars:
if not dfs(c):
return ""
return "".join(reversed(result))
print(alienOrder(["wrt","wrf","er","ett","rftt"])) # "wertf"
print(alienOrder(["z","x"])) # "zx"
print(alienOrder(["z","x","z"])) # ""
print(alienOrder(["abc","ab"])) # ""
print(alienOrder(["z"])) # "z"
Complexity
- Time:
O(C)— building graph and DFS both visit each character once - Space:
O(U + E)— graph, visited map, result
Common Pitfalls
Comparing the wrong pair of words. Only adjacent words in the sorted list give ordering information. Non-adjacent words don’t directly tell you anything.
Missing the invalid prefix case. If words[i] is longer than words[i+1] and words[i+1] is a prefix of words[i], the input is invalid. For example, ["abc", "ab"] should return "".
Only one edge per adjacent pair. Stop comparing characters after the first difference — subsequent differences are not meaningful ordering constraints.
Cheapest Flights Within K Stops
Difficulty: Medium Source: NeetCode
Problem
There are
ncities connected by some number of flights. You are given an arrayflightswhereflights[i] = [fromi, toi, pricei]indicates that there is a flight from cityfromito citytoiwith costpricei.Given three integers
src,dst, andk, return the cheapest price fromsrctodstwith at mostkstops. If there is no such route, return-1.Example 1: Input:
n=4, flights=[[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], src=0, dst=3, k=1Output:700Example 2: Input:
n=3, flights=[[0,1,100],[1,2,100],[0,2,500]], src=0, dst=2, k=1Output:200Constraints:
1 <= n <= 1000 <= flights.length <= (n * (n-1) / 2)flights[i].length == 30 <= src, dst, k < nsrc != dst
Prerequisites
Before attempting this problem, you should be comfortable with:
- Bellman-Ford Algorithm — relaxing edges over multiple iterations
- BFS / Dijkstra — shortest path with state augmentation
- Dynamic Programming on Graphs — building up shortest paths iteration by iteration
1. Bellman-Ford (k+1 Iterations)
Intuition
Standard Dijkstra doesn’t work here because we have a constraint on the number of stops, not just the cost. Bellman-Ford is a great fit: each iteration relaxes all edges once, which corresponds to taking one more flight. After k+1 iterations (k stops means k+1 flights), dist[dst] gives us the cheapest cost.
The critical trick: use a copy of the previous iteration’s distances when updating, to prevent using more than one edge per iteration (otherwise you might chain multiple flights in a single round).
Algorithm
- Initialize
dist = [inf] * n,dist[src] = 0. - Repeat
k+1times:- Make a copy
temp = dist[:]. - For each flight
(u, v, price): ifdist[u] + price < temp[v], updatetemp[v]. - Set
dist = temp.
- Make a copy
- Return
dist[dst]if not infinity, else-1.
Solution
def findCheapestPrice_bf(n, flights, src, dst, k):
dist = [float('inf')] * n
dist[src] = 0
for _ in range(k + 1): # k stops = k+1 flights
temp = dist[:]
for u, v, price in flights:
if dist[u] != float('inf') and dist[u] + price < temp[v]:
temp[v] = dist[u] + price
dist = temp
return dist[dst] if dist[dst] != float('inf') else -1
flights1 = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]]
print(findCheapestPrice_bf(4, flights1, 0, 3, 1)) # 700
flights2 = [[0,1,100],[1,2,100],[0,2,500]]
print(findCheapestPrice_bf(3, flights2, 0, 2, 1)) # 200
print(findCheapestPrice_bf(3, flights2, 0, 2, 0)) # 500
Complexity
- Time:
O(k * E)where E is number of flights - Space:
O(n)— distance arrays
2. BFS with State (Node, Stops Remaining)
Intuition
Model this as a BFS where state = (current_city, stops_remaining). We track the cheapest cost to reach each (city, stops) pair. Use a min-heap to always process the cheapest option first (Dijkstra-style). This avoids exploring paths that are guaranteed to be more expensive.
The key difference from standard Dijkstra is that we can visit the same city multiple times — once for each different number of stops remaining — because fewer stops used might allow cheaper paths later.
Algorithm
- Build adjacency list from flights.
- Push
(0, src, k+1)—(cost, city, remaining_stops)— into min-heap. - Track
visited = {city: best_remaining_stops}to prune. - While heap is not empty:
- Pop
(cost, city, stops). - If
city == dst, returncost. - If
stops == 0, skip (no more flights allowed). - For each neighbor
(next, price):- If we can reach
(next, stops-1)cheaper than before, push to heap.
- If we can reach
- Pop
- Return
-1if destination not reached.
Solution
import heapq
from collections import defaultdict
def findCheapestPrice(n, flights, src, dst, k):
graph = defaultdict(list)
for u, v, price in flights:
graph[u].append((v, price))
# (cost, city, stops_remaining)
heap = [(0, src, k + 1)]
# best cost to reach city with at least `stops` remaining
visited = {} # city -> best stops remaining at that cost
while heap:
cost, city, stops = heapq.heappop(heap)
if city == dst:
return cost
if stops == 0:
continue
# Prune: if we've been here with >= stops remaining, skip
if city in visited and visited[city] >= stops:
continue
visited[city] = stops
for next_city, price in graph[city]:
heapq.heappush(heap, (cost + price, next_city, stops - 1))
return -1
flights1 = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]]
print(findCheapestPrice(4, flights1, 0, 3, 1)) # 700
flights2 = [[0,1,100],[1,2,100],[0,2,500]]
print(findCheapestPrice(3, flights2, 0, 2, 1)) # 200
print(findCheapestPrice(3, flights2, 0, 2, 0)) # 500
Complexity
- Time:
O(k * E * log(k * n))— heap operations with the stops dimension added - Space:
O(k * n)— state space
Common Pitfalls
Forgetting to copy distances in Bellman-Ford. If you update dist in-place during an iteration, you might chain multiple flights in one round. Always work from a snapshot (temp = dist[:]) and write into temp.
K stops vs K+1 flights confusion. k stops means k+1 edges (flights). Loop k+1 times in Bellman-Ford, or allow k+1 transitions in BFS.
Pruning too aggressively in Dijkstra approach. Unlike standard Dijkstra, you can revisit a city with fewer stops remaining — that might unlock cheaper paths later. The visited check should account for the number of stops, not just the city.
Find Critical and Pseudo-Critical Edges in MST
Difficulty: Hard Source: NeetCode
Problem
Given a weighted undirected connected graph with
nvertices numbered from0ton - 1, and an arrayedgeswhereedges[i] = [ai, bi, weighti]represents a bidirectional and weighted edge between nodesaiandbi.A minimum spanning tree (MST) is a subset of the graph’s edges that connects all vertices together without any cycles and with the minimum possible total edge weight.
Find all the critical and pseudo-critical edges in the MST of the given graph.
- Critical: removing this edge increases the MST weight (or makes it disconnected)
- Pseudo-critical: this edge can appear in some MST, but not all; including it forces some MST of the same weight
Return
[criticalEdges, pseudoCriticalEdges]as lists of edge indices.Example 1: Input:
n=5, edges=[[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]]Output:[[0,1],[2,3,4,5]]Constraints:
2 <= n <= 1001 <= edges.length <= min(200, n*(n-1)/2)edges[i].length == 30 <= ai < bi < n1 <= weighti <= 1000- All pairs
(ai, bi)are distinct
Prerequisites
Before attempting this problem, you should be comfortable with:
- Kruskal’s Algorithm — building MST by sorting edges and using Union-Find
- Union-Find — tracking connected components efficiently
- Brute Force Checking — re-running MST with/without specific edges
1. Brute Force (Naive Check Per Edge)
Intuition
For each edge, check if it’s critical or pseudo-critical by brute force: build MST without it (critical test) and build MST with it forced in (pseudo-critical test). Compare against the baseline MST weight. This is O(E² * α(n)) which is acceptable given the small constraints (E ≤ 200).
Algorithm
- Compute the baseline MST weight using Kruskal’s.
- For each edge
i:- Critical test: run Kruskal’s skipping edge
i. If resulting weight > baseline (or graph is disconnected), edgeiis critical. - Pseudo-critical test: force edge
iinto the MST first, then run Kruskal’s on remaining edges. If the total equals baseline, edgeiis pseudo-critical.
- Critical test: run Kruskal’s skipping edge
- An edge is pseudo-critical only if it’s not critical.
Solution
def findCriticalAndPseudoCriticalEdges(n, edges):
# Add original indices before sorting
indexed_edges = [(w, u, v, i) for i, (u, v, w) in enumerate(edges)]
indexed_edges.sort()
def make_uf():
parent = list(range(n))
rank = [0] * n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
return True
return find, union
def kruskal(skip=-1, force=-1):
find, union = make_uf()
weight = 0
count = 0
# Force an edge in first
if force != -1:
w, u, v, _ = indexed_edges[force]
union(u, v)
weight += w
count += 1
for idx, (w, u, v, orig) in enumerate(indexed_edges):
if idx == skip:
continue
if union(u, v):
weight += w
count += 1
return weight if count == n - 1 else float('inf')
baseline = kruskal()
critical, pseudo_critical = [], []
for i in range(len(indexed_edges)):
orig_idx = indexed_edges[i][3]
# Critical check: skip this edge
if kruskal(skip=i) > baseline:
critical.append(orig_idx)
# Pseudo-critical check: force this edge in
elif kruskal(force=i) == baseline:
pseudo_critical.append(orig_idx)
return [sorted(critical), sorted(pseudo_critical)]
n1 = 5
edges1 = [[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]]
print(findCriticalAndPseudoCriticalEdges(n1, edges1)) # [[0,1],[2,3,4,5]]
n2 = 4
edges2 = [[0,1,1],[1,2,1],[2,3,1],[0,3,1]]
print(findCriticalAndPseudoCriticalEdges(n2, edges2)) # [[],[0,1,2,3]]
Complexity
- Time:
O(E² * α(n))— for each edge, two MST runs - Space:
O(n + E)— Union-Find and edge list
2. Optimized Approach (Same Complexity, Cleaner Structure)
Intuition
The same approach as above, but structured more cleanly. The key insight is:
- An edge is critical if removing it makes the MST weight increase.
- An edge is pseudo-critical if including it as a forced edge still gives MST weight equal to baseline.
- An edge that is critical cannot be pseudo-critical (it’s already in every MST).
The structure of running Kruskal’s multiple times is hard to avoid for this problem without much more complex algorithms. Given E ≤ 200, the brute force is perfectly fine.
Solution
def findCriticalAndPseudoCriticalEdges_v2(n, edges):
E = len(edges)
indexed = sorted(range(E), key=lambda i: edges[i][2]) # sort by weight
def find(parent, x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(parent, rank, x, y):
px, py = find(parent, x), find(parent, y)
if px == py:
return False
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
return True
def mst_weight(skip=-1, force=-1):
parent = list(range(n))
rank = [0] * n
w = 0
cnt = 0
if force != -1:
u, v, wt = edges[force]
union(parent, rank, u, v)
w += wt
cnt += 1
for i in indexed:
if i == skip:
continue
u, v, wt = edges[i]
if union(parent, rank, u, v):
w += wt
cnt += 1
return w if cnt == n - 1 else float('inf')
base = mst_weight()
crit, pseudo = [], []
for i in range(E):
if mst_weight(skip=i) > base:
crit.append(i)
elif mst_weight(force=i) == base:
pseudo.append(i)
return [crit, pseudo]
n1 = 5
edges1 = [[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]]
print(findCriticalAndPseudoCriticalEdges_v2(n1, edges1)) # [[0,1],[2,3,4,5]]
n2 = 4
edges2 = [[0,1,1],[1,2,1],[2,3,1],[0,3,1]]
print(findCriticalAndPseudoCriticalEdges_v2(n2, edges2)) # [[],[0,1,2,3]]
Complexity
- Time:
O(E² * α(n))— same as brute force; unavoidable without heavy machinery - Space:
O(n + E)
Common Pitfalls
Sorting edges but losing original indices. You need to sort edges for Kruskal’s, but the output requires original indices. Always track the original index alongside the edge data.
Marking edges as pseudo-critical when they’re actually critical. The pseudo-critical check includes critical edges (forcing a critical edge in still gives baseline weight). Always check critical first, and only check pseudo-critical for non-critical edges.
Forgetting to handle disconnected graphs when skipping edges. If removing an edge disconnects the graph, Kruskal’s won’t be able to form a spanning tree. Return inf in that case (count < n - 1).
Build a Matrix with Conditions
Difficulty: Hard Source: NeetCode
Problem
You are given a positive integer
k. You are also given:
- a 2D integer array
rowConditionsof sizemwith eachrowConditions[i] = [abovei, belowi], which means that row of numberaboveishould appear above the row of numberbelowi.- a 2D integer array
colConditionsof sizenwith eachcolConditions[i] = [lefti, righti], which means that column of numberleftishould appear to the left of the column of numberrighti.The numbers
1throughkmust appear in the matrix exactly once. Return any matrix of sizek x ksatisfying both sets of conditions, or return an empty 2D array if it is impossible.Example 1: Input:
k=3, rowConditions=[[1,2],[3,2]], colConditions=[[2,1],[3,2]]Output:[[3,0,0],[0,0,1],[0,2,0]](or any valid arrangement)Example 2: Input:
k=3, rowConditions=[[1,2],[2,3],[3,1],[2,3]], colConditions=[[2,1]]Output:[](cycle in row conditions)Constraints:
2 <= k <= 4001 <= rowConditions.length, colConditions.length <= 10^4- All numbers in conditions are in
[1, k]
Prerequisites
Before attempting this problem, you should be comfortable with:
- Topological Sort — ordering nodes with dependency constraints
- Cycle Detection — recognizing when constraints are contradictory
- Graph Building — translating constraints into directed edges
1. Brute Force (Try All Permutations)
Intuition
Generate all permutations of 1..k, and for each permutation, check if the row and column orderings satisfy all conditions. Return a matrix if found. This works for tiny inputs but is O(k! * conditions) — completely impractical for k > 8.
We skip the implementation here since Kahn’s topological sort is both correct and efficient. This section just motivates why topological sort is the right tool.
2. Topological Sort (Kahn’s BFS)
Intuition
The row conditions say “number A must be in a row above number B” — this is exactly a topological ordering constraint. Same for columns. So we need to:
- Find a valid topological ordering of numbers
1..kfor row positions. - Find a valid topological ordering of numbers
1..kfor column positions. - Place each number at
(row_order[num], col_order[num])in the k×k matrix.
If either topological sort finds a cycle, return [].
Algorithm
- Build directed graphs for rows and columns from the respective conditions.
- Run Kahn’s topological sort on each graph.
- If either sort doesn’t include all
knodes (cycle detected), return[]. - Create a
row_posandcol_posmapping:row_pos[num] = its position in row order. - Build the k×k zero matrix. For each number
1..k, setmatrix[row_pos[num]][col_pos[num]] = num. - Return the matrix.
Solution
from collections import defaultdict, deque
def buildMatrix(k, rowConditions, colConditions):
def topo_sort(conditions):
graph = defaultdict(list)
in_degree = {i: 0 for i in range(1, k + 1)}
for u, v in conditions:
graph[u].append(v)
in_degree[v] += 1
queue = deque([node for node in in_degree if in_degree[node] == 0])
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return order if len(order) == k else [] # empty = cycle detected
row_order = topo_sort(rowConditions)
col_order = topo_sort(colConditions)
if not row_order or not col_order:
return []
# Map each number to its position in the ordering
row_pos = {num: idx for idx, num in enumerate(row_order)}
col_pos = {num: idx for idx, num in enumerate(col_order)}
matrix = [[0] * k for _ in range(k)]
for num in range(1, k + 1):
matrix[row_pos[num]][col_pos[num]] = num
return matrix
k1 = 3
rc1 = [[1,2],[3,2]]
cc1 = [[2,1],[3,2]]
result = buildMatrix(k1, rc1, cc1)
for row in result:
print(row)
print()
# Cycle case
k2 = 3
rc2 = [[1,2],[2,3],[3,1]]
cc2 = [[2,1]]
print(buildMatrix(k2, rc2, cc2)) # []
Complexity
- Time:
O(k + R + C)where R = row conditions, C = col conditions - Space:
O(k + R + C)— graphs and ordering arrays
Common Pitfalls
Forgetting that all k numbers must appear even if they have no conditions. Initialize in_degree for all numbers from 1 to k, not just those mentioned in conditions. Otherwise, isolated nodes won’t be included in the topological order.
Checking len(order) == k for cycle detection. If there’s a cycle, Kahn’s BFS will stall before processing all nodes. The result length will be less than k — that’s your cycle indicator.
Confusing position with the number itself. row_order[i] is the number that goes in row i. You need the inverse: row_pos[num] = i tells you which row number num belongs to. Build the inverse mapping before constructing the matrix.
Greatest Common Divisor Traversal
Difficulty: Hard Source: NeetCode
Problem
You are given a 0-indexed integer array
nums, and you are allowed to traverse between its indices. You can traverse between indexiand indexj,i != j, if and only ifgcd(nums[i], nums[j]) > 1, wheregcdis the greatest common divisor.Your task is to determine if for every pair of indices
iandjinnums, there exists a sequence of traversals that can take us fromitoj.Return
Trueif it is possible to traverse between all such pairs of indices, orFalseotherwise.Example 1: Input:
nums = [2,3,6]Output:TrueExample 2: Input:
nums = [3,9,5]Output:FalseExample 3: Input:
nums = [4,3,12,8]Output:TrueConstraints:
1 <= nums.length <= 10^51 <= nums[i] <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Union-Find — tracking connected components
- Prime Factorization — decomposing numbers into their prime factors
- Graph Connectivity — checking if all nodes belong to one component
1. Brute Force (Pairwise GCD Check)
Intuition
For every pair of indices (i, j), compute their GCD. If it’s greater than 1, add an edge between them. Then check if the resulting graph is fully connected using BFS or Union-Find. This is O(n²) pairs times the GCD computation — too slow for n = 10^5 but fine conceptually.
Algorithm
- For each pair
(i, j), computegcd(nums[i], nums[j]). - If GCD > 1, union
iandj. - After all pairs, check if all indices share the same root.
Solution
from math import gcd
def canTraverseAllPairs_brute(nums):
n = len(nums)
if n == 1:
return True
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px != py:
parent[py] = px
for i in range(n):
for j in range(i + 1, n):
if gcd(nums[i], nums[j]) > 1:
union(i, j)
root = find(0)
return all(find(i) == root for i in range(1, n))
print(canTraverseAllPairs_brute([2, 3, 6])) # True
print(canTraverseAllPairs_brute([3, 9, 5])) # False
print(canTraverseAllPairs_brute([4, 3, 12, 8])) # True
print(canTraverseAllPairs_brute([1])) # True
Complexity
- Time:
O(n² * log(max_val))— n² pairs × GCD computation - Space:
O(n)— Union-Find
2. Union-Find via Prime Factorization
Intuition
Two numbers share a GCD > 1 if and only if they share at least one prime factor. So instead of checking pairs directly, we can union each number with all its prime factors, and prime factors with the numbers that share them.
Think of it like a bipartite graph: numbers on one side, primes on the other. If two numbers share a prime, they’re connected through that prime. Union-Find handles this elegantly — we union each number with each of its prime factors.
After processing all numbers, check if all indices (numbers) share the same connected component.
Algorithm
- Initialize Union-Find over indices
0..n-1plus prime numbers up tomax(nums). - For each
nums[i], factorize it. - For each prime factor
pofnums[i], union indexiwith a “node” representing primep. - After all numbers are processed, check if all indices have the same root.
- Special case: if any number is 1, it can’t connect to anything (GCD with anything is 1).
Solution
def canTraverseAllPairs(nums):
n = len(nums)
if n == 1:
return True
# If any number is 1, it can't form a GCD > 1 edge
# Unless n == 1 (handled above)
if 1 in nums:
return False
MAX_VAL = max(nums) + 1
# Union-Find over indices 0..n-1 AND prime "virtual nodes" n..n+MAX_VAL
parent = list(range(n + MAX_VAL))
rank = [0] * (n + MAX_VAL)
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py:
return
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
def prime_factors(num):
factors = []
d = 2
while d * d <= num:
if num % d == 0:
factors.append(d)
while num % d == 0:
num //= d
d += 1
if num > 1:
factors.append(num)
return factors
for i, num in enumerate(nums):
for p in prime_factors(num):
# Union index i with the "virtual node" for prime p
union(i, n + p)
# Check if all indices share the same root
root = find(0)
return all(find(i) == root for i in range(1, n))
print(canTraverseAllPairs([2, 3, 6])) # True (2 and 6 share 2; 3 and 6 share 3)
print(canTraverseAllPairs([3, 9, 5])) # False (5 shares nothing with 3 or 9)
print(canTraverseAllPairs([4, 3, 12, 8])) # True
print(canTraverseAllPairs([1, 2, 3])) # False (1 can't connect to anything)
print(canTraverseAllPairs([2])) # True (single element)
Complexity
- Time:
O(n * sqrt(max_val) * α(n))— factorize each number + Union-Find operations - Space:
O(n + max_val)— Union-Find with virtual prime nodes
Common Pitfalls
Forgetting that 1 connects to nothing. The number 1 has no prime factors, so it can never form a GCD > 1 edge with any other number. If nums contains 1 and has length > 1, the answer is always False.
Only checking index connectivity without prime virtual nodes. The naive union of pairs is O(n²). The prime factor trick reduces it dramatically by routing connectivity through shared primes.
Off-by-one in virtual node indexing. If index i corresponds to nums[i] (indices 0..n-1), then the virtual node for prime p should be at position n + p. Make sure your Union-Find is large enough: size n + max_val.
Dynamic Programming
Dynamic programming is just recursion with a notepad. Stop solving the same subproblem twice.
That’s the whole idea. You’ve almost certainly written recursive code that recomputes the same values over and over. Dynamic programming (DP) is the discipline of recognising when that happens and caching the results so each subproblem is solved exactly once.
The Two Properties That Make DP Applicable
Before reaching for DP, check that your problem has both of these properties.
1. Overlapping Subproblems
The same smaller problem appears repeatedly inside the larger problem. A naive recursive solution would compute it multiple times — DP solves it once and stores the answer.
2. Optimal Substructure
The optimal answer to the full problem can be built from the optimal answers to its subproblems. This is what lets you trust the stored results: a cached answer to a subproblem is always the best possible answer for that subproblem.
The Classic Example: Fibonacci
The Fibonacci sequence is the cleanest way to see why DP matters.
fib(n) = fib(n-1) + fib(n-2), with fib(0) = 0, fib(1) = 1
Naive Recursion — Exponential Work
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
# Count how many times each call is made
call_count = {}
def fib_counted(n):
call_count[n] = call_count.get(n, 0) + 1
if n <= 1:
return n
return fib_counted(n - 1) + fib_counted(n - 2)
fib_counted(7)
print("fib(7) =", fib_naive(7))
print()
print("Call counts for fib_naive(7):")
for k in sorted(call_count):
print(f" fib({k}) called {call_count[k]} time(s)")
The output makes the problem obvious. fib(3) is recomputed five times. fib(2) is recomputed eight times. The total work grows as O(2^n) — computing fib(50) naive would take longer than your lifetime.
The Overlapping Subproblem Tree for fib(5)
graph TD
A["fib(5)"] --> B["fib(4)"]
A --> C["fib(3) ①"]
B --> D["fib(3) ②"]
B --> E["fib(2) ①"]
C --> F["fib(2) ②"]
C --> G["fib(1) ①"]
D --> H["fib(2) ③"]
D --> I["fib(1) ②"]
E --> J["fib(1) ③"]
E --> K["fib(0) ①"]
F --> L["fib(1) ④"]
F --> M["fib(0) ②"]
H --> N["fib(1) ⑤"]
H --> O["fib(0) ③"]
style C fill:#f96,stroke:#c00
style D fill:#f96,stroke:#c00
style E fill:#fa0,stroke:#c60
style F fill:#fa0,stroke:#c60
style H fill:#fa0,stroke:#c60
The red nodes (fib(3)) are computed twice. The orange nodes (fib(2)) are computed three times. Every circled number is wasted work.
Fix 1: Memoization (Top-Down DP)
Add a cache — a “notepad” — to the recursive function. Before computing anything, check the notepad. If the answer is already there, return it immediately.
def fib_memo(n, cache={}):
if n in cache:
return cache[n] # Already solved — return immediately
if n <= 1:
return n
cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
return cache[n]
# Now each subproblem is solved exactly once
for i in range(10):
print(f"fib({i}) = {fib_memo(i)}")
The structure is identical to the naive version — the only addition is the cache dictionary. Time complexity drops from O(2^n) to O(n).
Fix 2: Tabulation (Bottom-Up DP)
Instead of starting at the top and recursing down, start at the base cases and build up to the answer iteratively. Fill a table row by row.
def fib_tabulation(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[0] = 0
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
print("DP table:", dp)
return dp[n]
result = fib_tabulation(9)
print(f"\nfib(9) = {result}")
No recursion, no stack overhead, no cache dictionary. Just a loop filling a list. For many problems this is the preferred style because it is easier to reason about memory and avoids Python’s recursion limit.
Memoization vs Tabulation at a Glance
| Property | Memoization (top-down) | Tabulation (bottom-up) |
|---|---|---|
| Style | Recursive + cache | Iterative + table |
| Solves only needed subproblems | Yes | No (fills entire table) |
| Stack overflow risk | Yes (deep recursion) | No |
| Often easier to write first | Yes | No |
| Often faster in practice | No | Yes |
Real-World Applications
DP is not an academic curiosity. It powers tools you use every day.
-
Spell checkers — The edit distance algorithm (Levenshtein distance) uses 2D DP to compute the minimum number of insertions, deletions, and substitutions needed to transform one word into another. When your phone suggests “hello” instead of “helo”, that’s DP.
-
Git diff — The
diffcommand finds the Longest Common Subsequence (LCS) of two files. Every line marked in green or red in a git diff was placed there by a DP algorithm. -
Bioinformatics — Sequence alignment tools like BLAST compare DNA and protein sequences using DP to find the optimal alignment despite millions of characters.
-
Route optimisation — GPS navigation and delivery routing use DP variants (like the Bellman-Ford algorithm) to find shortest paths through graphs with millions of nodes.
-
Auto-complete and predictive text — Language models and keyboard autocomplete use DP-style dynamic tables to score likely next words efficiently.
What’s in This Section
- 1-Dimension DP — problems with one varying state: climbing stairs, house robber
- 2-Dimension DP — problems with two varying states: unique grid paths, longest common subsequence
1-Dimension DP
Climbing stairs — how many ways can you reach step n if you can climb 1 or 2 steps at a time?
That deceptively simple question is the gateway to 1D dynamic programming. The “1D” means the state of the problem depends on a single integer — in this case, which step you are currently on. Once you can solve the stair problem, you can solve a surprisingly large family of real-world problems using identical thinking.
The Stair-Climbing Problem
Problem: You are at the bottom of a staircase with n steps. Each move you can climb either 1 step or 2 steps. How many distinct ways can you reach the top?
Let’s work out small cases by hand first.
| Steps (n) | Ways to climb | Paths |
|---|---|---|
| 1 | 1 | (1) |
| 2 | 2 | (1,1) or (2) |
| 3 | 3 | (1,1,1), (1,2), (2,1) |
| 4 | 5 | (1,1,1,1), (1,1,2), (1,2,1), (2,1,1), (2,2) |
| 5 | 8 | … |
Notice the pattern? The number of ways to reach step n equals the number of ways to reach step n-1 (then take 1 step) plus the number of ways to reach step n-2 (then take 2 steps). That’s the key insight.
The Recurrence Relation
dp[i] = dp[i-1] + dp[i-2]
Base cases:
dp[1] = 1 (only one way to reach step 1)
dp[2] = 2 (two ways to reach step 2)
This is Fibonacci in disguise. Recognising recurrence relations is the core skill of 1D DP.
Visualising the DP Table
For n = 6, here is how the table fills in from left to right:
graph LR
subgraph "dp table"
S1["dp[1] = 1"]
S2["dp[2] = 2"]
S3["dp[3] = 3"]
S4["dp[4] = 5"]
S5["dp[5] = 8"]
S6["dp[6] = 13"]
end
S1 -->|"+ dp[2]"| S3
S2 -->|"+ dp[1]"| S3
S2 -->|"+ dp[3]"| S4
S3 -->|"+ dp[2]"| S4
S3 -->|"+ dp[4]"| S5
S4 -->|"+ dp[3]"| S5
S4 -->|"+ dp[5]"| S6
S5 -->|"+ dp[4]"| S6
Each cell depends only on its two immediate predecessors. We never need to go back further.
Implementation: Tabulation (Bottom-Up)
Start at the base cases and fill forward. This is the most natural approach for this problem.
def climb_stairs_tabulation(n):
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1] = 1
dp[2] = 2
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
print(f"DP table for n={n}: {dp[1:]}")
return dp[n]
for steps in [1, 2, 3, 4, 5, 6, 10]:
print(f"climb_stairs({steps}) = {climb_stairs_tabulation(steps)}")
Implementation: Memoization (Top-Down)
Start from n and recurse down, caching every result.
def climb_stairs_memo(n, cache=None):
if cache is None:
cache = {}
if n in cache:
return cache[n]
if n <= 2:
return n
cache[n] = climb_stairs_memo(n - 1, cache) + climb_stairs_memo(n - 2, cache)
return cache[n]
for steps in [1, 2, 3, 4, 5, 6, 10]:
print(f"climb_stairs({steps}) = {climb_stairs_memo(steps)}")
Both produce the same answers. The tabulation approach is usually preferred for 1D DP because it is iterative — no recursion depth, no cache overhead.
Space Optimisation: O(1) Memory
Because dp[i] only ever looks back two positions, you do not need to store the full table — just keep a rolling pair of values.
def climb_stairs_optimised(n):
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
prev2, prev1 = prev1, prev1 + prev2
return prev1
# Same results, O(1) space instead of O(n)
for steps in [1, 2, 5, 10, 20, 40]:
print(f"climb_stairs({steps}) = {climb_stairs_optimised(steps)}")
This is a common final optimisation after you have the table-based solution working correctly.
Second Example: House Robber
Problem: You are a thief on a street of houses. Each house i has nums[i] dollars inside. You cannot rob two adjacent houses (the alarm will trigger). What is the maximum amount you can steal?
For example, [2, 7, 9, 3, 1] — the optimal is to rob houses at indices 0, 2, 4 for 2 + 9 + 1 = 12.
Recurrence Relation
At each house i you make a choice:
- Rob house
i: gainnums[i]plus the best you could do up to housei-2 - Skip house
i: take whatever the best was up to housei-1
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
def house_robber(nums):
n = len(nums)
if n == 0:
return 0
if n == 1:
return nums[0]
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
print(f"Houses: {nums}")
print(f"DP table: {dp}")
return dp[-1]
examples = [
[2, 7, 9, 3, 1],
[1, 2, 3, 1],
[5, 1, 1, 5],
[2, 1, 1, 2],
]
for nums in examples:
print(f"Max loot = {house_robber(nums)}")
print()
Notice how dp[i] “locks in” the best possible answer considering only the first i+1 houses. By the time you reach the end of the array, dp[-1] is the global optimum.
The General 1D DP Recipe
1. Define what dp[i] represents in plain English
2. Write the recurrence relation: how dp[i] depends on earlier dp values
3. Identify the base case(s): the smallest i you can answer directly
4. Fill the table from the base cases forward
5. (Optional) check if you only need the last k values and optimise space
Every 1D DP problem follows this same five-step structure.
Real-World Applications
-
Financial portfolio optimisation — Given a list of investments and a budget, DP computes the maximum return subject to constraints (a variant of the knapsack problem, which is 1D DP).
-
DNA sequence matching — Comparing a short query sequence against a long genome uses 1D DP windows to score likely alignment positions before committing to the expensive 2D alignment.
-
Text justification — Word processors like LaTeX use DP to break paragraphs into lines that minimise raggedness, treating each word position as a 1D state.
2-Dimension DP
Unique paths in a grid — a robot at the top-left corner of an m x n grid wants to reach the bottom-right corner, moving only right or down. How many distinct paths are there?
This question introduces 2D dynamic programming: problems where the answer depends on two changing indices simultaneously. The dp table becomes a two-dimensional grid instead of a one-dimensional array — and the thinking scales to problems that power git, spell-checkers, and genomic research.
The Unique Paths Problem
Problem: A robot starts at position (0, 0) in an m x n grid. It can only move right or down. How many distinct paths lead to (m-1, n-1)?
Working It Out By Hand (3 x 3 Grid)
The top row can only be reached by moving right the whole way — there is exactly 1 path to every cell in the top row. The leftmost column can only be reached by moving down the whole way — there is exactly 1 path to every cell in that column too. Every other cell can be reached either from the cell above it or the cell to its left:
dp[r][c] = dp[r-1][c] + dp[r][c-1]
For a 3x3 grid:
Start → →
↓ [1] [1] [1]
↓ [1] [2] [3]
↓ [1] [3] [6] ← answer: 6
Filling the DP Table as a Mermaid Grid
block-beta
columns 3
A["dp[0][0] = 1"]:1
B["dp[0][1] = 1"]:1
C["dp[0][2] = 1"]:1
D["dp[1][0] = 1"]:1
E["dp[1][1] = 2"]:1
F["dp[1][2] = 3"]:1
G["dp[2][0] = 1"]:1
H["dp[2][1] = 3"]:1
I["dp[2][2] = 6"]:1
style A fill:#cce5ff
style B fill:#cce5ff
style C fill:#cce5ff
style D fill:#cce5ff
style E fill:#d4edda
style F fill:#d4edda
style G fill:#cce5ff
style H fill:#d4edda
style I fill:#fff3cd
Blue cells are base cases (all 1s). Green cells are computed from two predecessors. Yellow cell is the answer.
Implementation
def unique_paths(rows, cols):
# Create a 2D table, initialised to 1
# (handles all base cases in the top row and left column)
dp = [[1] * cols for _ in range(rows)]
for r in range(1, rows):
for c in range(1, cols):
dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
# Print the table
print(f"DP table for {rows}x{cols} grid:")
for row in dp:
print(" ", row)
print(f"Answer: {dp[rows - 1][cols - 1]}")
return dp[rows - 1][cols - 1]
for r, c in [(2, 2), (3, 3), (3, 7), (4, 4)]:
unique_paths(r, c)
print()
Second Example: Longest Common Subsequence (LCS)
The Longest Common Subsequence is the algorithm at the heart of git diff, plagiarism checkers, and DNA sequence comparison. Understanding it means understanding how those tools actually work.
Problem: Given two strings, find the length of their longest common subsequence — a sequence of characters that appears in both strings in the same order, but not necessarily contiguously.
s1 = "ABCBDAB"
s2 = "BDCABA"
LCS = "BCBA" (length 4)
The Recurrence Relation
Let dp[i][j] be the length of the LCS of s1[:i] and s2[:j].
If s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1 (characters match — extend the LCS)
Else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) (characters don't match — take the best of skipping one)
The LCS DP Table
For s1 = "ABCB" and s2 = "BCB":
block-beta
columns 5
H0[" "]:1 H1[" ε "]:1 H2[" B "]:1 H3[" C "]:1 H4[" B "]:1
R0[" ε "]:1 C00["0"]:1 C01["0"]:1 C02["0"]:1 C03["0"]:1
R1[" A "]:1 C10["0"]:1 C11["0"]:1 C12["0"]:1 C13["0"]:1
R2[" B "]:1 C20["0"]:1 C21["1"]:1 C22["1"]:1 C23["1"]:1
R3[" C "]:1 C30["0"]:1 C31["1"]:1 C32["2"]:1 C33["2"]:1
R4[" B "]:1 C40["0"]:1 C41["1"]:1 C42["2"]:1 C43["3"]:1
style C21 fill:#d4edda
style C32 fill:#d4edda
style C43 fill:#fff3cd
The answer 3 is in the bottom-right. The actual LCS characters can be recovered by tracing back through the table.
Implementation: LCS
def lcs(s1, s2):
m, n = len(s1), len(s2)
# Build the DP table
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# Traceback to recover the actual subsequence
lcs_str = []
i, j = m, n
while i > 0 and j > 0:
if s1[i - 1] == s2[j - 1]:
lcs_str.append(s1[i - 1])
i -= 1
j -= 1
elif dp[i - 1][j] > dp[i][j - 1]:
i -= 1
else:
j -= 1
lcs_str.reverse()
print(f"s1 = {s1!r}")
print(f"s2 = {s2!r}")
print(f"LCS length = {dp[m][n]}")
print(f"LCS = {''.join(lcs_str)!r}")
return dp[m][n]
pairs = [
("ABCBDAB", "BDCABA"),
("AGGTAB", "GXTXAYB"),
("ABCDE", "ACE"),
("abcdef", "acf"),
]
for a, b in pairs:
lcs(a, b)
print()
Bonus: Edit Distance (Spell Checkers)
Edit distance — also called Levenshtein distance — counts the minimum number of single-character edits (insertions, deletions, substitutions) to turn one string into another. It is a direct extension of the LCS table.
def edit_distance(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Base cases: transforming empty string to s2[:j] costs j insertions
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # No edit needed
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # Delete from s1
dp[i][j - 1], # Insert into s1
dp[i - 1][j - 1] # Substitute
)
print(f"Edit distance({s1!r}, {s2!r}) = {dp[m][n]}")
return dp[m][n]
# Spell-checker style examples
edit_distance("kitten", "sitting") # Classic example
edit_distance("sunday", "saturday")
edit_distance("python", "pyhton") # Transposition typo
edit_distance("abc", "abc") # Identical
Every time your phone auto-corrects a typo or a search engine suggests “did you mean X?”, a variant of this 2D DP table is being filled.
The General 2D DP Recipe
1. Define dp[i][j] in plain English
(e.g. "the length of the LCS of s1[:i] and s2[:j]")
2. Write the recurrence: how dp[i][j] depends on dp[i-1][j], dp[i][j-1], dp[i-1][j-1]
3. Identify base cases: what are dp[0][j] and dp[i][0]?
4. Fill the table — usually row by row, left to right
5. Answer is usually at dp[m][n] (bottom-right corner)
6. If you need the actual solution (not just its length/cost), traceback through the table
Real-World Applications
-
Git diff — The
diffalgorithm computes the LCS of two files line by line. Lines in the LCS are unchanged; everything else is an insertion or deletion. This is whygit diffis accurate even for complex edits. -
Plagiarism detection — Plagiarism checkers compute LCS or edit distance between submitted documents to find suspiciously similar passages.
-
Bioinformatics — BLAST and Smith-Waterman alignment use 2D DP to align DNA and protein sequences, tolerating mutations (substitutions), insertions, and deletions across millions of base pairs.
-
Spell checkers and autocomplete — Edit distance powers every “did you mean?” suggestion. The smaller the edit distance, the higher the suggestion is ranked.
-
Speech recognition — Dynamic Time Warping (a variant of 2D DP) aligns spoken audio sequences to reference templates, handling differences in speaking speed.
0 / 1 Knapsack
You’re packing for a camping trip. Your backpack holds 10 kg. You have 5 items — each with a weight and a value. What do you pack to maximise value?
That is the knapsack problem, and the “0/1” in its name tells you the constraint: each item is either taken (1) or left behind (0). You cannot split an item, take half a sleeping bag, or bring three copies of the same water bottle. This one constraint turns a trivial greedy problem into one of the most studied problems in computer science.
The Setup
You have a list of items, each with a weight and a value. You have a bag with a maximum capacity W. You want to find the subset of items that fits within the capacity and has the highest total value.
Working Example
| Item | Weight | Value |
|---|---|---|
| A | 2 | 3 |
| B | 3 | 4 |
| C | 4 | 5 |
| D | 5 | 6 |
Capacity W = 5.
By hand, let’s enumerate some options:
| Items chosen | Total weight | Total value |
|---|---|---|
| A only | 2 | 3 |
| B only | 3 | 4 |
| A + B | 5 | 7 |
| C only | 4 | 5 |
| D only | 5 | 6 |
A + B with total weight 5 and value 7 is the best we can do. DP finds this automatically — even when there are thousands of items.
The Recurrence Relation
Let dp[i][w] = the maximum value achievable using the first i items with a bag capacity of exactly w.
For each item i, we have two choices:
- Skip item i — the answer is whatever the best was without it:
dp[i-1][w] - Take item i — only legal if
weight[i] <= w. The answer isvalue[i] + dp[i-1][w - weight[i]]
dp[i][w] = dp[i-1][w] if weight[i] > w
dp[i][w] = max(dp[i-1][w],
dp[i-1][w - weight[i]] + value[i]) otherwise
Base case: dp[0][w] = 0 for all w (no items, no value).
Filling the Table By Hand
Items: A(2,3), B(3,4), C(4,5), D(5,6). Capacity = 5. Rows = items 0–4, columns = capacity 0–5.
w=0 w=1 w=2 w=3 w=4 w=5
i=0 0 0 0 0 0 0
i=1(A) 0 0 3 3 3 3
i=2(B) 0 0 3 4 4 7 ← A+B fits at w=5
i=3(C) 0 0 3 4 5 7
i=4(D) 0 0 3 4 5 7
The answer is dp[4][5] = 7.
DP Table as a Diagram
block-beta
columns 7
H0[" "]:1 H1["w=0"]:1 H2["w=1"]:1 H3["w=2"]:1 H4["w=3"]:1 H5["w=4"]:1 H6["w=5"]:1
R0["i=0 (none)"]:1 C00["0"]:1 C01["0"]:1 C02["0"]:1 C03["0"]:1 C04["0"]:1 C05["0"]:1
R1["i=1 (A:2,3)"]:1 C10["0"]:1 C11["0"]:1 C12["3"]:1 C13["3"]:1 C14["3"]:1 C15["3"]:1
R2["i=2 (B:3,4)"]:1 C20["0"]:1 C21["0"]:1 C22["3"]:1 C23["4"]:1 C24["4"]:1 C25["7"]:1
R3["i=3 (C:4,5)"]:1 C30["0"]:1 C31["0"]:1 C32["3"]:1 C33["4"]:1 C34["5"]:1 C35["7"]:1
R4["i=4 (D:5,6)"]:1 C40["0"]:1 C41["0"]:1 C42["3"]:1 C43["4"]:1 C44["5"]:1 C45["7"]:1
style C00 fill:#cce5ff
style C01 fill:#cce5ff
style C02 fill:#cce5ff
style C03 fill:#cce5ff
style C04 fill:#cce5ff
style C05 fill:#cce5ff
style C12 fill:#d4edda
style C23 fill:#d4edda
style C25 fill:#fff3cd
style C45 fill:#fff3cd
Blue = base cases. Green = first non-trivial fills. Yellow = the answer.
2D Tabulation Implementation
def knapsack_2d(weights, values, capacity):
n = len(weights)
# dp[i][w] = best value using first i items, capacity w
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
w_i = weights[i - 1]
v_i = values[i - 1]
for w in range(capacity + 1):
# Option 1: skip item i
dp[i][w] = dp[i - 1][w]
# Option 2: take item i (only if it fits)
if w_i <= w:
take = dp[i - 1][w - w_i] + v_i
if take > dp[i][w]:
dp[i][w] = take
# Print the full DP table
header = " " + "".join(f" w={c}" for c in range(capacity + 1))
print(header)
for i in range(n + 1):
label = f"i={i} " if i == 0 else f"i={i}({chr(64+i)}) "
row = " ".join(str(dp[i][w]).rjust(2) for w in range(capacity + 1))
print(f"{label} {row}")
print(f"\nMax value = {dp[n][capacity]}")
return dp
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 5
dp = knapsack_2d(weights, values, capacity)
Traceback: Which Items Were Chosen?
The table tells us the maximum value, but not which items produced it. We recover the selection by walking backwards through the table.
def knapsack_with_items(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
w_i = weights[i - 1]
v_i = values[i - 1]
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w]
if w_i <= w:
take = dp[i - 1][w - w_i] + v_i
if take > dp[i][w]:
dp[i][w] = take
# Traceback
chosen = []
w = capacity
for i in range(n, 0, -1):
if dp[i][w] != dp[i - 1][w]:
# Item i was taken
chosen.append(i - 1)
w -= weights[i - 1]
chosen.reverse()
print(f"Max value : {dp[n][capacity]}")
print(f"Items chosen : {[chr(65 + idx) for idx in chosen]}")
total_w = sum(weights[i] for i in chosen)
total_v = sum(values[i] for i in chosen)
print(f"Total weight : {total_w} / {capacity}")
print(f"Total value : {total_v}")
return dp[n][capacity], chosen
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 5
knapsack_with_items(weights, values, capacity)
print()
# Larger example
weights2 = [1, 3, 4, 5, 2]
values2 = [1, 4, 5, 7, 3]
capacity2 = 7
knapsack_with_items(weights2, values2, capacity2)
Space Optimisation: Rolling 1D Array
The 2D table uses O(n * W) space. Notice that row i only ever reads from row i-1. We can therefore keep just one row and update it in reverse — this is the classic “rolling array” trick.
Key rule: iterate w from capacity down to weight[i]. If we went left-to-right, a cell we already updated in the current pass would pollute later cells in the same pass, equivalent to picking the same item twice.
def knapsack_1d(weights, values, capacity):
dp = [0] * (capacity + 1)
for i, (w_i, v_i) in enumerate(zip(weights, values)):
# Iterate RIGHT TO LEFT so each item is counted at most once
for w in range(capacity, w_i - 1, -1):
dp[w] = max(dp[w], dp[w - w_i] + v_i)
print(f"1D dp array : {dp}")
print(f"Max value : {dp[capacity]}")
return dp[capacity]
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 5
knapsack_1d(weights, values, capacity)
print()
# Verify against the 2D solution on a larger input
weights3 = [2, 4, 6, 9, 3, 1, 5]
values3 = [3, 5, 8, 10, 4, 2, 7]
knapsack_1d(weights3, values3, 15)
Complexity Analysis
| Version | Time | Space |
|---|---|---|
| 2D tabulation | O(n × W) | O(n × W) |
| 1D rolling array | O(n × W) | O(W) |
n is the number of items, W is the bag capacity. Both versions have the same time complexity — only space differs.
Real-World Applications
-
Resource allocation — A server has limited CPU and RAM. Which combination of jobs maximises throughput? Each job either runs or does not.
-
Investment portfolio selection — Given a fixed budget and a set of investment opportunities, each with a cost and projected return, choose the subset that maximises expected return.
-
Cargo loading — An aircraft has a fixed weight limit. Which cargo containers to load to maximise revenue?
-
Feature selection in ML — Given a compute budget, which subset of features produces the best model? Each feature either is or is not included in training.
-
CPU task scheduling — A real-time system has a deadline. Which tasks can be scheduled within the time window to maximise total priority?
The 0/1 knapsack pattern appears wherever you have a binary choice (include or exclude) and a capacity constraint — two of the most common ingredients in real decisions.
Unbounded Knapsack
Same camping trip, but now you can pack multiple copies of the same item. A water bottle weighs 1 kg and is worth 2 — why not pack as many as fit?
The moment “each item can be used once” becomes “each item can be used as many times as you like”, the problem transforms into the unbounded knapsack. One small change in the recurrence ripples into a completely different set of classical sub-problems: coin change, rod cutting, tile filling, and more.
0/1 vs Unbounded: The One-Line Difference
In 0/1 knapsack each item can only be taken once. The 2D recurrence reads from the previous row to prevent re-picking:
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])
^^^^^^^^ ^^^^^^^^
"without item i" "take item i — but look at row i-1
so item i can only be used once"
In unbounded knapsack there is no such restriction. We drop to 1D and read from the current row instead:
dp[w] = max(dp[w], dp[w - weight[i]] + value[i])
^^^^^^^^
"take item i again — use dp[w] which already
reflects item i being available at this capacity"
Iterating w left-to-right (unlike 0/1 which goes right-to-left) means an item already counted at a smaller capacity can be counted again — that is precisely what “unbounded” means.
Building Intuition: Water Bottle Example
Items: bottle(1 kg, value 2), tent(3 kg, value 5), stove(4 kg, value 7). Capacity = 5.
Filling dp left-to-right, using all three items freely:
w=0: 0
w=1: bottle → dp[1-1]+2 = 2 → dp[1] = 2
w=2: bottle → dp[2-1]+2 = 4 → dp[2] = 4
w=3: bottle → dp[3-1]+2 = 6
tent → dp[3-3]+5 = 5 → dp[3] = 6
w=4: bottle → dp[4-1]+2 = 8
stove → dp[4-4]+7 = 7 → dp[4] = 8
w=5: bottle → dp[5-1]+2 = 10
tent → dp[5-3]+5 = 9
stove → dp[5-4]+7 = 9 → dp[5] = 10 (five bottles)
dp Array After Each Item Pass
block-beta
columns 7
H0[" "]:1 H1["w=0"]:1 H2["w=1"]:1 H3["w=2"]:1 H4["w=3"]:1 H5["w=4"]:1 H6["w=5"]:1
R1["after bottle"]:1 C10["0"]:1 C11["2"]:1 C12["4"]:1 C13["6"]:1 C14["8"]:1 C15["10"]:1
R2["after tent"]:1 C20["0"]:1 C21["2"]:1 C22["4"]:1 C23["6"]:1 C24["8"]:1 C25["10"]:1
R3["after stove"]:1 C30["0"]:1 C31["2"]:1 C32["4"]:1 C33["6"]:1 C34["8"]:1 C35["10"]:1
style C15 fill:#fff3cd
style C25 fill:#fff3cd
style C35 fill:#fff3cd
Because the bottle (weight 1) dominates, packing 5 bottles beats all other combinations here.
Core Implementation
def unbounded_knapsack(weights, values, capacity):
dp = [0] * (capacity + 1)
for w in range(1, capacity + 1):
for i in range(len(weights)):
if weights[i] <= w:
candidate = dp[w - weights[i]] + values[i]
if candidate > dp[w]:
dp[w] = candidate
print(f"dp array: {dp}")
print(f"Max value at capacity {capacity}: {dp[capacity]}")
return dp[capacity]
# Water bottle, tent, stove
unbounded_knapsack([1, 3, 4], [2, 5, 7], capacity=5)
print()
# Heavier items: bar(5,10), rod(2,3), nail(1,1) — capacity 10
unbounded_knapsack([5, 2, 1], [10, 3, 1], capacity=10)
Classic Problem 1: Coin Change — Minimum Coins
You have coins of denominations [1, 5, 10, 25]. What is the minimum number of coins to make amount n?
This is unbounded knapsack where each “value” is −1 (we minimise, not maximise), or equivalently, we track counts instead of values.
The recurrence:
dp[w] = min(dp[w - coin] + 1) for each coin ≤ w
dp[0] = 0
dp[w] = ∞ initially
def coin_change_min(coins, amount):
INF = float('inf')
dp = [INF] * (amount + 1)
dp[0] = 0 # 0 coins needed to make amount 0
for w in range(1, amount + 1):
for coin in coins:
if coin <= w and dp[w - coin] + 1 < dp[w]:
dp[w] = dp[w - coin] + 1
result = dp[amount] if dp[amount] != INF else -1
print(f"coins={coins}, amount={amount}")
print(f"min coins = {result}")
# Reconstruct which coins were used
if result != -1:
used = []
w = amount
while w > 0:
for coin in coins:
if coin <= w and dp[w - coin] + 1 == dp[w]:
used.append(coin)
w -= coin
break
print(f"coins used = {sorted(used, reverse=True)}")
print()
coin_change_min([1, 5, 10, 25], 41) # 25+10+5+1
coin_change_min([1, 3, 4], 6) # 3+3 beats 4+1+1
coin_change_min([2], 3) # impossible
coin_change_min([1, 5, 6, 9], 11) # 9+1+1 or 6+5?
Classic Problem 2: Coin Change — Number of Ways
Same coins, different question: how many distinct ways can you make amount n?
The recurrence shifts to addition (count all ways rather than take the best):
dp[0] = 1 (one way to make 0: use no coins)
dp[w] += dp[w - coin] for each coin ≤ w
Order of loops matters. Iterating coins in the outer loop and amounts in the inner loop counts combinations (order doesn’t matter). Swapping the loops counts permutations (order matters — [1,2] and [2,1] are different).
def coin_change_ways(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1 # exactly one way to make 0
# Outer loop = coins → counts unordered combinations
for coin in coins:
for w in range(coin, amount + 1):
dp[w] += dp[w - coin]
print(f"coins={coins}, amount={amount}")
print(f"number of ways (combinations) = {dp[amount]}")
def coin_change_permutations(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1
# Outer loop = amounts → counts ordered permutations
for w in range(1, amount + 1):
for coin in coins:
if coin <= w:
dp[w] += dp[w - coin]
print(f"number of ways (permutations) = {dp[amount]}")
print()
coins = [1, 2, 5]
coin_change_ways(coins, 5)
coin_change_permutations(coins, 5)
coins2 = [2]
coin_change_ways(coins2, 3)
coin_change_permutations(coins2, 3)
coins3 = [10]
coin_change_ways(coins3, 10)
coin_change_permutations(coins3, 10)
Classic Problem 3: Rod Cutting
You have a rod of length n. You can cut it into pieces of any integer length. Each length i sells for price p[i]. What is the maximum revenue?
This is unbounded knapsack where the “weights” are cut lengths (1 to n) and “values” are their prices. The rod itself is the “capacity”.
def rod_cutting(prices, n):
"""
prices[i] = revenue from a rod of length i+1 (1-indexed logically).
n = total rod length.
"""
dp = [0] * (n + 1)
for length in range(1, n + 1):
for cut in range(1, length + 1):
if cut <= len(prices):
candidate = dp[length - cut] + prices[cut - 1]
if candidate > dp[length]:
dp[length] = candidate
# Traceback to find the cuts used
cuts_used = []
remaining = n
while remaining > 0:
for cut in range(1, remaining + 1):
if cut <= len(prices) and dp[remaining - cut] + prices[cut - 1] == dp[remaining]:
cuts_used.append(cut)
remaining -= cut
break
print(f"Rod length : {n}")
print(f"Prices : {dict(enumerate(prices, start=1))}")
print(f"Max revenue: {dp[n]}")
print(f"Cuts made : {sorted(cuts_used, reverse=True)}")
print(f"dp array : {dp}")
print()
return dp[n]
# Classic textbook example
prices1 = [1, 5, 8, 9, 10, 17, 17, 20]
rod_cutting(prices1, n=8) # optimal: cut into 2+6 = 5+17 = 22
# All equal prices per unit
prices2 = [3, 3, 3, 3, 3]
rod_cutting(prices2, n=5) # keep whole: 3*5 = 15
# Premium on short pieces
prices3 = [10, 5, 2, 1]
rod_cutting(prices3, n=4) # four pieces of length 1: 10*4 = 40
Complexity Analysis
| Problem | Time | Space |
|---|---|---|
| Unbounded knapsack | O(n × W) | O(W) |
| Coin change (min) | O(coins × W) | O(W) |
| Coin change (ways) | O(coins × W) | O(W) |
| Rod cutting | O(n²) | O(n) |
All are O(capacity × items) in time and O(capacity) in space — dramatically better than brute-force enumeration which is exponential.
Real-World Applications
-
Currency exchange and vending machines — A vending machine dispensing change chooses the fewest coins from available denominations. Every ATM and cash register uses a variant of coin change.
-
Manufacturing cut optimisation — A factory cuts steel bars, pipes, or timber to order. Rod-cutting style DP determines how to cut raw stock to fulfil orders with minimal waste.
-
Network packet fragmentation — A router breaks a large packet into smaller fragments. Each fragment has a fixed overhead; the unbounded model finds the optimal fragmentation size to maximise data-to-overhead ratio.
-
Tile and material coverage — Tiling a floor with tiles of various sizes at various costs per tile is directly unbounded knapsack over the floor length.
The unbounded variant is your go-to pattern whenever the same resource or item can be reused — which is surprisingly common in logistics, finance, and systems engineering.
Longest Common Subsequence (LCS)
git diff shows you exactly what changed between two files. The coloured lines — red for deleted, green for added — come from one question: what is the longest sequence of lines that appears in both files in the same order?
The algorithm behind it: Longest Common Subsequence.
Subsequence vs Substring
A substring is a contiguous block of characters: "ABC" is a substring of "ABCDE".
A subsequence preserves relative order but does not have to be contiguous: "ACE" is a subsequence of "ABCDE" because A, C, E appear in that order — even though they are not adjacent.
String: A B C D E
Subsequence: A C E ✓ (pick positions 0, 2, 4)
The Longest Common Subsequence of two strings is the longest sequence that is a subsequence of both.
s1 = "ABCBDAB"
s2 = "BDCABA"
LCS = "BCBA" length 4
s1: A B C B D A B
↑ ↑ ↑ ↑ (positions 1,3,5,6 in s1)
s2: B D C A B A
↑ ↑ ↑ ↑ (positions 0,2,3,4 in s2 — or BDAB from s2 for a different LCS)
There may be multiple LCS strings of the same length. The DP gives us the length; traceback recovers one such string.
The Recurrence Relation
Let dp[i][j] = length of LCS of s1[:i] and s2[:j].
Two cases at each cell:
Characters match — s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1 ← extend the LCS by one
Characters differ:
dp[i][j] = max(dp[i-1][j], ← skip s1[i-1]
dp[i][j-1]) ← skip s2[j-1]
Base cases: dp[0][j] = 0 and dp[i][0] = 0 (LCS with empty string is 0).
Filling the Table for “ABCBDAB” and “BDCABA”
The table below shows the full DP matrix. Rows are characters of s1 = "ABCBDAB", columns are characters of s2 = "BDCABA".
block-beta
columns 8
H0[" "]:1 H1["ε"]:1 H2["B"]:1 H3["D"]:1 H4["C"]:1 H5["A"]:1 H6["B"]:1 H7["A"]:1
R0["ε"]:1 C00["0"]:1 C01["0"]:1 C02["0"]:1 C03["0"]:1 C04["0"]:1 C05["0"]:1 C06["0"]:1
R1["A"]:1 C10["0"]:1 C11["0"]:1 C12["0"]:1 C13["0"]:1 C14["1"]:1 C15["1"]:1 C16["1"]:1
R2["B"]:1 C20["0"]:1 C21["1"]:1 C22["1"]:1 C23["1"]:1 C24["1"]:1 C25["2"]:1 C26["2"]:1
R3["C"]:1 C30["0"]:1 C31["1"]:1 C32["1"]:1 C33["2"]:1 C34["2"]:1 C35["2"]:1 C36["2"]:1
R4["B"]:1 C40["0"]:1 C41["1"]:1 C42["1"]:1 C43["2"]:1 C44["2"]:1 C45["3"]:1 C46["3"]:1
R5["D"]:1 C50["0"]:1 C51["1"]:1 C52["2"]:1 C53["2"]:1 C54["2"]:1 C55["3"]:1 C56["3"]:1
R6["A"]:1 C60["0"]:1 C61["1"]:1 C62["2"]:1 C63["2"]:1 C64["3"]:1 C65["3"]:1 C66["4"]:1
R7["B"]:1 C70["0"]:1 C71["1"]:1 C72["2"]:1 C73["2"]:1 C74["3"]:1 C75["4"]:1 C76["4"]:1
style C14 fill:#d4edda
style C21 fill:#d4edda
style C33 fill:#d4edda
style C45 fill:#d4edda
style C52 fill:#d4edda
style C64 fill:#d4edda
style C75 fill:#d4edda
style C76 fill:#fff3cd
Every green cell is where a match occurred (+1 diagonal). The yellow cell is the answer: dp[7][6] = 4.
Implementation: LCS Length and Traceback
def lcs(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Fill the table
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# Traceback to recover the actual LCS string
result = []
i, j = m, n
while i > 0 and j > 0:
if s1[i - 1] == s2[j - 1]:
result.append(s1[i - 1])
i -= 1
j -= 1
elif dp[i - 1][j] >= dp[i][j - 1]:
i -= 1
else:
j -= 1
result.reverse()
print(f"s1 = {s1!r}")
print(f"s2 = {s2!r}")
print(f"LCS length = {dp[m][n]}")
print(f"LCS = {''.join(result)!r}")
print()
return dp[m][n], ''.join(result)
lcs("ABCBDAB", "BDCABA")
lcs("AGGTAB", "GXTXAYB")
lcs("ABCDE", "ACE")
lcs("abcdef", "ace")
lcs("XMJYAUZ", "MZJAWXU")
Edit Distance: LCS’s Close Cousin
Edit distance (Levenshtein distance) counts the minimum number of single-character insertions, deletions, and substitutions to turn s1 into s2. It uses almost the same DP table — only the recurrence differs.
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] ← free: chars match
else:
dp[i][j] = 1 + min(
dp[i-1][j], ← delete from s1
dp[i][j-1], ← insert into s1
dp[i-1][j-1] ← substitute
)
Base cases: dp[i][0] = i (delete all of s1) and dp[0][j] = j (insert all of s2).
def edit_distance(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(
dp[i - 1][j],
dp[i][j - 1],
dp[i - 1][j - 1]
)
dist = dp[m][n]
print(f"edit_distance({s1!r}, {s2!r}) = {dist}")
return dist
edit_distance("kitten", "sitting") # classic: 3
edit_distance("sunday", "saturday") # 3
edit_distance("python", "pyhton") # 2 (transposition)
edit_distance("abc", "abc") # 0 (identical)
edit_distance("", "hello") # 5 (insert everything)
The relationship between LCS and edit distance is tight: edit_distance(s1, s2) = m + n - 2 * lcs_length(s1, s2) when only insertions and deletions are allowed (no substitutions).
Extension 1: Longest Common Substring
A substring must be contiguous. The DP is similar but we reset to 0 when characters don’t match, and track the maximum seen rather than the bottom-right corner.
def longest_common_substring(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
max_len = 0
end_idx = 0 # end index in s1
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
if dp[i][j] > max_len:
max_len = dp[i][j]
end_idx = i
else:
dp[i][j] = 0 # ← key difference from LCS: reset on mismatch
substr = s1[end_idx - max_len : end_idx]
print(f"s1 = {s1!r}")
print(f"s2 = {s2!r}")
print(f"Longest common substring = {substr!r} (length {max_len})")
print()
return substr
longest_common_substring("ABCBDAB", "BDCABA")
longest_common_substring("GeeksforGeeks", "GeeksQuiz")
longest_common_substring("abcdef", "zcdemf")
Extension 2: Shortest Common Supersequence
The Shortest Common Supersequence (SCS) is the shortest string that contains both s1 and s2 as subsequences. Its length is m + n - lcs_length(s1, s2).
def shortest_common_supersequence(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# Reconstruct SCS by tracing the LCS table
scs = []
i, j = m, n
while i > 0 and j > 0:
if s1[i - 1] == s2[j - 1]:
scs.append(s1[i - 1])
i -= 1
j -= 1
elif dp[i - 1][j] > dp[i][j - 1]:
scs.append(s1[i - 1])
i -= 1
else:
scs.append(s2[j - 1])
j -= 1
while i > 0:
scs.append(s1[i - 1]); i -= 1
while j > 0:
scs.append(s2[j - 1]); j -= 1
scs.reverse()
result = ''.join(scs)
print(f"s1 = {s1!r}")
print(f"s2 = {s2!r}")
print(f"SCS = {result!r} (length {len(result)})")
print(f"SCS length = {m + n - dp[m][n]} (verify: m+n-lcs)")
print()
return result
shortest_common_supersequence("ABCBDAB", "BDCABA")
shortest_common_supersequence("geek", "eke")
Complexity Analysis
| Problem | Time | Space | Space (optimised) |
|---|---|---|---|
| LCS length | O(m × n) | O(m × n) | O(min(m,n)) |
| LCS with traceback | O(m × n) | O(m × n) | — (need full table) |
| Edit distance | O(m × n) | O(m × n) | O(min(m,n)) |
| Longest common substring | O(m × n) | O(m × n) | O(n) |
| Shortest common supersequence | O(m × n) | O(m × n) | — |
The space for LCS length alone can be reduced to two rows (or one row with careful ordering), but traceback always needs the full table.
Real-World Applications
-
git diff and patch —
diffcomputes LCS on the lines of two files. Lines in the LCS are unchanged. Everything else is a+or-line.git mergeuses a three-way variant of the same algorithm. -
Plagiarism detection — Academic integrity tools compare student submissions by computing LCS (or edit distance) to find suspiciously similar passages, even after synonym substitution or word reordering.
-
DNA sequence alignment — BLAST, Smith-Waterman, and Needleman-Wunsch all solve variants of LCS/edit-distance on DNA and protein sequences millions of bases long. Identifying mutations, finding conserved regions, and tracing evolutionary relationships all reduce to this table.
-
Spell checkers and autocorrect — Every “did you mean?” suggestion is ranked by edit distance. The word with the smallest distance to the mistyped word ranks first.
-
File comparison and merge tools — Beyond git, tools like
vimdiff,Beyond Compare, and document co-editing engines (Google Docs, Notion) use LCS or its variants to highlight differences and resolve conflicts.
The 2D DP table is one of the most productive two-dimensional structures in all of computer science. Once you can fill it by hand for LCS, the door opens to a whole family of string and sequence problems.
Palindromes
Racecar. Level. A man a plan a canal Panama.
Palindromes are everywhere — and DP makes the hard ones computable. We’ll build from the simplest palindrome check all the way up to “minimum insertions to make any string a palindrome”, with a DP table and real code at every step.
Level 1: Is a String a Palindrome?
A string is a palindrome if it reads the same forwards and backwards. The fastest check uses two pointers moving inward from both ends.
def is_palindrome(s):
left, right = 0, len(s) - 1
while left < right:
if s[left] != s[right]:
return False
left += 1
right -= 1
return True
tests = [
"racecar", "level", "hello", "a",
"abba", "abcba", "amanaplanacanalpanama",
"python", ""
]
for s in tests:
result = is_palindrome(s)
print(f"is_palindrome({s!r:30}) = {result}")
Time: O(n). Space: O(1). This is the baseline.
Level 2: Longest Palindromic Substring
Given "babad", the longest palindromic substring (contiguous) is "bab" or "aba" — both length 3.
The key insight: every palindrome expands from a centre. There are 2n - 1 possible centres (each character, and each gap between characters to handle even-length palindromes).
def longest_palindromic_substring(s):
if not s:
return ""
start = 0
max_len = 1
def expand(left, right):
nonlocal start, max_len
while left >= 0 and right < len(s) and s[left] == s[right]:
if right - left + 1 > max_len:
max_len = right - left + 1
start = left
left -= 1
right += 1
for i in range(len(s)):
expand(i, i) # odd-length centres
expand(i, i + 1) # even-length centres
result = s[start : start + max_len]
print(f"s = {s!r}")
print(f"Longest palindromic substring = {result!r} (length {max_len})")
print()
return result
longest_palindromic_substring("babad")
longest_palindromic_substring("cbbd")
longest_palindromic_substring("racecar")
longest_palindromic_substring("abacaba")
longest_palindromic_substring("aacabdkacaa")
Time: O(n²). Space: O(1). For a linear solution, look up Manacher’s algorithm — but the expand-from-centre approach is the one to learn first.
Level 3: Longest Palindromic Subsequence
Now the subsequence variant (not contiguous). Given "BBABCBCAB", what is the longest subsequence that is a palindrome?
Answer: "BABCBAB" or "BBCBB" — length 7.
The Recurrence
Let dp[i][j] = length of the longest palindromic subsequence in s[i..j].
If s[i] == s[j]:
dp[i][j] = dp[i+1][j-1] + 2 ← both ends match: extend by 2
If s[i] != s[j]:
dp[i][j] = max(dp[i+1][j], ← skip left character
dp[i][j-1]) ← skip right character
Base cases:
dp[i][i] = 1 (single character is always a palindrome)
dp[i][i+1] = 2 if s[i]==s[i+1], else 1
We fill the table diagonally (shorter substrings first, then longer ones), because dp[i][j] depends on dp[i+1][j-1], dp[i+1][j], and dp[i][j-1].
DP Table for “BBABCBCAB”
String length = 9. Indices 0–8. We show the upper-right triangle of the table (only dp[i][j] where i ≤ j is meaningful).
block-beta
columns 10
H0[" "]:1 H1["B"]:1 H2["B"]:1 H3["A"]:1 H4["B"]:1 H5["C"]:1 H6["B"]:1 H7["C"]:1 H8["A"]:1 H9["B"]:1
R0["B"]:1 C00["1"]:1 C01["2"]:1 C02["2"]:1 C03["3"]:1 C04["3"]:1 C05["5"]:1 C06["5"]:1 C07["5"]:1 C08["7"]:1
R1["B"]:1 C10[" "]:1 C11["1"]:1 C12["1"]:1 C13["3"]:1 C14["3"]:1 C15["5"]:1 C16["5"]:1 C17["5"]:1 C18["7"]:1
R2["A"]:1 C20[" "]:1 C21[" "]:1 C22["1"]:1 C23["1"]:1 C24["1"]:1 C25["3"]:1 C26["3"]:1 C27["5"]:1 C28["5"]:1
R3["B"]:1 C30[" "]:1 C31[" "]:1 C32[" "]:1 C33["1"]:1 C34["1"]:1 C35["3"]:1 C36["3"]:1 C37["3"]:1 C38["5"]:1
R4["C"]:1 C40[" "]:1 C41[" "]:1 C42[" "]:1 C43[" "]:1 C44["1"]:1 C45["1"]:1 C46["3"]:1 C47["3"]:1 C48["3"]:1
R5["B"]:1 C50[" "]:1 C51[" "]:1 C52[" "]:1 C53[" "]:1 C54[" "]:1 C55["1"]:1 C56["1"]:1 C57["1"]:1 C58["3"]:1
R6["C"]:1 C60[" "]:1 C61[" "]:1 C62[" "]:1 C63[" "]:1 C64[" "]:1 C65[" "]:1 C66["1"]:1 C67["1"]:1 C68["1"]:1
R7["A"]:1 C70[" "]:1 C71[" "]:1 C72[" "]:1 C73[" "]:1 C74[" "]:1 C75[" "]:1 C76[" "]:1 C77["1"]:1 C78["1"]:1
R8["B"]:1 C80[" "]:1 C81[" "]:1 C82[" "]:1 C83[" "]:1 C84[" "]:1 C85[" "]:1 C86[" "]:1 C87[" "]:1 C88["1"]:1
style C00 fill:#cce5ff
style C11 fill:#cce5ff
style C22 fill:#cce5ff
style C33 fill:#cce5ff
style C44 fill:#cce5ff
style C55 fill:#cce5ff
style C66 fill:#cce5ff
style C77 fill:#cce5ff
style C88 fill:#cce5ff
style C08 fill:#fff3cd
style C18 fill:#fff3cd
Blue diagonal = base cases (length 1). Yellow top-right cells = the answer for the full string.
Implementation: All Four Palindrome Problems
# ── Problem 3: Longest Palindromic Subsequence ──────────────────────────────
def longest_palindromic_subsequence(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
# Base case: single characters
for i in range(n):
dp[i][i] = 1
# Fill diagonals of increasing length
for length in range(2, n + 1): # length of the window
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
inner = dp[i + 1][j - 1] if length > 2 else 0
dp[i][j] = inner + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
# Traceback
def traceback(i, j):
if i > j:
return ""
if i == j:
return s[i]
if s[i] == s[j]:
return s[i] + traceback(i + 1, j - 1) + s[j]
elif dp[i + 1][j] > dp[i][j - 1]:
return traceback(i + 1, j)
else:
return traceback(i, j - 1)
length = dp[0][n - 1]
subseq = traceback(0, n - 1)
print(f"s = {s!r}")
print(f"Longest palindromic subsequence = {subseq!r} (length {length})")
print()
return length
longest_palindromic_subsequence("BBABCBCAB")
longest_palindromic_subsequence("GEEKSFORGEEKS")
longest_palindromic_subsequence("agbdba")
longest_palindromic_subsequence("abcde")
# ── Problem 4: Minimum Insertions to Make a Palindrome ──────────────────────
#
# Key insight: min_insertions(s) = len(s) - LPS(s)
# We already know the longest palindromic subsequence (LPS). The characters
# NOT in the LPS each need one insertion to "mirror" them.
#
def min_insertions_to_palindrome(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
inner = dp[i + 1][j - 1] if length > 2 else 0
dp[i][j] = inner + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
lps_len = dp[0][n - 1]
insertions = n - lps_len
print(f"s = {s!r}")
print(f"LPS length = {lps_len}")
print(f"Min insertions needed = {insertions}")
print()
return insertions
min_insertions_to_palindrome("AB") # 1: "ABA" or "BAB"
min_insertions_to_palindrome("AA") # 0: already palindrome
min_insertions_to_palindrome("ABCD") # 3: "DCBABCD"
min_insertions_to_palindrome("BBABCBCAB") # 2
min_insertions_to_palindrome("leetcode") # 5
Comparing the Four Problems
| Problem | Approach | Time | Space |
|---|---|---|---|
| Is palindrome? | Two pointers | O(n) | O(1) |
| Longest palindromic substring | Expand from centre | O(n²) | O(1) |
| Longest palindromic subsequence | 2D DP (diagonal fill) | O(n²) | O(n²) |
| Minimum insertions | LPS + subtraction | O(n²) | O(n²) |
The big conceptual dividing line is substring vs subsequence. Substring problems can often be solved without DP (expand from centre, or Manacher’s). Subsequence problems almost always need DP because you must consider non-contiguous character selections.
Why Diagonal Filling?
When you fill a standard 2D table row by row, cell (i, j) depends on (i-1, j), (i, j-1), and (i-1, j-1) — all already computed.
For the palindrome subsequence table, dp[i][j] depends on dp[i+1][j-1] — a cell that is in a smaller subproblem (shorter substring). The correct traversal order is by increasing substring length:
length=1 → fill the diagonal (base cases)
length=2 → fill one step above the diagonal
length=3 → fill two steps above
...
length=n → fill the top-right corner (the answer)
# Visualise the diagonal fill order on a small string
def show_fill_order(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
print(f"Filling DP table for {s!r} (n={n}):")
print(f"{'':4}" + "".join(f" {c:2}" for c in s))
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
inner = dp[i + 1][j - 1] if length > 2 else 0
dp[i][j] = inner + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
for i in range(n):
row = "".join(
f" {dp[i][j]:2}" if j >= i else " "
for j in range(n)
)
print(f" {s[i]} {row}")
print(f"Answer (dp[0][{n-1}]) = {dp[0][n-1]}")
show_fill_order("ABCBA")
print()
show_fill_order("agbdba")
Real-World Applications
-
DNA palindromes in molecular biology — Restriction enzymes recognise specific palindromic sequences in double-stranded DNA (the sequence reads the same on both strands 5’→3’). Tools that locate cut sites use exact palindrome search on sequences millions of bases long.
-
Text compression — Run-length encoding and other compressors exploit repeated and symmetric patterns. Identifying the longest palindromic substring in a data block can guide compression decisions.
-
Data validation — Credit card number checksums (Luhn algorithm) and ISBN validation are not palindromes per se, but both rely on digit-level symmetry checks that use the same two-pointer scan pattern.
-
Word games and puzzles — Crossword constructors and word-game AIs use LPS to find the longest palindrome embeddable in a given set of letters, and minimum-insertion algorithms to determine how close a word is to being a palindrome.
Every time you encounter a problem involving “how symmetric is this sequence?” — whether the sequence is characters, DNA bases, or numbers — one of these four palindrome algorithms is likely the right tool.
Dynamic Programming Problems
Practice problems for memoization and tabulation across 1-D and 2-D state spaces.
1-D Dynamic Programming Problems
Practice problems with linear state transitions and one-dimensional DP tables.
Climbing Stairs
Difficulty: Easy Source: NeetCode
Problem
You are climbing a staircase. It takes
nsteps to reach the top. Each time you can climb either1or2steps. In how many distinct ways can you climb to the top?Example 1: Input:
n = 2Output:2(1+1, 2)Example 2: Input:
n = 3Output:3(1+1+1, 1+2, 2+1)Constraints:
1 <= n <= 45
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion — breaking a problem into smaller subproblems
- Memoization — caching results to avoid repeated computation
- Dynamic Programming — building solutions bottom-up from base cases
1. Brute Force (Recursion)
Intuition
To reach step n, you had to come from either step n-1 (taking 1 step) or step n-2 (taking 2 steps). So the number of ways to reach n equals the ways to reach n-1 plus the ways to reach n-2. This is the Fibonacci recurrence. The brute force just recurses without caching, so it recomputes the same subproblems over and over.
Algorithm
- Base cases:
ways(0) = 1(one way to stand at bottom),ways(1) = 1. - Recursive case:
ways(n) = ways(n-1) + ways(n-2).
Solution
def climbStairs_brute(n):
if n <= 1:
return 1
return climbStairs_brute(n - 1) + climbStairs_brute(n - 2)
print(climbStairs_brute(2)) # 2
print(climbStairs_brute(3)) # 3
print(climbStairs_brute(5)) # 8
print(climbStairs_brute(10)) # 89
Complexity
- Time:
O(2^n)— each call branches into two more - Space:
O(n)— recursion stack depth
2. Dynamic Programming (Bottom-Up)
Intuition
Notice that ways(n) = ways(n-1) + ways(n-2) — this is just Fibonacci! Instead of recursing from the top, build up from the base cases. We only ever need the last two values, so we can use two variables instead of a full array. This is the cleanest and most efficient solution.
Algorithm
- Handle base cases: if
n == 1, return1. - Initialize
prev2 = 1(ways to reach step 0) andprev1 = 1(ways to reach step 1). - For each step from 2 to n:
curr = prev1 + prev2, then slide the window. - Return
curr.
Solution
def climbStairs(n):
if n <= 1:
return 1
prev2, prev1 = 1, 1
for _ in range(2, n + 1):
curr = prev1 + prev2
prev2 = prev1
prev1 = curr
return prev1
print(climbStairs(1)) # 1
print(climbStairs(2)) # 2
print(climbStairs(3)) # 3
print(climbStairs(5)) # 8
print(climbStairs(10)) # 89
print(climbStairs(45)) # 1836311903
Complexity
- Time:
O(n)— single pass - Space:
O(1)— only two variables
Common Pitfalls
Off-by-one in base cases. There is exactly 1 way to reach step 0 (do nothing) and 1 way to reach step 1 (take one step). Getting this wrong shifts all subsequent values.
Using a full DP array when only two values are needed. A common beginner pattern is dp = [0] * (n+1); dp[0] = dp[1] = 1. This works but uses O(n) space unnecessarily. Two variables are enough.
Mixing up Fibonacci indexing. Climbing stairs is Fibonacci starting at F(1)=1, F(2)=2, F(3)=3. Standard Fibonacci starts at F(0)=0, F(1)=1. Just make sure your base cases match the problem.
Min Cost Climbing Stairs
Difficulty: Easy Source: NeetCode
Problem
You are given an integer array
costwherecost[i]is the cost of theith step on a staircase. Once you pay the cost, you can either climb one or two steps.You can either start from the step with index
0, or the step with index1. Return the minimum cost to reach the top of the floor (one step beyond the last index).Example 1: Input:
cost = [10,15,20]Output:15Example 2: Input:
cost = [1,100,1,1,1,100,1,1,100,1]Output:6Constraints:
2 <= cost.length <= 10000 <= cost[i] <= 999
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dynamic Programming — building optimal solutions from subproblems
- Climbing Stairs — understanding the 1-or-2-step recurrence
1. Brute Force (Recursion with Memoization)
Intuition
Define min_cost(i) as the minimum cost to reach the top starting from step i. From step i you can jump to i+1 or i+2. You pay cost[i] to leave step i. The recursion is: min_cost(i) = cost[i] + min(min_cost(i+1), min_cost(i+2)). The “top” is reached when i >= len(cost).
Algorithm
- Base case:
min_cost(i) = 0ifi >= len(cost). - Recursive case:
min_cost(i) = cost[i] + min(min_cost(i+1), min_cost(i+2)). - Start from both step 0 and step 1, return the minimum.
Solution
def minCostClimbingStairs_memo(cost):
n = len(cost)
memo = {}
def dp(i):
if i >= n:
return 0
if i in memo:
return memo[i]
memo[i] = cost[i] + min(dp(i + 1), dp(i + 2))
return memo[i]
return min(dp(0), dp(1))
print(minCostClimbingStairs_memo([10, 15, 20])) # 15
print(minCostClimbingStairs_memo([1, 100, 1, 1, 1, 100, 1, 1, 100, 1])) # 6
print(minCostClimbingStairs_memo([0, 0])) # 0
Complexity
- Time:
O(n)— each subproblem computed once - Space:
O(n)— memo table and recursion stack
2. Dynamic Programming (Bottom-Up)
Intuition
Build the solution from the top down: starting from the last step, work backwards. dp[i] = minimum cost to reach the top from step i. The recurrence is dp[i] = cost[i] + min(dp[i+1], dp[i+2]). After filling the array, the answer is min(dp[0], dp[1]) since we can start from either.
Like Climbing Stairs, we only need the two most recent values — space can be reduced to O(1).
Algorithm
- Initialize
dp = [0] * (n + 1)where the last two positions are 0 (reaching “top” is free). - Fill from
i = n-1down to0:dp[i] = cost[i] + min(dp[i+1], dp[i+2]). - Return
min(dp[0], dp[1]).
Solution
def minCostClimbingStairs(cost):
n = len(cost)
# dp[i] = min cost to reach top from step i
# Extend cost by two zeros to represent "beyond the array"
dp = cost + [0, 0]
for i in range(n - 1, -1, -1):
dp[i] = cost[i] + min(dp[i + 1], dp[i + 2])
return min(dp[0], dp[1])
def minCostClimbingStairs_o1(cost):
# Space-optimized: only track two future values
n = len(cost)
next1, next2 = 0, 0 # cost to reach top from step n and n+1
for i in range(n - 1, -1, -1):
curr = cost[i] + min(next1, next2)
next2 = next1
next1 = curr
return min(next1, next2)
print(minCostClimbingStairs([10, 15, 20])) # 15
print(minCostClimbingStairs([1, 100, 1, 1, 1, 100, 1, 1, 100, 1])) # 6
print(minCostClimbingStairs_o1([10, 15, 20])) # 15
print(minCostClimbingStairs_o1([1, 100, 1, 1, 1, 100, 1, 1, 100, 1])) # 6
print(minCostClimbingStairs_o1([0, 0])) # 0
Complexity
- Time:
O(n)— single pass - Space:
O(1)— two variables (space-optimized version)
Common Pitfalls
Thinking the answer is just dp[0]. You can start from step 0 or step 1. The answer is min(dp[0], dp[1]).
Forgetting that reaching the top is free. The “top” is beyond the last step. Once you jump past the last index, you’ve arrived — no additional cost. Set those boundary values to 0.
Confusing top-down vs bottom-up direction. If you fill the DP array left-to-right, you’d be computing the minimum cost to arrive at step i, which works too — but make sure the recurrence and base cases match your chosen direction.
N-th Tribonacci Number
Difficulty: Easy Source: NeetCode
Problem
The Tribonacci sequence Tn is defined as follows:
T0 = 0, T1 = 1, T2 = 1, andTn+3 = Tn + Tn+1 + Tn+2forn >= 0.Given
n, return the value ofTn.Example 1: Input:
n = 4Output:4Example 2: Input:
n = 25Output:1389537Constraints:
0 <= n <= 37- The answer is guaranteed to fit in a 32-bit integer.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Fibonacci Sequence — understanding recurrence relations
- Dynamic Programming — bottom-up DP with rolling variables
- Memoization — caching recursive results
1. Brute Force (Recursion)
Intuition
Directly implement the recurrence: T(n) = T(n-1) + T(n-2) + T(n-3). Without caching, this is an exponential tree of recursive calls — the same values get recomputed many times. For n=37 this is manageable in practice but bad in theory.
Algorithm
- Base cases:
T(0) = 0,T(1) = 1,T(2) = 1. - Recursive case:
T(n) = T(n-1) + T(n-2) + T(n-3).
Solution
def tribonacci_brute(n):
if n == 0:
return 0
if n <= 2:
return 1
return tribonacci_brute(n - 1) + tribonacci_brute(n - 2) + tribonacci_brute(n - 3)
print(tribonacci_brute(0)) # 0
print(tribonacci_brute(1)) # 1
print(tribonacci_brute(4)) # 4
print(tribonacci_brute(10)) # 149
Complexity
- Time:
O(3^n)— three branches at each level - Space:
O(n)— recursion stack
2. Dynamic Programming (Rolling Variables)
Intuition
Since each Tribonacci number only depends on the previous three, there’s no need to store the entire sequence. Use three rolling variables — a, b, c — representing T(n-3), T(n-2), T(n-1). Each iteration computes T(n) = a + b + c and slides the window forward.
Algorithm
- Initialize
a = 0(T0),b = 1(T1),c = 1(T2). - Handle the base cases directly.
- For each step from 3 to n: compute
d = a + b + c, thena, b, c = b, c, d. - Return
c.
Solution
def tribonacci(n):
if n == 0:
return 0
if n <= 2:
return 1
a, b, c = 0, 1, 1 # T0, T1, T2
for _ in range(3, n + 1):
a, b, c = b, c, a + b + c
return c
print(tribonacci(0)) # 0
print(tribonacci(1)) # 1
print(tribonacci(2)) # 1
print(tribonacci(3)) # 2
print(tribonacci(4)) # 4
print(tribonacci(25)) # 1389537
print(tribonacci(37)) # 2082876103
Complexity
- Time:
O(n)— one pass from 3 to n - Space:
O(1)— only three variables
Common Pitfalls
Getting base cases wrong. T0 = 0, T1 = 1, T2 = 1 — notice that T1 and T2 are both 1, not 0 and 1. Mixing these up shifts the entire sequence.
Off-by-one in the loop range. The loop should start at 3 and go up to and including n. If your loop starts at 2, you overwrite T2 incorrectly.
Returning the wrong variable. After the loop, c holds T(n). If you accidentally return b, you get T(n-1).
House Robber
Difficulty: Medium Source: NeetCode
Problem
You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed. Adjacent houses have security systems connected and it will automatically contact the police if two adjacent houses were broken into on the same night.
Given an integer array
numsrepresenting the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.Example 1: Input:
nums = [1,2,3,1]Output:4Example 2: Input:
nums = [2,7,9,3,1]Output:12Constraints:
1 <= nums.length <= 1000 <= nums[i] <= 400
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dynamic Programming — optimal substructure and overlapping subproblems
- Greedy Thinking — why greedy alone doesn’t work here
1. Brute Force (Recursion)
Intuition
At each house, you have two choices: rob it (skip the next one) or skip it (move to the next). Recursively try both options and return the maximum. Without memoization, this explores every subset of non-adjacent houses.
Algorithm
- Define
rob(i)= max money from housesi..n-1. - At each house:
rob(i) = max(nums[i] + rob(i+2), rob(i+1)). - Base cases:
rob(n) = 0,rob(n-1) = nums[n-1].
Solution
def rob_brute(nums):
n = len(nums)
def helper(i):
if i >= n:
return 0
return max(nums[i] + helper(i + 2), helper(i + 1))
return helper(0)
print(rob_brute([1, 2, 3, 1])) # 4
print(rob_brute([2, 7, 9, 3, 1])) # 12
print(rob_brute([0])) # 0
print(rob_brute([1, 2])) # 2
Complexity
- Time:
O(2^n)— binary tree of choices - Space:
O(n)— recursion stack
2. Dynamic Programming (Bottom-Up)
Intuition
Define dp[i] as the maximum money robbed from the first i houses. At house i, you either:
- Rob it: add
nums[i]to the best you could do without the previous house →dp[i-2] + nums[i] - Skip it: carry forward the best from
i-1→dp[i-1]
So dp[i] = max(dp[i-1], dp[i-2] + nums[i]). This is exactly like Fibonacci in structure.
Since we only need the previous two values, we can use O(1) space.
Algorithm
- Handle edge cases: if
n == 1, returnnums[0]. - Initialize
prev2 = nums[0],prev1 = max(nums[0], nums[1]). - For each house from index 2 to n-1:
curr = max(prev1, prev2 + nums[i]). - Slide the window and return
prev1.
Solution
def rob(nums):
n = len(nums)
if n == 1:
return nums[0]
prev2 = nums[0]
prev1 = max(nums[0], nums[1])
for i in range(2, n):
curr = max(prev1, prev2 + nums[i])
prev2 = prev1
prev1 = curr
return prev1
print(rob([1, 2, 3, 1])) # 4 (rob houses 0 and 2: 1+3=4)
print(rob([2, 7, 9, 3, 1])) # 12 (rob houses 0, 2, 4: 2+9+1=12)
print(rob([0])) # 0
print(rob([1, 2])) # 2
print(rob([2, 1, 1, 2])) # 4 (rob houses 0 and 3)
Complexity
- Time:
O(n)— single pass - Space:
O(1)— two variables
Common Pitfalls
Initializing prev1 = nums[1] instead of max(nums[0], nums[1]). The best result from the first two houses isn’t necessarily nums[1] — you might prefer to rob only nums[0] if it’s larger.
Thinking you can always rob every other house. A greedy “rob every other house” doesn’t work for inputs like [2, 1, 1, 2] — robbing indices 0 and 3 gives 4, but alternating gives only 3.
Forgetting the n=1 edge case. If there’s only one house, just rob it. The loop doesn’t run, and accessing nums[1] would be out of bounds.
House Robber II
Difficulty: Medium Source: NeetCode
Problem
You are a professional robber planning to rob houses along a street. All houses at this place are arranged in a circle. That means the first house is the neighbor of the last one. If two adjacent houses are broken into, the police are alerted.
Given an integer array
numsrepresenting the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.Example 1: Input:
nums = [2,3,2]Output:3Example 2: Input:
nums = [1,2,3,1]Output:4Constraints:
1 <= nums.length <= 1000 <= nums[i] <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- House Robber I — linear house robber DP
- Circular Constraint Reduction — breaking a circular problem into two linear subproblems
1. Brute Force (All Subsets)
Intuition
Try every possible subset of houses, skip any subset where two adjacent houses (including first and last) are both selected, and return the maximum sum. Exponential time but correct.
Solution
def rob_brute(nums):
n = len(nums)
if n == 1:
return nums[0]
best = 0
for mask in range(1 << n):
total = 0
valid = True
for i in range(n):
if mask & (1 << i):
# Check adjacency (including wrap-around)
if mask & (1 << ((i + 1) % n)):
valid = False
break
total += nums[i]
if valid:
best = max(best, total)
return best
print(rob_brute([2, 3, 2])) # 3
print(rob_brute([1, 2, 3, 1])) # 4
print(rob_brute([1, 2, 3])) # 3
Complexity
- Time:
O(2^n * n)— exponential - Space:
O(1)
2. Two-Pass Linear DP
Intuition
The circular constraint means house 0 and house n-1 are neighbors — you can’t rob both. The key insight: at least one of house 0 or house n-1 is NOT robbed in any optimal solution. So we can break the problem into two independent linear House Robber I problems:
- Rob houses
0..n-2(exclude the last house) - Rob houses
1..n-1(exclude the first house)
Take the maximum of both. This guarantees we never rob both the first and last houses simultaneously.
Algorithm
- If
n == 1, returnnums[0]. - Run House Robber I on
nums[:-1](skip last). - Run House Robber I on
nums[1:](skip first). - Return the max of both results.
Solution
def rob(nums):
n = len(nums)
if n == 1:
return nums[0]
if n == 2:
return max(nums)
def rob_linear(houses):
if not houses:
return 0
if len(houses) == 1:
return houses[0]
prev2 = houses[0]
prev1 = max(houses[0], houses[1])
for i in range(2, len(houses)):
curr = max(prev1, prev2 + houses[i])
prev2 = prev1
prev1 = curr
return prev1
# Option 1: rob houses 0..n-2 (exclude last)
# Option 2: rob houses 1..n-1 (exclude first)
return max(rob_linear(nums[:-1]), rob_linear(nums[1:]))
print(rob([2, 3, 2])) # 3
print(rob([1, 2, 3, 1])) # 4
print(rob([1, 2, 3])) # 3 (rob 1 and 3)
print(rob([1])) # 1
print(rob([1, 2])) # 2
Complexity
- Time:
O(n)— two linear passes - Space:
O(1)— only rolling variables (if we pass slices it’s O(n), but we can pass indices instead)
Common Pitfalls
Forgetting the n=1 and n=2 edge cases. For one house, rob it. For two houses, take the larger. The main two-pass logic assumes at least three houses.
Thinking you need to try all four combinations. It might seem like you need to check “rob first, skip last”, “skip first, rob last”, “skip both”, and “rob both”. You don’t — “rob both” is illegal, and the other three cases are all covered by the two linear subproblems (skipping one endpoint each time).
Passing slices creates new lists. nums[:-1] and nums[1:] create copies in Python. For large inputs, you could instead pass index ranges to the linear robber to avoid the extra allocation.
Longest Palindromic Substring
Difficulty: Medium Source: NeetCode
Problem
Given a string
s, return the longest palindromic substring ins.Example 1: Input:
s = "babad"Output:"bab"(or"aba", both are valid)Example 2: Input:
s = "cbbd"Output:"bb"Constraints:
1 <= s.length <= 1000sconsists of only digits and English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Palindrome Checking — recognizing when a string reads the same forwards and backwards
- Two Pointers — expanding outward from a center
- String Indexing — working with substrings and indices
1. Brute Force
Intuition
Check every possible substring by trying all pairs of start and end indices. For each substring, verify if it’s a palindrome. Track the longest valid one found. Three nested operations: O(n²) pairs × O(n) palindrome check = O(n³) total.
Algorithm
- For every pair
(i, j)wherei <= j, extracts[i:j+1]. - Check if it’s a palindrome.
- Track the longest palindrome found.
- Return it.
Solution
def longestPalindrome_brute(s):
n = len(s)
best = s[0]
for i in range(n):
for j in range(i, n):
sub = s[i:j + 1]
if sub == sub[::-1] and len(sub) > len(best):
best = sub
return best
print(longestPalindrome_brute("babad")) # "bab" or "aba"
print(longestPalindrome_brute("cbbd")) # "bb"
print(longestPalindrome_brute("a")) # "a"
print(longestPalindrome_brute("racecar")) # "racecar"
Complexity
- Time:
O(n³)— n² substrings × O(n) palindrome check - Space:
O(n)— substring creation
2. Expand Around Center
Intuition
Every palindrome has a center. For a string of length n, there are 2n - 1 possible centers: each character (for odd-length palindromes) and each gap between characters (for even-length palindromes). Expand outward from each center as long as characters match. This finds all palindromes in O(n²) total — much better than brute force.
This is the “sweet spot” solution: simple to implement, fast in practice, and doesn’t require any complex data structures.
Algorithm
- For each center position (single char and between chars):
- Expand outward while
s[left] == s[right]. - Track the longest palindrome found.
- Expand outward while
- Return the longest substring.
Solution
def longestPalindrome(s):
n = len(s)
start, max_len = 0, 1
def expand(left, right):
nonlocal start, max_len
while left >= 0 and right < n and s[left] == s[right]:
if right - left + 1 > max_len:
start = left
max_len = right - left + 1
left -= 1
right += 1
for i in range(n):
expand(i, i) # odd-length palindromes centered at i
expand(i, i + 1) # even-length palindromes centered between i and i+1
return s[start:start + max_len]
print(longestPalindrome("babad")) # "bab"
print(longestPalindrome("cbbd")) # "bb"
print(longestPalindrome("a")) # "a"
print(longestPalindrome("racecar")) # "racecar"
print(longestPalindrome("abcba")) # "abcba"
print(longestPalindrome("aacabdkacaa")) # "aca" (first or last)
Complexity
- Time:
O(n²)— n centers, each expanding up to O(n) - Space:
O(1)— only indices tracked
Common Pitfalls
Only checking odd-length palindromes. Expanding from a single character only finds odd-length palindromes like “aba”. You must also expand from gaps between characters to find even-length ones like “abba”.
Returning the substring directly vs tracking indices. Tracking (start, max_len) is cleaner and avoids creating many string objects. Return s[start:start+max_len] at the end.
Using sub == sub[::-1] in the brute force on large inputs. This creates an extra reversed copy of the string for every substring — it works but is slow. The expand approach avoids this entirely.
Palindromic Substrings
Difficulty: Medium Source: NeetCode
Problem
Given a string
s, return the number of palindromic substrings in it. A substring is a contiguous sequence of characters within the string.Example 1: Input:
s = "abc"Output:3(Palindromes: “a”, “b”, “c”)Example 2: Input:
s = "aaa"Output:6(Palindromes: “a”, “a”, “a”, “aa”, “aa”, “aaa”)Constraints:
1 <= s.length <= 1000sconsists of lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Longest Palindromic Substring — the expand-around-center technique
- Palindrome Counting — understanding that each expansion step adds a new palindrome
1. Brute Force
Intuition
Check every possible substring to see if it’s a palindrome. For each valid palindrome, increment the count. This is O(n³) due to the nested loops plus the palindrome check, but simple to reason about.
Algorithm
- For every pair
(i, j), check ifs[i:j+1]is a palindrome. - Increment count if it is.
- Return the total count.
Solution
def countSubstrings_brute(s):
n = len(s)
count = 0
for i in range(n):
for j in range(i, n):
sub = s[i:j + 1]
if sub == sub[::-1]:
count += 1
return count
print(countSubstrings_brute("abc")) # 3
print(countSubstrings_brute("aaa")) # 6
print(countSubstrings_brute("a")) # 1
print(countSubstrings_brute("aa")) # 3
Complexity
- Time:
O(n³)— n² substrings × O(n) palindrome check - Space:
O(n)— substring allocation
2. Expand Around Center
Intuition
Just like the Longest Palindromic Substring problem, expand from every center. But instead of tracking the longest, increment the count for each successful expansion step. Each step outward that still forms a palindrome is one more palindromic substring.
There are 2n - 1 centers: n single characters (odd palindromes) and n-1 gaps (even palindromes). Each character starts as a palindrome of length 1, so start the count at n and add extras as you expand.
Algorithm
- For each center (char and gap):
- Expand outward. Each successful expansion = +1 to count.
- The center itself (single char) counts as 1, so initialize count to 0 and increment on each valid window including the center.
- Return total count.
Solution
def countSubstrings(s):
n = len(s)
count = 0
def expand(left, right):
nonlocal count
while left >= 0 and right < n and s[left] == s[right]:
count += 1
left -= 1
right += 1
for i in range(n):
expand(i, i) # odd-length palindromes
expand(i, i + 1) # even-length palindromes
return count
print(countSubstrings("abc")) # 3
print(countSubstrings("aaa")) # 6
print(countSubstrings("a")) # 1
print(countSubstrings("aa")) # 3
print(countSubstrings("aba")) # 4 ("a","b","a","aba")
Complexity
- Time:
O(n²)— n centers, each expanding up to O(n) - Space:
O(1)— just a counter and indices
Common Pitfalls
Not counting the center itself. When expand(i, i) is called, the first iteration where left == right == i and s[i] == s[i] is always true — that’s the single character palindrome. Make sure the increment happens inside the while loop so it counts this base case.
Missing even-length palindromes. Don’t forget expand(i, i+1) for each i. A string like “aaa” has three “aa” palindromes, which only get counted with even-center expansion.
Treating this as “count distinct palindromes”. The problem counts by occurrence, not uniqueness. In “aaa”, the string “aa” appears twice and both count separately.
Decode Ways
Difficulty: Medium Source: NeetCode
Problem
A message containing letters from
A-Zcan be encoded into numbers using the following mapping:'A' → "1",'B' → "2", …,'Z' → "26"To decode an encoded message, all the digits must be mapped back into letters using the reverse of the mapping above. Given a string
scontaining only digits, return the number of ways to decode it. If there is no valid decoding, return0.Example 1: Input:
s = "12"Output:2(“AB” or “L”)Example 2: Input:
s = "226"Output:3(“BZ”, “VF”, or “BBF”… wait — “226” → “B”,“B”,“F” or “V”,“F” or “B”,“Z”)Example 3: Input:
s = "06"Output:0Constraints:
1 <= s.length <= 100scontains only digitssmay contain leading zeros
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dynamic Programming — building up counts of valid decodings
- String Parsing — checking single and two-digit validity
- Fibonacci-style DP — how previous two states feed into the current one
1. Brute Force (Recursion)
Intuition
At each position, try decoding a single digit (1-9) or a two-digit number (10-26). Recursively count all valid decodings. Without memoization, this recomputes the same substrings many times.
Algorithm
- At each position
i, check ifs[i]is a valid single digit (not ‘0’). - Check if
s[i:i+2]is a valid two-digit encoding (10-26). - Sum the results of valid recursive calls.
Solution
def numDecodings_brute(s):
def dp(i):
if i == len(s):
return 1 # successfully decoded everything
if s[i] == '0':
return 0 # '0' can't start a valid encoding
# Single-digit decode
ways = dp(i + 1)
# Two-digit decode
if i + 1 < len(s) and 10 <= int(s[i:i + 2]) <= 26:
ways += dp(i + 2)
return ways
return dp(0)
print(numDecodings_brute("12")) # 2
print(numDecodings_brute("226")) # 3
print(numDecodings_brute("06")) # 0
print(numDecodings_brute("11106")) # 2
Complexity
- Time:
O(2^n)— two branches at each step - Space:
O(n)— recursion stack
2. Dynamic Programming (Bottom-Up)
Intuition
dp[i] = number of ways to decode the first i characters of s (i.e., s[:i]).
dp[0] = 1(empty string has one decoding — do nothing)dp[1] = 1ifs[0] != '0', else0
Transitions:
- Single digit: if
s[i-1] != '0', thendp[i] += dp[i-1](the last character forms a valid letter) - Two digits: if
10 <= int(s[i-2:i]) <= 26, thendp[i] += dp[i-2](the last two characters form a valid letter)
Like House Robber, only the previous two values matter, so we can optimize to O(1) space.
Algorithm
- Initialize
dp1 = 1(represents dp[i-1]),dp2 = 1(represents dp[i-2]). - For each position
ifrom 1 to n:- Compute
curr = 0. - Single digit: if
s[i-1] != '0', adddp1. - Two digits: if
10 <= int(s[i-2:i]) <= 26, adddp2. - Slide:
dp2 = dp1,dp1 = curr.
- Compute
- Return
dp1.
Solution
def numDecodings(s):
n = len(s)
if not s or s[0] == '0':
return 0
# dp1 = ways to decode s[:i-1], dp2 = ways to decode s[:i-2]
dp1, dp2 = 1, 1 # dp[1] = 1, dp[0] = 1
for i in range(2, n + 1):
curr = 0
# Single-digit decode of s[i-1]
if s[i - 1] != '0':
curr += dp1
# Two-digit decode of s[i-2:i]
two_digit = int(s[i - 2:i])
if 10 <= two_digit <= 26:
curr += dp2
dp2 = dp1
dp1 = curr
return dp1
print(numDecodings("12")) # 2
print(numDecodings("226")) # 3
print(numDecodings("06")) # 0
print(numDecodings("11106")) # 2
print(numDecodings("10")) # 1 ("J" only)
print(numDecodings("27")) # 1 ("BG" only, 27 > 26)
print(numDecodings("2101")) # 1
Complexity
- Time:
O(n)— single pass - Space:
O(1)— rolling variables
Common Pitfalls
Not handling leading zeros. "06" should return 0 because ‘0’ alone is not valid, and “06” as a two-digit number isn’t valid (must be 10-26). Always check s[i-1] != '0' before the single-digit decode.
Two-digit range is 10-26, not 1-26. Numbers 01-09 are invalid two-digit encodings. Only 10-26 are valid. Checking int(s[i-2:i]) <= 26 alone isn’t enough — you need >= 10 too.
Confusing the DP index shift. dp[i] represents ways to decode s[:i] (the first i characters), not s[i]. So s[i-1] is the current character and s[i-2:i] is the two-character window.
Coin Change
Difficulty: Medium Source: NeetCode
Problem
You are given an integer array
coinsrepresenting coins of different denominations and an integeramountrepresenting a total amount of money. Return the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return-1.You may assume that you have an infinite number of each kind of coin.
Example 1: Input:
coins = [1,5,11], amount = 15Output:3(11 + 1 + … wait, 11+1+1+1+1 = 5 coins… actually 5+5+5 = 3 coins)Example 2: Input:
coins = [1,5,11], amount = 15Output:3(5 + 5 + 5)Example 3: Input:
coins = [2], amount = 3Output:-1Constraints:
1 <= coins.length <= 121 <= coins[i] <= 2^31 - 10 <= amount <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Unbounded Knapsack — using each item (coin) unlimited times
- BFS on Implicit Graph — treating amounts as nodes and coins as edges
- DP Arrays — building up optimal subproblems for each sub-amount
1. Brute Force (Recursion)
Intuition
At each amount, try subtracting every coin and recurse. The minimum of all valid recursive calls plus 1 is the answer. Without memoization, this explores an exponential number of combinations.
Algorithm
dp(amount)= fewest coins to makeamount.- Base case:
dp(0) = 0. - Recursive case:
dp(a) = 1 + min(dp(a - coin) for coin in coins if coin <= a).
Solution
def coinChange_brute(coins, amount):
memo = {}
def dp(a):
if a == 0:
return 0
if a < 0:
return float('inf')
if a in memo:
return memo[a]
result = 1 + min(dp(a - coin) for coin in coins)
memo[a] = result
return result
ans = dp(amount)
return ans if ans != float('inf') else -1
print(coinChange_brute([1, 5, 11], 15)) # 3
print(coinChange_brute([2], 3)) # -1
print(coinChange_brute([1, 2, 5], 11)) # 3 (5+5+1)
print(coinChange_brute([1], 0)) # 0
Complexity
- Time:
O(amount * len(coins))with memoization - Space:
O(amount)— memo table and recursion stack
2. Dynamic Programming (Bottom-Up)
Intuition
Build a dp array where dp[i] = fewest coins needed to make amount i. Start with dp[0] = 0 (zero coins for zero amount), and initialize everything else to infinity.
For each amount from 1 to amount, try every coin: if dp[i - coin] + 1 < dp[i], update. This is the classic unbounded knapsack pattern — you can use each coin as many times as needed.
Algorithm
- Initialize
dp = [inf] * (amount + 1),dp[0] = 0. - For each
ifrom 1 toamount:- For each
coinincoins:- If
coin <= ianddp[i - coin] + 1 < dp[i]: updatedp[i].
- If
- For each
- Return
dp[amount]if not infinity, else-1.
Solution
def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if coin <= i:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
print(coinChange([1, 5, 11], 15)) # 3 (5+5+5)
print(coinChange([2], 3)) # -1
print(coinChange([1, 2, 5], 11)) # 3 (5+5+1)
print(coinChange([1], 0)) # 0
print(coinChange([1], 1)) # 1
print(coinChange([186, 419, 83, 408], 6249)) # 20
Complexity
- Time:
O(amount * len(coins))— nested loop - Space:
O(amount)— DP array
Common Pitfalls
Initializing dp to 0 instead of infinity. You want to find the minimum, so uncomputed states should start at infinity to avoid false minimums. Only dp[0] = 0 is the true base case.
Not checking if the answer is reachable. If dp[amount] is still infinity after filling, no valid combination exists — return -1.
Confusing this with 0/1 knapsack. In Coin Change, you can use each coin unlimited times (unbounded). In 0/1 knapsack, each item is used at most once. The bottom-up loop order here (iterating amounts in the outer loop) supports unbounded usage.
Maximum Product Subarray
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, find a subarray that has the largest product, and return the product.Example 1: Input:
nums = [2,3,-2,4]Output:6(subarray[2,3])Example 2: Input:
nums = [-2,0,-1]Output:0Constraints:
1 <= nums.length <= 2 * 10^4-10 <= nums[i] <= 10- The product of any prefix or suffix of
numsis guaranteed to fit in a 32-bit integer
Prerequisites
Before attempting this problem, you should be comfortable with:
- Kadane’s Algorithm — maximum sum subarray
- Tracking Min and Max — why we need both when negatives are involved
- Dynamic Programming — state transitions at each element
1. Brute Force
Intuition
Try every possible subarray by trying all start and end index pairs. Compute the product and track the maximum. Simple but slow.
Algorithm
- For each starting index
i:- Maintain a running product, extending to the right.
- Update the global maximum at each step.
- Return the maximum product seen.
Solution
def maxProduct_brute(nums):
n = len(nums)
result = nums[0]
for i in range(n):
product = 1
for j in range(i, n):
product *= nums[j]
result = max(result, product)
return result
print(maxProduct_brute([2, 3, -2, 4])) # 6
print(maxProduct_brute([-2, 0, -1])) # 0
print(maxProduct_brute([-2])) # -2
print(maxProduct_brute([-2, 3, -4])) # 24
Complexity
- Time:
O(n²)— all pairs - Space:
O(1)
2. Dynamic Programming (Track Max and Min)
Intuition
This is like Kadane’s algorithm, but with a twist: a large negative can become a large positive if multiplied by another negative. So we must track both the maximum product and the minimum product ending at each position.
At each element:
max_prod = max(num, num * prev_max, num * prev_min)min_prod = min(num, num * prev_max, num * prev_min)
The current element alone (num) handles the case of starting a fresh subarray. Multiplying by the previous max or min handles extending the existing subarray.
Algorithm
- Initialize
max_prod = min_prod = result = nums[0]. - For each number from index 1 to n-1:
- Compute
candidates = (num, num * max_prod, num * min_prod). new_max = max(candidates),new_min = min(candidates).- Update
result = max(result, new_max). - Set
max_prod, min_prod = new_max, new_min.
- Compute
- Return
result.
Solution
def maxProduct(nums):
max_prod = min_prod = result = nums[0]
for num in nums[1:]:
# Multiplying by num can flip max and min if num is negative
candidates = (num, num * max_prod, num * min_prod)
max_prod = max(candidates)
min_prod = min(candidates)
result = max(result, max_prod)
return result
print(maxProduct([2, 3, -2, 4])) # 6
print(maxProduct([-2, 0, -1])) # 0
print(maxProduct([-2])) # -2
print(maxProduct([-2, 3, -4])) # 24 (-2 * 3 * -4 = 24)
print(maxProduct([0, 2])) # 2
print(maxProduct([-1, -2, -9, -6])) # 108 (-2*-9*-6... actually -2*-9=18, 18*-6=-108. -1*-2*-9=18? No: -1*-2=2, 2*-9=-18, -18*-6=108... yes 108)
Complexity
- Time:
O(n)— single pass - Space:
O(1)— only three variables
Common Pitfalls
Only tracking the maximum, not the minimum. A negative minimum can become the maximum after multiplying by another negative. If you only track max_prod, you’ll miss these cases entirely.
Forgetting to consider starting a fresh subarray at the current element. Always include the bare num as a candidate. If the running products are terrible (say, due to many negatives or a zero), it’s better to start fresh.
Zeros resetting the product. When you hit a 0, both max and min become 0, effectively starting a new subarray from the next element. The num candidate in the max/min calculation handles this correctly.
Word Break
Difficulty: Medium Source: NeetCode
Problem
Given a string
sand a dictionary of stringswordDict, returntrueifscan be segmented into a space-separated sequence of one or more dictionary words.Note that the same word in the dictionary may be reused multiple times in the segmentation.
Example 1: Input:
s = "leetcode",wordDict = ["leet","code"]Output:trueExample 2: Input:
s = "applepenapple",wordDict = ["apple","pen"]Output:trueExample 3: Input:
s = "catsandog",wordDict = ["cats","dog","sand","and","cat"]Output:falseConstraints:
1 <= s.length <= 3001 <= wordDict.length <= 10001 <= wordDict[i].length <= 20
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dynamic Programming — boolean DP over string prefixes
- Hashing — fast word lookup in a set
- Substring Matching — checking if
s[j:i]is in the dictionary
1. Brute Force (Recursion)
Intuition
Try to peel off a valid word from the front of the string, then recursively solve the rest. If any split leads to a full segmentation, return True. Without memoization, the same suffix is checked many times.
Algorithm
- If
sis empty, return True. - For each word in
wordDict, ifsstarts with it, recurse ons[len(word):]. - Return True if any recursion returns True.
Solution
def wordBreak_brute(s, wordDict):
word_set = set(wordDict)
memo = {}
def dp(start):
if start == len(s):
return True
if start in memo:
return memo[start]
for end in range(start + 1, len(s) + 1):
if s[start:end] in word_set and dp(end):
memo[start] = True
return True
memo[start] = False
return False
return dp(0)
print(wordBreak_brute("leetcode", ["leet", "code"])) # True
print(wordBreak_brute("applepenapple", ["apple", "pen"])) # True
print(wordBreak_brute("catsandog", ["cats","dog","sand","and","cat"])) # False
print(wordBreak_brute("aaaaaaa", ["aaaa", "aaa"])) # True
Complexity
- Time:
O(n³)with memoization — n start positions × n end positions × O(n) substring check - Space:
O(n)— memo table
2. Dynamic Programming (Bottom-Up)
Intuition
dp[i] = True if s[:i] can be segmented into words from the dictionary. Build this up from left to right.
For each position i, check all positions j < i: if dp[j] is True and s[j:i] is a word in the dictionary, then dp[i] is True.
This is essentially asking: “Is there a valid cut point j such that the left part is segmentable and the right part s[j:i] is a dictionary word?”
Algorithm
- Initialize
dp = [False] * (n + 1), setdp[0] = True(empty string is trivially segmentable). - For each
ifrom 1 to n:- For each
jfrom 0 to i:- If
dp[j]ands[j:i]inword_set: setdp[i] = True, break.
- If
- For each
- Return
dp[n].
Solution
def wordBreak(s, wordDict):
word_set = set(wordDict)
n = len(s)
dp = [False] * (n + 1)
dp[0] = True # empty prefix is always valid
for i in range(1, n + 1):
for j in range(i):
if dp[j] and s[j:i] in word_set:
dp[i] = True
break
return dp[n]
print(wordBreak("leetcode", ["leet", "code"])) # True
print(wordBreak("applepenapple", ["apple", "pen"])) # True
print(wordBreak("catsandog", ["cats","dog","sand","and","cat"])) # False
print(wordBreak("aaaaaaa", ["aaaa", "aaa"])) # True
print(wordBreak("a", ["b"])) # False
print(wordBreak("", ["anything"])) # True (dp[0])
Complexity
- Time:
O(n³)— n²(i, j)pairs × O(n) substring hashing - Space:
O(n)— DP array
Common Pitfalls
Forgetting dp[0] = True. The base case is that the empty string requires no words. Without this, no word in the first position can ever be validated.
Not breaking after finding a valid split. Once dp[i] is set to True, you can break early. Continuing to check other j values is wasted work.
Using wordDict as a list for lookups. Convert wordDict to a set at the start. Checking membership in a list is O(k) per lookup (where k = number of words), but O(1) in a set. The difference matters when wordDict is large.
Longest Increasing Subsequence
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, return the length of the longest strictly increasing subsequence.A subsequence is derived from an array by deleting some or no elements without changing the order of the remaining elements.
Example 1: Input:
nums = [10,9,2,5,3,7,101,18]Output:4(e.g. [2,3,7,101] or [2,5,7,101])Example 2: Input:
nums = [0,1,0,3,2,3]Output:4Constraints:
1 <= nums.length <= 2500-10^4 <= nums[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Subsequences vs Substrings — elements don’t need to be contiguous
- DP on Indices —
dp[i]depends on alldp[j]wherej < i - Binary Search (bisect) — used in the O(n log n) patience sorting approach
1. Dynamic Programming O(n²)
Intuition
dp[i] = length of the longest increasing subsequence ending at index i. For each element, look back at all previous elements: if nums[j] < nums[i], then we can extend the LIS ending at j by one. Take the maximum of all such extensions.
Algorithm
- Initialize
dp = [1] * n(each element is an LIS of length 1 by itself). - For each
ifrom 1 to n-1:- For each
jfrom 0 to i-1:- If
nums[j] < nums[i]:dp[i] = max(dp[i], dp[j] + 1).
- If
- For each
- Return
max(dp).
Solution
def lengthOfLIS_dp(nums):
n = len(nums)
dp = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
print(lengthOfLIS_dp([10, 9, 2, 5, 3, 7, 101, 18])) # 4
print(lengthOfLIS_dp([0, 1, 0, 3, 2, 3])) # 4
print(lengthOfLIS_dp([7, 7, 7, 7])) # 1
print(lengthOfLIS_dp([1, 3, 6, 7, 9, 4, 10, 5, 6])) # 6
Complexity
- Time:
O(n²)— nested loops - Space:
O(n)— DP array
2. Patience Sorting with Binary Search O(n log n)
Intuition
Imagine playing solitaire where you build piles of cards. You place each card on the leftmost pile whose top card is greater than or equal to the current card. If no such pile exists, start a new pile.
The number of piles at the end equals the length of the LIS. We maintain a tails array where tails[i] is the smallest tail element of all increasing subsequences of length i+1. This array stays sorted, so we can use binary search to find the right position for each new element.
This doesn’t reconstruct the actual LIS, but correctly counts its length.
Algorithm
- Initialize an empty
tailslist. - For each number in
nums:- Use binary search to find the leftmost position in
tailswheretails[pos] >= num. - If
pos == len(tails), appendnum(new longer subsequence). - Otherwise, replace
tails[pos] = num(better tail for this length).
- Use binary search to find the leftmost position in
- Return
len(tails).
Solution
import bisect
def lengthOfLIS(nums):
tails = [] # tails[i] = smallest tail of LIS with length i+1
for num in nums:
# Find position to place num (replace first tail >= num)
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
tails.append(num)
else:
tails[pos] = num
return len(tails)
print(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])) # 4
print(lengthOfLIS([0, 1, 0, 3, 2, 3])) # 4
print(lengthOfLIS([7, 7, 7, 7])) # 1 (strict: 7 >= 7, replaces)
print(lengthOfLIS([1, 3, 6, 7, 9, 4, 10, 5, 6])) # 6
print(lengthOfLIS([1])) # 1
Complexity
- Time:
O(n log n)— n elements × binary search per element - Space:
O(n)— tails array
Common Pitfalls
Using bisect_right instead of bisect_left. Since we need strictly increasing, we use bisect_left to find the first position where the tail is >= num. Using bisect_right would allow non-strict increases.
Thinking tails represents an actual subsequence. The tails array is a bookkeeping structure — it’s not the actual LIS. Its length gives you the LIS length, but the elements might not form a valid increasing subsequence themselves.
Forgetting to handle all-equal arrays. For [7, 7, 7], every element would replace tails[0] because of strict inequality. The LIS length is 1, which is correct — no strictly increasing subsequence of length > 1 exists.
Partition Equal Subset Sum
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, returntrueif you can partition the array into two subsets such that the sum of the elements in both subsets is equal, orfalseotherwise.Example 1: Input:
nums = [1,5,11,5]Output:true([1,5,5] and [11])Example 2: Input:
nums = [1,2,3,5]Output:falseConstraints:
1 <= nums.length <= 2001 <= nums[i] <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- 0/1 Knapsack — each item can be used at most once
- Subset Sum — can a subset of elements sum to a target?
- Boolean DP — tracking reachable sums
1. Brute Force (Recursion)
Intuition
If we can find a subset that sums to exactly total // 2, the remaining elements automatically sum to the same amount. So the problem reduces to: “can we find a subset summing to target = total // 2?” Recursively try including or excluding each element.
Algorithm
- Compute
total = sum(nums). Iftotalis odd, return False immediately. - Set
target = total // 2. - Use recursion: at each index, either include the current element or skip it.
- Return True if we can reach exactly
target.
Solution
def canPartition_brute(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
memo = {}
def dp(i, remaining):
if remaining == 0:
return True
if remaining < 0 or i >= len(nums):
return False
if (i, remaining) in memo:
return memo[(i, remaining)]
# Include nums[i] or skip it
result = dp(i + 1, remaining - nums[i]) or dp(i + 1, remaining)
memo[(i, remaining)] = result
return result
return dp(0, target)
print(canPartition_brute([1, 5, 11, 5])) # True
print(canPartition_brute([1, 2, 3, 5])) # False
print(canPartition_brute([1, 1])) # True
print(canPartition_brute([1, 2, 5])) # False
Complexity
- Time:
O(n * target)with memoization - Space:
O(n * target)— memo table
2. 0/1 Knapsack DP (Boolean Set)
Intuition
Use a boolean set dp where dp[j] means “we can make sum j from elements seen so far.” Start with dp = {0} (we can always make sum 0). For each number, update the set: every reachable sum j can become j + num if we include the current element.
Process elements one at a time, iterating sums in descending order (or use a new set each round) so we don’t use the same element twice (0/1 knapsack constraint).
Algorithm
- Check odd total → False.
- Initialize
dp = {0}(achievable sums). - For each
numinnums:- For each
jin current dp (iterate backwards if using an array):- Add
j + numto dp.
- Add
- If
targetis in dp: return True early.
- For each
- Return
target in dp.
Solution
def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = {0} # achievable sums
for num in nums:
new_dp = set()
for j in dp:
new_dp.add(j)
new_dp.add(j + num)
dp = new_dp
if target in dp:
return True
return target in dp
def canPartition_array(nums):
"""Same idea, but with a boolean array — classic knapsack formulation."""
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
# Iterate in reverse to avoid reusing the same element
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
print(canPartition([1, 5, 11, 5])) # True
print(canPartition([1, 2, 3, 5])) # False
print(canPartition([1, 1])) # True
print(canPartition_array([1, 5, 11, 5])) # True
print(canPartition_array([1, 2, 3, 5])) # False
print(canPartition_array([3, 3, 3, 4, 5])) # True
Complexity
- Time:
O(n * target)— n elements × target sums - Space:
O(target)— boolean DP array
Common Pitfalls
Not checking if total is odd. If the total sum is odd, it’s impossible to split evenly. Always check this first as an early exit.
Iterating the inner loop forward in the array version. In the dp array approach, you must iterate j from target down to num. Forward iteration would let the same element be used multiple times (turning it into unbounded knapsack).
Using a set but forgetting it’s copied each round. If you iterate over dp while modifying it in the same loop, you’ll include elements multiple times. Either iterate over a copy (for j in dp.copy()) or build a new set each round.
Combination Sum IV
Difficulty: Medium Source: NeetCode
Problem
Given an array of distinct integers
numsand a target integertarget, return the number of possible combinations that add up totarget.The test cases are generated so that the answer fits in a 32-bit integer. Note: the order of numbers in a combination matters — different orderings count as different combinations.
Example 1: Input:
nums = [1,2,3], target = 4Output:7(1+1+1+1, 1+1+2, 1+2+1, 1+3, 2+1+1, 2+2, 3+1)Example 2: Input:
nums = [9], target = 3Output:0Constraints:
1 <= nums.length <= 2001 <= nums[i] <= 1000- All the integers in
numsare unique1 <= target <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Unbounded Knapsack — using items unlimited times
- Order Matters vs Order Doesn’t Matter — understanding when this gives permutations vs combinations
- DP on a Target Sum — building up from smaller targets
1. Brute Force (Recursion with Memoization)
Intuition
At each step, try placing every number from nums. Sum these choices recursively. Since order matters (1+2 and 2+1 are different), we don’t need to track which numbers have been used — we always start from all numbers again. This is more like counting ordered arrangements (permutations), not combinations.
Algorithm
dp(remaining)= number of ways to reachremainingusing any numbers innums.- Base case:
dp(0) = 1. - Recursive case: sum of
dp(remaining - num)for allnuminnumswherenum <= remaining.
Solution
def combinationSum4_memo(nums, target):
memo = {}
def dp(remaining):
if remaining == 0:
return 1
if remaining in memo:
return memo[remaining]
result = 0
for num in nums:
if num <= remaining:
result += dp(remaining - num)
memo[remaining] = result
return result
return dp(target)
print(combinationSum4_memo([1, 2, 3], 4)) # 7
print(combinationSum4_memo([9], 3)) # 0
print(combinationSum4_memo([1, 2, 3], 0)) # 1
print(combinationSum4_memo([3, 1, 2], 35)) # some large number
Complexity
- Time:
O(target * len(nums))— each sub-target computed once - Space:
O(target)— memo table
2. Dynamic Programming (Bottom-Up)
Intuition
dp[i] = number of ordered ways to sum to i. For each target amount i, try every number in nums. If num <= i, then dp[i] += dp[i - num].
The key insight: unlike 0/1 knapsack (where you iterate nums in the outer loop), here you iterate targets in the outer loop and nums in the inner loop. This is what counts ordered permutations — each target amount is reachable from all previous amounts, in any order.
Algorithm
- Initialize
dp = [0] * (target + 1),dp[0] = 1. - For each
ifrom 1 totarget:- For each
numinnums:- If
num <= i:dp[i] += dp[i - num].
- If
- For each
- Return
dp[target].
Solution
def combinationSum4(nums, target):
dp = [0] * (target + 1)
dp[0] = 1 # one way to reach 0: use nothing
for i in range(1, target + 1):
for num in nums:
if num <= i:
dp[i] += dp[i - num]
return dp[target]
print(combinationSum4([1, 2, 3], 4)) # 7
print(combinationSum4([9], 3)) # 0
print(combinationSum4([1, 2, 3], 0)) # 1
print(combinationSum4([4, 2, 1], 32)) # large number
Complexity
- Time:
O(target * len(nums))— nested loop - Space:
O(target)— DP array
Common Pitfalls
Confusing this with Combination Sum II (unordered). In Combination Sum II, [1,2] and [2,1] are the same and counted once. Here, order matters — they’re different. The outer-loop-over-targets pattern is what makes this count ordered arrangements.
Wrong loop order for unordered combinations. If you want unordered combinations (where [1,2] == [2,1]), you’d loop over nums in the outer loop. The fact that this problem counts ordered arrangements means target is the outer loop.
Not initializing dp[0] = 1. The empty combination (nothing selected) is the base case that seeds all other counts. Without it, nothing propagates.
Perfect Squares
Difficulty: Medium Source: NeetCode
Problem
Given an integer
n, return the least number of perfect square numbers that sum ton.A perfect square is an integer that is the square of an integer (e.g., 1, 4, 9, 16, …).
Example 1: Input:
n = 12Output:3(4 + 4 + 4)Example 2: Input:
n = 13Output:2(4 + 9)Constraints:
1 <= n <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Coin Change — this is structurally identical, with perfect squares as “coins”
- BFS on Implicit Graph — treating each value as a node
- Unbounded Knapsack — using the same squares unlimited times
1. Brute Force (Recursion with Memoization)
Intuition
This is exactly Coin Change, but the “coins” are all perfect squares up to n. Try subtracting each perfect square from n and recurse. Cache results to avoid recomputation.
Algorithm
- Precompute all perfect squares up to
n. dp(remaining)= minimum squares needed to sum toremaining.- Base case:
dp(0) = 0. - Recursive case:
1 + min(dp(remaining - sq) for sq in squares if sq <= remaining).
Solution
import math
def numSquares_memo(n):
squares = [i * i for i in range(1, int(math.isqrt(n)) + 1)]
memo = {}
def dp(remaining):
if remaining == 0:
return 0
if remaining in memo:
return memo[remaining]
best = float('inf')
for sq in squares:
if sq > remaining:
break
best = min(best, 1 + dp(remaining - sq))
memo[remaining] = best
return best
return dp(n)
print(numSquares_memo(12)) # 3
print(numSquares_memo(13)) # 2
print(numSquares_memo(1)) # 1
print(numSquares_memo(4)) # 1
Complexity
- Time:
O(n * sqrt(n))with memoization - Space:
O(n)— memo table
2. Dynamic Programming (Bottom-Up)
Intuition
Same as Coin Change bottom-up. dp[i] = minimum number of perfect squares summing to i. For each i, try all perfect squares j*j <= i: dp[i] = min(dp[i], dp[i - j*j] + 1).
Start with dp[0] = 0 and infinity everywhere else. Build up to dp[n].
Algorithm
- Precompute squares:
[1, 4, 9, 16, ...]up ton. - Initialize
dp = [inf] * (n + 1),dp[0] = 0. - For each
ifrom 1 to n:- For each
sqin squares wheresq <= i:dp[i] = min(dp[i], dp[i - sq] + 1).
- For each
- Return
dp[n].
Solution
import math
def numSquares(n):
squares = [i * i for i in range(1, int(math.isqrt(n)) + 1)]
dp = [float('inf')] * (n + 1)
dp[0] = 0
for i in range(1, n + 1):
for sq in squares:
if sq > i:
break
dp[i] = min(dp[i], dp[i - sq] + 1)
return dp[n]
print(numSquares(12)) # 3 (4+4+4)
print(numSquares(13)) # 2 (4+9)
print(numSquares(1)) # 1 (1)
print(numSquares(4)) # 1 (4)
print(numSquares(100)) # 1 (100 = 10^2)
print(numSquares(7)) # 4 (4+1+1+1)
Complexity
- Time:
O(n * sqrt(n))— n amounts × sqrt(n) squares per amount - Space:
O(n)— DP array
Common Pitfalls
Using 1 as the only starting point. It’s tempting to just repeatedly subtract 1 (greedy), but that gives the wrong answer for many inputs. n=12 would give 12 ones, not 3 fours.
Forgetting that 1 is always a perfect square. Every positive integer can be decomposed into 1s, so the answer always exists. There’s no need to return -1 unlike Coin Change with arbitrary coins.
Recomputing square roots inside the loop. Precompute the list of squares outside the DP loop to avoid repeated isqrt calls. This keeps the code clean and fast.
Integer Break
Difficulty: Medium Source: NeetCode
Problem
Given an integer
n, break it into the sum ofkpositive integers, wherek >= 2, and maximize the product of those integers. Return the maximum product you can get.Example 1: Input:
n = 2Output:1(2 = 1 + 1, product = 1)Example 2: Input:
n = 10Output:36(10 = 3 + 3 + 4, product = 36)Constraints:
2 <= n <= 58
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dynamic Programming — optimal substructure
- Math Intuition — understanding why breaking into 3s is optimal
1. Brute Force (Recursion with Memoization)
Intuition
At each step, try splitting off each possible first piece k (from 1 to n-1). The remaining n-k can either be left as-is (if we want n-k as one piece) or recursively split further. Take the maximum of k * (n-k) and k * dp(n-k).
Algorithm
dp(n)= max product from breakingninto at least 2 pieces.- For each split
kfrom 1 to n-1:product = k * max(n - k, dp(n - k)).
- Return max over all
k.
Solution
def integerBreak_memo(n):
memo = {}
def dp(num):
if num in memo:
return memo[num]
if num == 1:
return 1
best = 0
for k in range(1, num):
# k is one piece; (num - k) can be kept whole or broken further
product = k * max(num - k, dp(num - k))
best = max(best, product)
memo[num] = best
return best
return dp(n)
print(integerBreak_memo(2)) # 1
print(integerBreak_memo(3)) # 2
print(integerBreak_memo(4)) # 4
print(integerBreak_memo(10)) # 36
print(integerBreak_memo(58)) # 1549681956
Complexity
- Time:
O(n²)with memoization - Space:
O(n)— memo table
2. Dynamic Programming (Bottom-Up)
Intuition
Build dp[i] = maximum product from breaking i. For each number, try all first-piece sizes j and check both keeping (i-j) whole and splitting it: dp[i] = max(j * (i-j), j * dp[i-j]).
Math insight (worth knowing): The optimal strategy is to break everything into 3s. You never want a piece of size 1 (it doesn’t contribute to the product), and splitting any piece ≥ 5 into smaller pieces always gives a better product. Remainders: if remainder is 0 → all 3s; if 1 → replace one 3+1 with two 2s; if 2 → add one 2.
Algorithm
- Initialize
dp = [0] * (n + 1),dp[1] = 1. - For each
ifrom 2 to n:- For each
jfrom 1 to i-1:dp[i] = max(dp[i], j * (i - j), j * dp[i - j]).
- For each
- Return
dp[n].
Solution
def integerBreak(n):
dp = [0] * (n + 1)
dp[1] = 1 # not really used, but set for completeness
for i in range(2, n + 1):
for j in range(1, i):
# Either keep (i-j) as one piece, or break it further
dp[i] = max(dp[i], j * (i - j), j * dp[i - j])
return dp[n]
def integerBreak_math(n):
"""O(1) math solution using the 'break into 3s' insight."""
if n == 2:
return 1
if n == 3:
return 2
threes = n // 3
remainder = n % 3
if remainder == 0:
return 3 ** threes
elif remainder == 1:
# Replace one 3 with two 2s: 3*1 → 2*2 = 4 > 3
return 3 ** (threes - 1) * 4
else: # remainder == 2
return 3 ** threes * 2
print(integerBreak(2)) # 1
print(integerBreak(3)) # 2
print(integerBreak(4)) # 4
print(integerBreak(10)) # 36
print(integerBreak(58)) # 1549681956
print(integerBreak_math(2)) # 1
print(integerBreak_math(10)) # 36
print(integerBreak_math(58)) # 1549681956
Complexity
- Time:
O(n²)for DP;O(1)for math solution - Space:
O(n)for DP;O(1)for math
Common Pitfalls
Forgetting the constraint of at least 2 pieces. You must split n into at least 2 parts. So dp[2] = 1*1 = 1, not 2.
Not considering keeping (i-j) as a whole piece. When building dp[i], you should consider both j * (i-j) (two pieces: j and i-j) and j * dp[i-j] (j as one piece, then optimally split i-j). Missing the j * (i-j) option causes wrong answers for small n.
Using the math shortcut without verifying n=2 and n=3. For n=2: only split is 1+1, product=1. For n=3: splits are 1+2 or 1+1+1, best is 1*2=2. These are special cases that the formula handles by the remainder logic.
Stone Game III
Difficulty: Hard Source: NeetCode
Problem
Alice and Bob continue their games with piles of stones. There are several stones arranged in a row, and each stone has an associated value which is an integer given in the array
stoneValue.Alice and Bob take turns, with Alice starting first. On each player’s turn, that player can take 1, 2, or 3 stones from the first remaining stones in the row.
Both players play optimally. If Alice wins, return
"Alice". If Bob wins, return"Bob". If they tie, return"Tie".Example 1: Input:
stoneValue = [1,2,3,7]Output:"Bob"Example 2: Input:
stoneValue = [1,2,3,-9]Output:"Alice"Example 3: Input:
stoneValue = [1,2,3,6]Output:"Tie"Constraints:
1 <= stoneValue.length <= 5 * 10^4-1000 <= stoneValue[i] <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Game Theory / Minimax — optimal play for both players
- DP from the End — building solutions right-to-left
- Prefix Sums — quickly computing sums of subarrays
1. Brute Force (Recursion without Memoization)
Intuition
At each position, the current player picks 1, 2, or 3 stones. They want to maximize their own score minus the opponent’s score (from the remaining stones). dp(i) = best score difference (current player minus other player) starting from position i.
Without memoization this is O(3^n) but conceptually clean.
Algorithm
dp(i)= best score advantage the current player can achieve from indexito end.- For each choice
kin {1, 2, 3}: takesum(stoneValue[i:i+k])and subtractdp(i+k)(opponent’s best from that point). - Return max of all choices.
Solution
def stoneGameIII_brute(stoneValue):
n = len(stoneValue)
memo = {}
def dp(i):
if i >= n:
return 0
if i in memo:
return memo[i]
best = float('-inf')
total = 0
for k in range(1, 4):
if i + k - 1 < n:
total += stoneValue[i + k - 1]
# Current player gains `total`, opponent gets dp(i+k)
score = total - dp(i + k)
best = max(best, score)
memo[i] = best
return best
result = dp(0)
if result > 0:
return "Alice"
elif result < 0:
return "Bob"
return "Tie"
print(stoneGameIII_brute([1, 2, 3, 7])) # "Bob"
print(stoneGameIII_brute([1, 2, 3, -9])) # "Alice"
print(stoneGameIII_brute([1, 2, 3, 6])) # "Tie"
Complexity
- Time:
O(n)with memoization - Space:
O(n)— memo and recursion stack
2. Dynamic Programming (Bottom-Up)
Intuition
Build the DP array right-to-left. dp[i] = best score difference the current player can achieve starting from position i. The current player takes 1, 2, or 3 stones and gains their sum, while the opponent plays optimally from position i+k (giving dp[i+k]).
dp[i] = max(sum(stoneValue[i:i+k]) - dp[i+k] for k in 1,2,3)
At the end, dp[0] is Alice’s score advantage. If positive, Alice wins; negative, Bob wins; zero, tie.
Algorithm
- Initialize
dp = [0] * (n + 3)(padding with zeros for out-of-bounds). - Precompute prefix sums for fast range sums.
- Fill from right to left:
dp[i] = max(prefix[i+k] - prefix[i] - dp[i+k] for k in 1,2,3). - Return “Alice” if
dp[0] > 0, “Bob” ifdp[0] < 0, else “Tie”.
Solution
def stoneGameIII(stoneValue):
n = len(stoneValue)
# dp[i] = max score advantage (current player - other) from index i
# We need dp[n], dp[n+1], dp[n+2] to be 0 (base cases)
dp = [0] * (n + 3)
# Precompute prefix sums for O(1) range queries
prefix = [0] * (n + 1)
for i in range(n):
prefix[i + 1] = prefix[i] + stoneValue[i]
def range_sum(l, r):
"""Sum of stoneValue[l:r] (exclusive r)."""
return prefix[min(r, n)] - prefix[l]
# Fill right to left
for i in range(n - 1, -1, -1):
best = float('-inf')
for k in range(1, 4):
if i + k <= n:
taken = range_sum(i, i + k)
score = taken - dp[i + k]
best = max(best, score)
dp[i] = best
result = dp[0]
if result > 0:
return "Alice"
elif result < 0:
return "Bob"
return "Tie"
print(stoneGameIII([1, 2, 3, 7])) # "Bob"
print(stoneGameIII([1, 2, 3, -9])) # "Alice"
print(stoneGameIII([1, 2, 3, 6])) # "Tie"
print(stoneGameIII([-1, -2, -3])) # "Tie" (both forced to take; Alice takes first, total sum split)
print(stoneGameIII([7])) # "Alice"
Complexity
- Time:
O(n)— single pass right-to-left, constant work per position - Space:
O(n)— DP array and prefix sums
Common Pitfalls
Modeling dp[i] as the current player’s total score instead of the score difference. Using the score difference (current_player - opponent) elegantly handles the alternating nature of the game without needing to track whose turn it is.
Forgetting negative stone values. Unlike many stone game problems, values here can be negative. A player might be forced to take negative-value stones. The minimax dp handles this correctly since even a negative total - dp[i+k] might be the best available option.
Iterating left-to-right without correct dependencies. dp[i] depends on dp[i+1], dp[i+2], dp[i+3]. You must fill the array from right to left so those values are already computed.
2-D Dynamic Programming Problems
Practice problems with grid, string, and multi-parameter state transitions.
Unique Paths
Difficulty: Medium Source: NeetCode
Problem
A robot is located at the top-left corner of an
m x ngrid. The robot can only move either right or down at any point. The robot is trying to reach the bottom-right corner. How many possible unique paths are there?Example 1: Input:
m = 3, n = 7Output:28Example 2: Input:
m = 3, n = 2Output:3Constraints:
1 <= m, n <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion — understanding how to break a problem into sub-problems
- 2D Dynamic Programming — building a solution table from base cases
- Combinatorics (optional) — the math shortcut using combinations
1. Brute Force / Recursive
Intuition
From any cell, the robot can come from the cell above or the cell to the left. So the number of unique paths to (i, j) is the sum of paths to (i-1, j) and (i, j-1). The base case is the top row and left column — there’s exactly one way to reach any of those cells (go straight right or straight down). We recurse from the bottom-right corner back to the top-left.
Algorithm
- Define
dfs(i, j)returning number of paths to reach(i, j). - If
i == 0orj == 0, return1(base case: only one way along edge). - Otherwise return
dfs(i-1, j) + dfs(i, j-1). - Call
dfs(m-1, n-1).
Solution
def uniquePaths(m, n):
def dfs(i, j):
if i == 0 or j == 0:
return 1
return dfs(i - 1, j) + dfs(i, j - 1)
return dfs(m - 1, n - 1)
print(uniquePaths(3, 7)) # 28
print(uniquePaths(3, 2)) # 3
print(uniquePaths(1, 1)) # 1
Complexity
- Time:
O(2^(m+n))— exponential without memoization - Space:
O(m+n)— recursion stack depth
2. Top-Down DP (Memoization)
Intuition
The brute-force approach recomputes the same cells over and over. For instance dfs(2, 3) might be called dozens of times. We can cache the result of each (i, j) pair so it’s only computed once. This turns the exponential solution into a polynomial one.
Algorithm
- Create a memo dictionary.
- In
dfs(i, j), return1if at an edge, or look up the cache. - Otherwise compute
dfs(i-1, j) + dfs(i, j-1), store in cache, and return.
Solution
def uniquePaths(m, n):
memo = {}
def dfs(i, j):
if i == 0 or j == 0:
return 1
if (i, j) in memo:
return memo[(i, j)]
memo[(i, j)] = dfs(i - 1, j) + dfs(i, j - 1)
return memo[(i, j)]
return dfs(m - 1, n - 1)
print(uniquePaths(3, 7)) # 28
print(uniquePaths(3, 2)) # 3
print(uniquePaths(1, 1)) # 1
Complexity
- Time:
O(m * n) - Space:
O(m * n)— memo table plus stack
3. Bottom-Up DP (Tabulation)
Intuition
Instead of recursing from the end, build a table starting from the top-left. Every cell in the first row and first column is 1 (only one way to get there). For every other cell, dp[i][j] = dp[i-1][j] + dp[i][j-1]. Fill row by row and the answer lands at dp[m-1][n-1].
Algorithm
- Create a
m x ntable filled with1s. - For
ifrom1tom-1, andjfrom1ton-1:dp[i][j] = dp[i-1][j] + dp[i][j-1]
- Return
dp[m-1][n-1].
Solution
def uniquePaths(m, n):
dp = [[1] * n for _ in range(m)]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]
print(uniquePaths(3, 7)) # 28
print(uniquePaths(3, 2)) # 3
print(uniquePaths(1, 1)) # 1
Complexity
- Time:
O(m * n) - Space:
O(m * n)— can be optimised toO(n)using a single row
Common Pitfalls
Forgetting the base case. If you don’t seed the first row and column with 1s, cells adjacent to the edge compute 0 + 0 = 0 instead of the correct value.
Swapping m and n. The grid is m rows by n columns. Make sure your loop bounds match — range(m) for rows, range(n) for columns.
Off-by-one in the recursive call. We index the grid from (0,0) to (m-1, n-1), so the recursive entry point is dfs(m-1, n-1), not dfs(m, n).
Unique Paths II
Difficulty: Medium Source: NeetCode
Problem
A robot is at the top-left of an
m x ngrid. Some cells are obstacles (1), the rest are empty (0). The robot can only move right or down. Count how many unique paths reach the bottom-right corner. Return0if the start or end is blocked.Example 1: Input:
obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]Output:2Example 2: Input:
obstacleGrid = [[0,1],[0,0]]Output:1Constraints:
1 <= m, n <= 100obstacleGrid[i][j]is0or1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Unique Paths (62) — the base problem without obstacles
- 2D DP — filling a grid table based on neighbours
1. Brute Force / Recursive
Intuition
Same idea as Unique Paths, but we add one extra rule: if a cell contains an obstacle, it contributes 0 paths (we can’t pass through it). The robot still comes from the top or the left, and we recurse back from the destination.
Algorithm
- If
obstacleGrid[0][0] == 1orobstacleGrid[m-1][n-1] == 1, return0. - Define
dfs(i, j):- If out of bounds or
obstacleGrid[i][j] == 1, return0. - If
i == 0andj == 0, return1. - Return
dfs(i-1, j) + dfs(i, j-1).
- If out of bounds or
Solution
def uniquePathsWithObstacles(obstacleGrid):
m, n = len(obstacleGrid), len(obstacleGrid[0])
if obstacleGrid[0][0] == 1 or obstacleGrid[m - 1][n - 1] == 1:
return 0
def dfs(i, j):
if i < 0 or j < 0 or obstacleGrid[i][j] == 1:
return 0
if i == 0 and j == 0:
return 1
return dfs(i - 1, j) + dfs(i, j - 1)
return dfs(m - 1, n - 1)
print(uniquePathsWithObstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]])) # 2
print(uniquePathsWithObstacles([[0, 1], [0, 0]])) # 1
print(uniquePathsWithObstacles([[1, 0]])) # 0
Complexity
- Time:
O(2^(m+n))— exponential without memoization - Space:
O(m+n)— recursion stack
2. Bottom-Up DP (Tabulation)
Intuition
Build the table left-to-right, top-to-bottom. Obstacle cells get 0. The first row and column are trickier than before — once you hit an obstacle on an edge, every cell after it on that edge is also 0 (the robot can’t get there at all). For interior cells, dp[i][j] = dp[i-1][j] + dp[i][j-1] as before, but obstacles short-circuit to 0.
Algorithm
- Create a
m x ndp table of zeros. - Fill
dp[0][0] = 1if not an obstacle. - Fill first column:
dp[i][0] = dp[i-1][0]ifobstacleGrid[i][0] == 0, else0. - Fill first row:
dp[0][j] = dp[0][j-1]ifobstacleGrid[0][j] == 0, else0. - For interior cells: if obstacle set
0, elsedp[i-1][j] + dp[i][j-1]. - Return
dp[m-1][n-1].
Solution
def uniquePathsWithObstacles(obstacleGrid):
m, n = len(obstacleGrid), len(obstacleGrid[0])
if obstacleGrid[0][0] == 1 or obstacleGrid[m - 1][n - 1] == 1:
return 0
dp = [[0] * n for _ in range(m)]
dp[0][0] = 1
for i in range(1, m):
dp[i][0] = dp[i - 1][0] if obstacleGrid[i][0] == 0 else 0
for j in range(1, n):
dp[0][j] = dp[0][j - 1] if obstacleGrid[0][j] == 0 else 0
for i in range(1, m):
for j in range(1, n):
if obstacleGrid[i][j] == 1:
dp[i][j] = 0
else:
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]
print(uniquePathsWithObstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]])) # 2
print(uniquePathsWithObstacles([[0, 1], [0, 0]])) # 1
print(uniquePathsWithObstacles([[1, 0]])) # 0
Complexity
- Time:
O(m * n) - Space:
O(m * n)— reducible toO(n)with a rolling array
Common Pitfalls
Not checking the start/end for obstacles. If obstacleGrid[0][0] is blocked, the answer is immediately 0 — don’t let your code set dp[0][0] = 1 in that case.
Edge propagation along the border. Once an obstacle is hit on the first row or column, all subsequent cells in that row/column must be 0, not 1. A simple dp[i][0] = 1 loop will get this wrong.
Modifying the input grid. It’s tempting to write obstacleGrid[i][j] = paths directly, but this mutates the caller’s data. Use a separate dp table.
Minimum Path Sum
Difficulty: Medium Source: NeetCode
Problem
Given an
m x ngrid filled with non-negative numbers, find a path from the top-left to the bottom-right which minimizes the sum of all numbers along the path. You can only move right or down.Example 1: Input:
grid = [[1,3,1],[1,5,1],[4,2,1]]Output:7Explanation: The path1 → 3 → 1 → 1 → 1has sum 7.Example 2: Input:
grid = [[1,2,3],[4,5,6]]Output:12Constraints:
1 <= m, n <= 2000 <= grid[i][j] <= 200
Prerequisites
Before attempting this problem, you should be comfortable with:
- Unique Paths (62) — same grid traversal pattern
- 2D DP — understanding how cell values depend on neighbours
1. Brute Force / Recursive
Intuition
At every cell, choose the cheaper option — coming from above or from the left. Recurse from the destination back to the source. The base case is the top-left cell, which costs exactly grid[0][0]. Since all values are non-negative, we never need to explore all paths — we just take the minimum at each step, though without memoization we’ll redo the same subproblems repeatedly.
Algorithm
- Define
dfs(i, j)returning the minimum cost path to reach(i, j). - If
i == 0andj == 0, returngrid[0][0]. - If
i == 0, returngrid[0][j] + dfs(0, j-1)(can only come from left). - If
j == 0, returngrid[i][0] + dfs(i-1, 0)(can only come from above). - Otherwise return
grid[i][j] + min(dfs(i-1, j), dfs(i, j-1)).
Solution
def minPathSum(grid):
def dfs(i, j):
if i == 0 and j == 0:
return grid[0][0]
if i == 0:
return grid[0][j] + dfs(0, j - 1)
if j == 0:
return grid[i][0] + dfs(i - 1, 0)
return grid[i][j] + min(dfs(i - 1, j), dfs(i, j - 1))
m, n = len(grid), len(grid[0])
return dfs(m - 1, n - 1)
print(minPathSum([[1, 3, 1], [1, 5, 1], [4, 2, 1]])) # 7
print(minPathSum([[1, 2, 3], [4, 5, 6]])) # 12
Complexity
- Time:
O(2^(m+n))— exponential overlap without memoization - Space:
O(m+n)— recursion stack
2. Bottom-Up DP (Tabulation)
Intuition
Build a dp table where dp[i][j] stores the minimum cost to reach cell (i, j). The first row can only be reached by going right, and the first column can only be reached by going down, so those are straightforward prefix sums. Every other cell is grid[i][j] + min(dp[i-1][j], dp[i][j-1]). The answer is in the bottom-right corner.
Algorithm
- Create a
m x ndp table. dp[0][0] = grid[0][0].- Fill first row:
dp[0][j] = dp[0][j-1] + grid[0][j]. - Fill first column:
dp[i][0] = dp[i-1][0] + grid[i][0]. - For interior:
dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]). - Return
dp[m-1][n-1].
Solution
def minPathSum(grid):
m, n = len(grid), len(grid[0])
dp = [[0] * n for _ in range(m)]
dp[0][0] = grid[0][0]
for j in range(1, n):
dp[0][j] = dp[0][j - 1] + grid[0][j]
for i in range(1, m):
dp[i][0] = dp[i - 1][0] + grid[i][0]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1])
return dp[m - 1][n - 1]
print(minPathSum([[1, 3, 1], [1, 5, 1], [4, 2, 1]])) # 7
print(minPathSum([[1, 2, 3], [4, 5, 6]])) # 12
print(minPathSum([[1]])) # 1
Complexity
- Time:
O(m * n) - Space:
O(m * n)— can be optimised toO(n)by reusing a single row
Common Pitfalls
Using min on the entire row/column. The minimum path isn’t the cell with the smallest individual value — you need the minimum cumulative cost. Always add grid[i][j] to dp[i][j], not just carry the smaller neighbour.
Forgetting to seed the edges. If you initialise everything to 0 and only fill interior cells, the edge cells will have wrong values because they can only be reached from one direction.
In-place modification confusion. You can modify grid in place (skipping the dp array) — just make sure you understand what grid[i][j] means after you’ve overwritten it.
Longest Common Subsequence
Difficulty: Medium Source: NeetCode
Problem
Given two strings
text1andtext2, return the length of their longest common subsequence. A subsequence is a sequence that can be derived from the original string by deleting some (or no) characters without changing the relative order of the remaining characters. If there is no common subsequence, return0.Example 1: Input:
text1 = "abcde",text2 = "ace"Output:3Explanation: The LCS is"ace".Example 2: Input:
text1 = "abc",text2 = "abc"Output:3Example 3: Input:
text1 = "abc",text2 = "def"Output:0Constraints:
1 <= text1.length, text2.length <= 1000- Both strings consist of only lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- 2D DP — filling a table based on two sequences
- Subsequences vs Substrings — a subsequence doesn’t need to be contiguous
1. Brute Force / Recursive
Intuition
Compare characters from the end of both strings. If they match, this character is part of the LCS — take it and move both pointers inward. If they don’t match, try skipping one character from either string and take the better result. This naturally explores all possible pairings, but overlapping subproblems make it exponential.
Algorithm
- Define
dfs(i, j)= LCS length fortext1[:i]andtext2[:j]. - Base case: if
i == 0orj == 0, return0. - If
text1[i-1] == text2[j-1], return1 + dfs(i-1, j-1). - Else return
max(dfs(i-1, j), dfs(i, j-1)).
Solution
def longestCommonSubsequence(text1, text2):
def dfs(i, j):
if i == 0 or j == 0:
return 0
if text1[i - 1] == text2[j - 1]:
return 1 + dfs(i - 1, j - 1)
return max(dfs(i - 1, j), dfs(i, j - 1))
return dfs(len(text1), len(text2))
print(longestCommonSubsequence("abcde", "ace")) # 3
print(longestCommonSubsequence("abc", "abc")) # 3
print(longestCommonSubsequence("abc", "def")) # 0
Complexity
- Time:
O(2^(m+n))— exponential without memoization - Space:
O(m+n)— recursion stack
2. Bottom-Up DP (Tabulation)
Intuition
Create a (m+1) x (n+1) table where dp[i][j] is the LCS length of text1[:i] and text2[:j]. The extra row and column of zeros are our base cases (empty string LCS is 0). Fill the table row by row using the recurrence: if characters match, take the diagonal + 1; if not, take the max of the cell above or to the left. The answer is in dp[m][n].
Algorithm
- Create
dpof size(m+1) x (n+1)filled with0. - For
iin1..m, forjin1..n:- If
text1[i-1] == text2[j-1]:dp[i][j] = 1 + dp[i-1][j-1] - Else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
- If
- Return
dp[m][n].
Solution
def longestCommonSubsequence(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = 1 + dp[i - 1][j - 1]
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]
print(longestCommonSubsequence("abcde", "ace")) # 3
print(longestCommonSubsequence("abc", "abc")) # 3
print(longestCommonSubsequence("abc", "def")) # 0
print(longestCommonSubsequence("", "abc")) # 0
Complexity
- Time:
O(m * n) - Space:
O(m * n)— reducible toO(n)with two alternating rows
Common Pitfalls
Confusing indices. text1[i-1] corresponds to row i in the (m+1) x (n+1) table. The +1 offset is there so row 0 and column 0 act as the empty-string base case — don’t mix up i and i-1.
Using max(above, left, diagonal). The diagonal is only valid when characters match. When they don’t, you choose between above and left — the diagonal is not a candidate.
Thinking LCS must be contiguous. The LCS is a subsequence, meaning characters can be spread across the string. It’s not a substring. “abcde” and “ace” share LCS “ace” even though there are gaps.
Last Stone Weight II
Difficulty: Medium Source: NeetCode
Problem
You have a collection of stones with positive integer weights. Each turn, smash the two heaviest stones together. If they have equal weight, both are destroyed. If not, the smaller one is destroyed and the larger one’s weight is reduced by the smaller’s weight. Return the smallest possible weight of the remaining stone (or
0if none remain).Example 1: Input:
stones = [2, 7, 4, 1, 8, 1]Output:1Example 2: Input:
stones = [31, 26, 33, 21, 40]Output:5Constraints:
1 <= stones.length <= 301 <= stones[i] <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- 0/1 Knapsack — deciding which items to include up to a weight limit
- Subset Sum — can we partition values to hit a target sum?
1. Brute Force / Recursive
Intuition
Every stone ends up with either a + or - sign applied to it. The final result is the absolute value of sum(+group) - sum(-group). We want to minimise this difference. Try assigning each stone to either the positive or negative group recursively, then track the minimum absolute difference.
Algorithm
- Define
dfs(i, total)wheretotalis the running signed sum so far. - Base case: when
i == len(stones), returnabs(total). - Try assigning
+stones[i]and-stones[i], return the minimum result.
Solution
def lastStoneWeightII(stones):
def dfs(i, total):
if i == len(stones):
return abs(total)
return min(dfs(i + 1, total + stones[i]),
dfs(i + 1, total - stones[i]))
return dfs(0, 0)
print(lastStoneWeightII([2, 7, 4, 1, 8, 1])) # 1
print(lastStoneWeightII([31, 26, 33, 21, 40])) # 5
print(lastStoneWeightII([1])) # 1
Complexity
- Time:
O(2^n)— try both choices for each stone - Space:
O(n)— recursion depth
2. DP — Subset Sum / Knapsack
Intuition
The key insight: split stones into two groups A and B. The remaining weight is |sum(A) - sum(B)|. Since sum(A) + sum(B) = S (total), minimising |sum(A) - sum(B)| is equivalent to finding a subset with sum as close to S // 2 as possible. This reduces to a 0/1 knapsack: can we build a subset summing to each value from 0 to S // 2?
Build a boolean set dp of reachable sums. Start with {0}. For each stone, expand the set by adding stone to every existing reachable sum. After processing all stones, the answer is S - 2 * max(s for s in dp if s <= S // 2).
Algorithm
- Compute
S = sum(stones). Target =S // 2. - Initialise
dp = {0}(set of reachable sums). - For each stone:
dp = {s + stone for s in dp} | dp(but cap at target). - Find
best = max(s for s in dp if s <= target). - Return
S - 2 * best.
Solution
def lastStoneWeightII(stones):
S = sum(stones)
target = S // 2
dp = {0}
for stone in stones:
new_dp = set()
for s in dp:
new_dp.add(s)
if s + stone <= target:
new_dp.add(s + stone)
dp = new_dp
best = max(dp)
return S - 2 * best
print(lastStoneWeightII([2, 7, 4, 1, 8, 1])) # 1
print(lastStoneWeightII([31, 26, 33, 21, 40])) # 5
print(lastStoneWeightII([1])) # 1
# Alternative: boolean array version (classic knapsack style)
def lastStoneWeightII_v2(stones):
S = sum(stones)
target = S // 2
dp = [False] * (target + 1)
dp[0] = True
for stone in stones:
# iterate backwards to avoid using same stone twice
for j in range(target, stone - 1, -1):
dp[j] = dp[j] or dp[j - stone]
for j in range(target, -1, -1):
if dp[j]:
return S - 2 * j
return S
print(lastStoneWeightII_v2([2, 7, 4, 1, 8, 1])) # 1
print(lastStoneWeightII_v2([31, 26, 33, 21, 40])) # 5
Complexity
- Time:
O(n * S)whereS = sum(stones) - Space:
O(S)
Common Pitfalls
Iterating the knapsack dp forwards. When doing the 1D array approach, you must iterate j backwards. If you go forwards, a stone can be used multiple times (unbounded knapsack), which is wrong here.
Returning S // 2 - best. The answer is S - 2 * best, not S // 2 - best. We want the full difference between the two groups.
Assuming the answer is always 0. You can only make the difference zero if the stones are perfectly partitionable. In general, the best you can do is S mod 2 or larger.
Best Time to Buy and Sell Stock with Cooldown
Difficulty: Medium Source: NeetCode
Problem
You are given an array
priceswhereprices[i]is the price of a stock on dayi. You can buy and sell on multiple days, but you cannot buy on the next day after selling (cooldown of 1 day). You may not hold more than one stock at a time. Find the maximum profit.Example 1: Input:
prices = [1, 2, 3, 0, 2]Output:3Explanation: Buy on day 0, sell on day 1, cooldown on day 2, buy on day 3, sell on day 4.Example 2: Input:
prices = [1]Output:0Constraints:
1 <= prices.length <= 50000 <= prices[i] <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- State Machine DP — tracking multiple mutually exclusive states
- Buy and Sell Stock I/II — simpler versions without cooldown
1. Brute Force / Recursive
Intuition
At each day we are in one of three states: holding a stock, in cooldown (just sold), or idle (no stock, not cooling down). We recurse through each day, trying every valid transition from the current state. This explores all possible buy/sell schedules but is exponential without memoization.
Algorithm
- Define
dfs(i, holding)= max profit from dayionwards, given whether we currently hold a stock. - Base case:
i >= len(prices)→ return0. - If
holding:- Sell today: profit =
prices[i] + dfs(i+2, False)(skip dayi+1for cooldown) - Hold:
dfs(i+1, True) - Return max of these.
- Sell today: profit =
- If not holding:
- Buy today:
-prices[i] + dfs(i+1, True) - Skip:
dfs(i+1, False) - Return max of these.
- Buy today:
Solution
def maxProfit(prices):
def dfs(i, holding):
if i >= len(prices):
return 0
if holding:
sell = prices[i] + dfs(i + 2, False)
hold = dfs(i + 1, True)
return max(sell, hold)
else:
buy = -prices[i] + dfs(i + 1, True)
skip = dfs(i + 1, False)
return max(buy, skip)
return dfs(0, False)
print(maxProfit([1, 2, 3, 0, 2])) # 3
print(maxProfit([1])) # 0
print(maxProfit([2, 1])) # 0
Complexity
- Time:
O(2^n)— exponential without memoization - Space:
O(n)— recursion depth
2. DP with State Machine
Intuition
Model the problem as a state machine with three states at each day:
- holding: currently holding a stock
- sold (cooldown): just sold, must wait one day
- idle: free to buy
Transitions:
holding[i] = max(holding[i-1], idle[i-1] - prices[i])— keep holding, or buy from idlesold[i] = holding[i-1] + prices[i]— sell what we holdidle[i] = max(idle[i-1], sold[i-1])— stay idle, or come out of cooldown
The answer is max(sold[-1], idle[-1]) — we can’t end holding.
Algorithm
- Initialise
holding = -prices[0],sold = 0,idle = 0. - For each day from index
1:new_holding = max(holding, idle - prices[i])new_sold = holding + prices[i]new_idle = max(idle, sold)- Update all three states simultaneously.
- Return
max(sold, idle).
Solution
def maxProfit(prices):
if not prices:
return 0
holding = -prices[0]
sold = 0
idle = 0
for i in range(1, len(prices)):
new_holding = max(holding, idle - prices[i])
new_sold = holding + prices[i]
new_idle = max(idle, sold)
holding, sold, idle = new_holding, new_sold, new_idle
return max(sold, idle)
print(maxProfit([1, 2, 3, 0, 2])) # 3
print(maxProfit([1])) # 0
print(maxProfit([2, 1])) # 0
print(maxProfit([6, 1, 3, 2, 4, 7])) # 6
Complexity
- Time:
O(n) - Space:
O(1)— only three variables needed
Common Pitfalls
Updating states in place without temporaries. If you do holding = max(holding, idle - prices[i]) then immediately use the new holding to compute sold, you’ll get wrong results. Always compute all three new values before assigning.
Skipping two days instead of one. The cooldown is exactly 1 day — you sell on day i and can next buy on day i+2. In the recursive approach, jump to i+2 when selling, not i+3.
Including the holding state in the final answer. You can’t end the simulation while still holding a stock (you’d be sitting on unrealised profit). Only max(sold, idle) makes sense at the end.
Coin Change II
Difficulty: Medium Source: NeetCode
Problem
You are given an integer
amountand an array of integerscoinsrepresenting coin denominations. Return the number of combinations (order doesn’t matter) that make up theamount. If no combination is possible, return0.Example 1: Input:
amount = 5,coins = [1, 2, 5]Output:4Explanation:5=5,5=2+2+1,5=2+1+1+1,5=1+1+1+1+1Example 2: Input:
amount = 3,coins = [2]Output:0Example 3: Input:
amount = 10,coins = [10]Output:1Constraints:
1 <= coins.length <= 3001 <= coins[i] <= 50000 <= amount <= 5000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Coin Change I (322) — minimum coins version
- Unbounded Knapsack — each item can be used unlimited times
- Combinations vs Permutations — order doesn’t matter here
1. Brute Force / Recursive
Intuition
For each coin, decide how many times to use it (0, 1, 2, …). This naturally avoids counting permutations because we process coins one at a time — once we move past a coin, we never revisit it. Recurse through each coin with a remaining amount.
Algorithm
- Define
dfs(i, remaining)= number of ways to makeremainingusingcoins[i:]. - Base case:
remaining == 0→ return1(found a valid combination). - Base case:
remaining < 0ori == len(coins)→ return0. - Return
dfs(i, remaining - coins[i]) + dfs(i + 1, remaining).- First term: use
coins[i]again (unbounded). - Second term: skip to next coin.
- First term: use
Solution
def change(amount, coins):
def dfs(i, remaining):
if remaining == 0:
return 1
if remaining < 0 or i == len(coins):
return 0
return dfs(i, remaining - coins[i]) + dfs(i + 1, remaining)
return dfs(0, amount)
print(change(5, [1, 2, 5])) # 4
print(change(3, [2])) # 0
print(change(10, [10])) # 1
Complexity
- Time:
O(amount^len(coins))— very slow without memoization - Space:
O(amount)— recursion depth
2. Bottom-Up DP (Tabulation)
Intuition
Build a 1D dp array where dp[j] = number of ways to make amount j. Initialise dp[0] = 1 (one way to make 0: use no coins). For each coin, iterate amounts from coin to amount and add dp[j - coin] to dp[j]. The outer loop is over coins (not amounts) — this is critical! It ensures we count combinations, not permutations. If we put amounts on the outside, we’d count [1,2] and [2,1] as separate.
Algorithm
dp = [0] * (amount + 1), setdp[0] = 1.- For each
coinincoins:- For
jfromcointoamount:dp[j] += dp[j - coin]
- For
- Return
dp[amount].
Solution
def change(amount, coins):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for j in range(coin, amount + 1):
dp[j] += dp[j - coin]
return dp[amount]
print(change(5, [1, 2, 5])) # 4
print(change(3, [2])) # 0
print(change(10, [10])) # 1
print(change(0, [1, 2, 5])) # 1 (one way to make 0)
# 2D DP version (easier to understand the transition)
def change_2d(amount, coins):
m = len(coins)
dp = [[0] * (amount + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = 1 # one way to make 0
for i in range(1, m + 1):
for j in range(amount + 1):
dp[i][j] = dp[i - 1][j] # don't use coins[i-1]
if j >= coins[i - 1]:
dp[i][j] += dp[i][j - coins[i - 1]] # use coins[i-1] again
return dp[m][amount]
print(change_2d(5, [1, 2, 5])) # 4
Complexity
- Time:
O(len(coins) * amount) - Space:
O(amount)for 1D;O(len(coins) * amount)for 2D
Common Pitfalls
Swapping the loop order. If you loop over amounts on the outside and coins on the inside, you count permutations (ordered sequences). Coin Change II wants combinations (unordered). Always loop coins first, amounts second.
Not initialising dp[0] = 1. The base case “one way to make 0” is what seeds all the other values. If it’s 0, nothing propagates and you’ll always get 0.
Confusing this with Coin Change I. In Coin Change I you track the minimum number of coins; here you track the count of combinations. The transition is addition (+=) not min.
Target Sum
Difficulty: Medium Source: NeetCode
Problem
You are given an integer array
numsand an integertarget. Assign a+or-to each element ofnums. Return the number of different ways you can assign symbols to make the sum ofnumsequal totarget.Example 1: Input:
nums = [1,1,1,1,1],target = 3Output:5Example 2: Input:
nums = [1],target = 1Output:1Constraints:
1 <= nums.length <= 200 <= nums[i] <= 1000-1000 <= target <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion / Backtracking — exploring all sign assignments
- Subset Sum — 0/1 knapsack variant
- Math reduction — converting the problem into a cleaner form
1. Brute Force / Recursive
Intuition
At each index, we can assign + or - to the current number. We recurse through all indices, accumulating the running total. When we reach the end, check if the total equals the target. This is O(2^n) since each element has two choices, but it’s simple and correct.
Algorithm
- Define
dfs(i, total)= number of ways to reachtargetusingnums[i:]with currenttotal. - Base:
i == len(nums)→ return1iftotal == target, else0. - Return
dfs(i+1, total + nums[i]) + dfs(i+1, total - nums[i]).
Solution
def findTargetSumWays(nums, target):
def dfs(i, total):
if i == len(nums):
return 1 if total == target else 0
return dfs(i + 1, total + nums[i]) + dfs(i + 1, total - nums[i])
return dfs(0, 0)
print(findTargetSumWays([1, 1, 1, 1, 1], 3)) # 5
print(findTargetSumWays([1], 1)) # 1
print(findTargetSumWays([1], -1)) # 1
print(findTargetSumWays([0, 0, 0], 0)) # 8
Complexity
- Time:
O(2^n) - Space:
O(n)— recursion depth
2. DP with Memoization (Top-Down)
Intuition
The brute force recomputes the same (i, total) pairs many times. Cache them. The total ranges from -sum(nums) to +sum(nums), so the state space is O(n * sum). Using a dictionary as a memo makes this clean.
Algorithm
- Define
dfs(i, total)with a memo dictionary. - If
(i, total)is in memo, return it. - Otherwise compute recursively and cache.
Solution
def findTargetSumWays(nums, target):
memo = {}
def dfs(i, total):
if i == len(nums):
return 1 if total == target else 0
if (i, total) in memo:
return memo[(i, total)]
result = dfs(i + 1, total + nums[i]) + dfs(i + 1, total - nums[i])
memo[(i, total)] = result
return result
return dfs(0, 0)
print(findTargetSumWays([1, 1, 1, 1, 1], 3)) # 5
print(findTargetSumWays([1], 1)) # 1
# --- Math reduction to subset sum ---
# Let P = sum of positive subset, N = sum of negative subset.
# P + N = S (total sum)
# P - N = target
# => 2P = S + target => P = (S + target) / 2
# Count subsets of nums summing to P.
def findTargetSumWays_dp(nums, target):
S = sum(nums)
# If (S + target) is odd or out of range, no solution
if (S + target) % 2 != 0 or abs(target) > S:
return 0
P = (S + target) // 2
dp = [0] * (P + 1)
dp[0] = 1
for num in nums:
for j in range(P, num - 1, -1):
dp[j] += dp[j - num]
return dp[P]
print(findTargetSumWays_dp([1, 1, 1, 1, 1], 3)) # 5
print(findTargetSumWays_dp([1], 1)) # 1
print(findTargetSumWays_dp([0, 0, 0], 0)) # 8
Complexity
- Time:
O(n * sum(nums)) - Space:
O(sum(nums))
Common Pitfalls
Forgetting the parity check. (S + target) must be even for a valid partition to exist. If it’s odd, the answer is immediately 0.
Not handling zeros. A zero can be assigned +0 or -0, both giving the same sum — they count as two different assignments. The subset-sum approach handles this naturally since dp[j] += dp[j - 0] adds dp[j] to itself.
Iterating the knapsack forward. This is a 0/1 knapsack (each number used once). Iterate j from P down to num, not forward — otherwise you reuse the same number multiple times.
Interleaving String
Difficulty: Medium Source: NeetCode
Problem
Given strings
s1,s2, ands3, returntrueifs3is formed by an interleaving ofs1ands2. An interleaving of two strings keeps the relative order of characters from each string but merges them together.Example 1: Input:
s1 = "aabcc",s2 = "dbbca",s3 = "aadbbcbcac"Output:trueExample 2: Input:
s1 = "aabcc",s2 = "dbbca",s3 = "aadbbbaccc"Output:falseExample 3: Input:
s1 = "",s2 = "",s3 = ""Output:trueConstraints:
0 <= s1.length, s2.length <= 100s3.length == s1.length + s2.length
Prerequisites
Before attempting this problem, you should be comfortable with:
- 2D DP on two strings — the same table structure as LCS
- Recursion with two pointers — advancing through two source strings simultaneously
1. Brute Force / Recursive
Intuition
Use two pointers i and j tracking how far we’ve consumed s1 and s2. The current character of s3 is s3[i+j]. At each step, we try matching it with s1[i] (advance i) or s2[j] (advance j). We succeed if we consume all of s1 and s2 simultaneously.
Algorithm
- Define
dfs(i, j)= cans1[:i]ands2[:j]interleave to forms3[:i+j]? - Base case:
i == len(s1)andj == len(s2)→ returnTrue. - If
i < len(s1)ands1[i] == s3[i+j], trydfs(i+1, j). - If
j < len(s2)ands2[j] == s3[i+j], trydfs(i, j+1). - Return
Trueif any path succeeds.
Solution
def isInterleave(s1, s2, s3):
if len(s1) + len(s2) != len(s3):
return False
def dfs(i, j):
if i == len(s1) and j == len(s2):
return True
if i < len(s1) and s1[i] == s3[i + j]:
if dfs(i + 1, j):
return True
if j < len(s2) and s2[j] == s3[i + j]:
if dfs(i, j + 1):
return True
return False
return dfs(0, 0)
print(isInterleave("aabcc", "dbbca", "aadbbcbcac")) # True
print(isInterleave("aabcc", "dbbca", "aadbbbaccc")) # False
print(isInterleave("", "", "")) # True
Complexity
- Time:
O(2^(m+n))— exponential without memoization - Space:
O(m+n)— recursion depth
2. Bottom-Up DP (Tabulation)
Intuition
Build a (m+1) x (n+1) boolean table where dp[i][j] is True if s1[:i] and s2[:j] can interleave to form s3[:i+j]. Start with dp[0][0] = True. Fill the first row using only s2 characters, and the first column using only s1 characters. For interior cells, it’s true if we can come from the left (match s2[j-1]) or from above (match s1[i-1]).
Algorithm
- If
len(s1) + len(s2) != len(s3), returnFalse. - Create
(m+1) x (n+1)dp table,dp[0][0] = True. - Fill first column:
dp[i][0] = dp[i-1][0] and s1[i-1] == s3[i-1]. - Fill first row:
dp[0][j] = dp[0][j-1] and s2[j-1] == s3[j-1]. - Interior:
dp[i][j] = (dp[i-1][j] and s1[i-1] == s3[i+j-1]) or (dp[i][j-1] and s2[j-1] == s3[i+j-1]). - Return
dp[m][n].
Solution
def isInterleave(s1, s2, s3):
m, n = len(s1), len(s2)
if m + n != len(s3):
return False
dp = [[False] * (n + 1) for _ in range(m + 1)]
dp[0][0] = True
for i in range(1, m + 1):
dp[i][0] = dp[i - 1][0] and s1[i - 1] == s3[i - 1]
for j in range(1, n + 1):
dp[0][j] = dp[0][j - 1] and s2[j - 1] == s3[j - 1]
for i in range(1, m + 1):
for j in range(1, n + 1):
from_above = dp[i - 1][j] and s1[i - 1] == s3[i + j - 1]
from_left = dp[i][j - 1] and s2[j - 1] == s3[i + j - 1]
dp[i][j] = from_above or from_left
return dp[m][n]
print(isInterleave("aabcc", "dbbca", "aadbbcbcac")) # True
print(isInterleave("aabcc", "dbbca", "aadbbbaccc")) # False
print(isInterleave("", "", "")) # True
print(isInterleave("a", "b", "ab")) # True
print(isInterleave("a", "b", "ba")) # True
Complexity
- Time:
O(m * n) - Space:
O(m * n)— reducible toO(n)with a rolling row
Common Pitfalls
Forgetting the length check. If len(s1) + len(s2) != len(s3), it’s immediately impossible. Don’t skip this — it prevents index-out-of-bounds errors too.
Wrong index into s3. The character we’re matching in s3 at position (i, j) is s3[i+j-1] (1-indexed i, j). Getting this formula wrong is the most common bug in this problem.
Greedy won’t work. You might think “just scan left to right and match whichever string fits” — but that fails on ambiguous characters. For example if s1 = "aa" and s2 = "aa", a greedy approach can’t know which string to draw from. DP is necessary.
Stone Game
Difficulty: Medium Source: NeetCode
Problem
Alice and Bob play a game with
pilesof stones arranged in a row. They alternate turns, with Alice going first. On each turn, a player takes the entire pile from either end of the row. The player with the most stones at the end wins. Both play optimally. Returntrueif Alice wins.Example 1: Input:
piles = [5, 3, 4, 5]Output:trueExample 2: Input:
piles = [3, 7, 2, 3]Output:trueConstraints:
2 <= piles.length <= 500piles.lengthis even1 <= piles[i] <= 500- All elements are distinct
Prerequisites
Before attempting this problem, you should be comfortable with:
- Game theory / minimax — reasoning about optimal play for two players
- Interval DP — subproblems defined over subarrays
piles[i..j]
1. Observation (Mathematical Proof)
Intuition
Alice always wins when n is even and all values are distinct. Here’s why: colour the piles alternately “even-indexed” and “odd-indexed”. Alice, going first, can always choose to take either all even-indexed piles or all odd-indexed piles. Since the piles have distinct values, one of these two groups has a strictly higher total. Alice picks the better group and forces Bob to take the worse one. So the answer is always True.
Solution
def stoneGame(piles):
# Alice always wins when n is even and all values distinct
return True
print(stoneGame([5, 3, 4, 5])) # True
print(stoneGame([3, 7, 2, 3])) # True
Complexity
- Time:
O(1) - Space:
O(1)
2. DP — Interval DP (General Game Approach)
Intuition
For a general solution (useful for Stone Game variants), use interval DP. Define dp[i][j] as the maximum score difference (current player’s score minus opponent’s score) that the current player can achieve over the subarray piles[i..j]. The current player picks either piles[i] or piles[j], and then the opponent plays optimally on the remaining subarray.
- Take left:
piles[i] - dp[i+1][j](we gainpiles[i], then opponent gainsdp[i+1][j]over us) - Take right:
piles[j] - dp[i][j-1]
Alice wins if dp[0][n-1] > 0.
Algorithm
- Create
n x ndp table. - Base case:
dp[i][i] = piles[i](only one pile, take it). - Fill by increasing interval length (length 2, 3, …, n).
dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1]).- Return
dp[0][n-1] > 0.
Solution
def stoneGame(piles):
n = len(piles)
dp = [[0] * n for _ in range(n)]
# Base case: single pile
for i in range(n):
dp[i][i] = piles[i]
# Fill for increasing lengths
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
take_left = piles[i] - dp[i + 1][j]
take_right = piles[j] - dp[i][j - 1]
dp[i][j] = max(take_left, take_right)
return dp[0][n - 1] > 0
print(stoneGame([5, 3, 4, 5])) # True
print(stoneGame([3, 7, 2, 3])) # True
print(stoneGame([3, 2])) # True (Alice picks 3)
# Recursive with memoization version
def stoneGame_memo(piles):
from functools import lru_cache
@lru_cache(maxsize=None)
def dfs(i, j):
if i == j:
return piles[i]
return max(piles[i] - dfs(i + 1, j),
piles[j] - dfs(i, j - 1))
return dfs(0, len(piles) - 1) > 0
print(stoneGame_memo([5, 3, 4, 5])) # True
Complexity
- Time:
O(n²) - Space:
O(n²)
Common Pitfalls
Not understanding the score difference formulation. dp[i][j] is the difference (my score minus opponent’s score), not just my score. So when the current player takes piles[i], the opponent then achieves dp[i+1][j] over the current player — hence piles[i] - dp[i+1][j].
Filling the table in wrong order. You must fill by increasing interval length, not row by row. dp[i][j] depends on dp[i+1][j] and dp[i][j-1], so those must be computed first.
Confusing this with Stone Game II. This problem has a fixed take-one-from-either-end rule. Stone Game II has a more complex “take up to 2M piles” rule — don’t mix up their DP formulations.
Stone Game II
Difficulty: Medium Source: NeetCode
Problem
Alice and Bob continue the stone game. The piles are in a row and players alternate turns. On each turn, the current player can take the first
Xpiles where1 <= X <= 2 * M. After taking,M = max(M, X)for the next turn. Alice goes first withM = 1. Return the maximum number of stones Alice can get.Example 1: Input:
piles = [2, 7, 9, 4, 4]Output:10Example 2: Input:
piles = [1, 2, 3, 4, 5]Output:9Constraints:
1 <= piles.length <= 1001 <= piles[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stone Game (877) — simpler version of the same game
- Suffix sums — quickly computing the sum of remaining piles
- Interval/parametric DP — state includes both index and parameter
M
1. Brute Force / Recursive
Intuition
At each turn, the current player can take X piles (from 1 to 2M) from the front. After taking, M updates. We track position i and current M. The current player maximises their take; the opponent will do the same. Since both play optimally, the current player gets suffix_sum[i] - opponent_best, where opponent_best is what the opponent collects playing optimally on the rest.
Algorithm
- Precompute suffix sums.
- Define
dfs(i, M)= max stones the current player can collect starting at indexiwith parameterM. - If
i + 2*M >= n, current player takes all remaining piles. - Otherwise, try each
Xfrom1to2*M; newM' = max(M, X). Opponent playsdfs(i+X, M'). Current player getssuffix[i] - dfs(i+X, M'). - Return the maximum.
Solution
def stoneGameII(piles):
n = len(piles)
suffix = [0] * (n + 1)
for i in range(n - 1, -1, -1):
suffix[i] = suffix[i + 1] + piles[i]
def dfs(i, M):
if i >= n:
return 0
if i + 2 * M >= n:
return suffix[i] # take all remaining
best = 0
for X in range(1, 2 * M + 1):
new_M = max(M, X)
opponent = dfs(i + X, new_M)
best = max(best, suffix[i] - opponent)
return best
return dfs(0, 1)
print(stoneGameII([2, 7, 9, 4, 4])) # 10
print(stoneGameII([1, 2, 3, 4, 5])) # 9
Complexity
- Time:
O(n³)— exponential states without memoization - Space:
O(n)— recursion depth
2. Top-Down DP (Memoization)
Intuition
The same recursive idea, but we cache (i, M) pairs. Since i ranges from 0 to n and M ranges from 1 to n, the table is at most n² states. Each state does O(n) work in the worst case, giving O(n³) total.
Algorithm
- Same as brute force but wrap in
@lru_cache.
Solution
from functools import lru_cache
def stoneGameII(piles):
n = len(piles)
suffix = [0] * (n + 1)
for i in range(n - 1, -1, -1):
suffix[i] = suffix[i + 1] + piles[i]
@lru_cache(maxsize=None)
def dfs(i, M):
if i >= n:
return 0
if i + 2 * M >= n:
return suffix[i]
best = 0
for X in range(1, 2 * M + 1):
new_M = max(M, X)
opponent = dfs(i + X, new_M)
best = max(best, suffix[i] - opponent)
return best
return dfs(0, 1)
print(stoneGameII([2, 7, 9, 4, 4])) # 10
print(stoneGameII([1, 2, 3, 4, 5])) # 9
print(stoneGameII([1])) # 1
# Bottom-up version
def stoneGameII_bu(piles):
n = len(piles)
suffix = [0] * (n + 1)
for i in range(n - 1, -1, -1):
suffix[i] = suffix[i + 1] + piles[i]
dp = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(n - 1, -1, -1):
for M in range(1, n + 1):
if i + 2 * M >= n:
dp[i][M] = suffix[i]
else:
for X in range(1, 2 * M + 1):
new_M = max(M, X)
dp[i][M] = max(dp[i][M], suffix[i] - dp[i + X][new_M])
return dp[0][1]
print(stoneGameII_bu([2, 7, 9, 4, 4])) # 10
print(stoneGameII_bu([1, 2, 3, 4, 5])) # 9
Complexity
- Time:
O(n³) - Space:
O(n²)
Common Pitfalls
Forgetting the “take all remaining” base case. When i + 2*M >= n, the current player can take everything left. Don’t let your recursion fall through to dfs(i+X, ...) for values of X that overshoot the array.
Not using suffix sums. Computing sum(piles[i:]) inside the recursion makes it O(n⁴). Pre-compute suffix sums to keep it O(n³).
Updating M incorrectly. M becomes max(M, X) after the take, not just X. If you always set M = X, you may undercount the options available to the next player.
Longest Increasing Path in a Matrix
Difficulty: Hard Source: NeetCode
Problem
Given an
m x ninteger matrix, return the length of the longest increasing path. From each cell, you can move in 4 directions (up, down, left, right). You cannot move diagonally or outside the boundary. The path must be strictly increasing.Example 1: Input:
matrix = [[9,9,4],[6,6,8],[2,1,1]]Output:4Explanation: The longest increasing path is[1, 2, 6, 9].Example 2: Input:
matrix = [[3,4,5],[3,2,6],[2,2,1]]Output:4Constraints:
1 <= m, n <= 2000 <= matrix[i][j] <= 2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- DFS on a grid — exploring neighbours with recursion
- Memoization — caching DFS results to avoid recomputation
- Topological sort (optional) — alternative BFS-based approach
1. Brute Force / DFS Without Memoization
Intuition
From each cell, do a DFS exploring all four directions, only moving to a strictly larger neighbour. Track the length of the longest path found from this cell. Call this for every cell and return the global maximum. Without caching, we recompute the same paths many times.
Algorithm
- For each cell
(i, j), calldfs(i, j, -1)(previous value starts at -1 so any cell is valid). - In DFS: for each of 4 directions, if the neighbour is in bounds and strictly larger, recurse.
- Return
1 + max(neighbour results).
Solution
def longestIncreasingPath(matrix):
if not matrix:
return 0
m, n = len(matrix), len(matrix[0])
dirs = [(0, 1), (0, -1), (1, 0), (-1, 0)]
def dfs(i, j, prev_val):
if i < 0 or i >= m or j < 0 or j >= n:
return 0
if matrix[i][j] <= prev_val:
return 0
best = 1
for di, dj in dirs:
result = dfs(i + di, j + dj, matrix[i][j])
best = max(best, 1 + result)
return best
ans = 0
for i in range(m):
for j in range(n):
ans = max(ans, dfs(i, j, -1))
return ans
print(longestIncreasingPath([[9, 9, 4], [6, 6, 8], [2, 1, 1]])) # 4
print(longestIncreasingPath([[3, 4, 5], [3, 2, 6], [2, 2, 1]])) # 4
Complexity
- Time:
O((m*n)²)— very slow, re-explores paths - Space:
O(m*n)— recursion stack
2. DFS with Memoization
Intuition
The key observation: the longest path starting from (i, j) is always the same regardless of how we got there — because we only move to strictly larger values, there are no cycles. This means we can safely cache dp[i][j] = longest increasing path starting at (i, j). Once computed, reuse it.
Algorithm
- Create a
dptable of zeros. - For each cell
(i, j), calldfs(i, j). - In
dfs(i, j): ifdp[i][j]is already computed, return it. - Otherwise, check all 4 neighbours that are in bounds and strictly greater.
dp[i][j] = 1 + max(dfs(neighbour))over valid neighbours.- Return
dp[i][j].
Solution
def longestIncreasingPath(matrix):
if not matrix:
return 0
m, n = len(matrix), len(matrix[0])
dp = [[0] * n for _ in range(m)]
dirs = [(0, 1), (0, -1), (1, 0), (-1, 0)]
def dfs(i, j):
if dp[i][j]:
return dp[i][j]
best = 1
for di, dj in dirs:
ni, nj = i + di, j + dj
if 0 <= ni < m and 0 <= nj < n and matrix[ni][nj] > matrix[i][j]:
best = max(best, 1 + dfs(ni, nj))
dp[i][j] = best
return best
ans = 0
for i in range(m):
for j in range(n):
ans = max(ans, dfs(i, j))
return ans
print(longestIncreasingPath([[9, 9, 4], [6, 6, 8], [2, 1, 1]])) # 4
print(longestIncreasingPath([[3, 4, 5], [3, 2, 6], [2, 2, 1]])) # 4
print(longestIncreasingPath([[1]])) # 1
print(longestIncreasingPath([[1, 2], [3, 4]])) # 3 (1→2→4 or 1→3→4)
Complexity
- Time:
O(m * n)— each cell computed exactly once - Space:
O(m * n)— dp table + recursion stack
Common Pitfalls
Using a visited set instead of memoization. You don’t need a visited set here — the strictly increasing constraint already prevents cycles. If you add one, it will interfere with the memoization by blocking valid paths that visit a cell from a different direction.
Checking < instead of <=. The path must be strictly increasing, so matrix[ni][nj] > matrix[i][j]. Using >= would allow equal values and could create infinite loops.
Initialising dp with 0 and misreading the cache. We check if dp[i][j] to detect a cached result. Since paths have length at least 1, dp[i][j] = 0 means “not yet computed” — safe to use. If your grid had valid 0-length results, you’d need a sentinel like -1 instead.
Distinct Subsequences
Difficulty: Hard Source: NeetCode
Problem
Given two strings
sandt, return the number of distinct subsequences ofsthat equalt.Example 1: Input:
s = "rabbbit",t = "rabbit"Output:3Explanation: Three ways to choose whichbto drop.Example 2: Input:
s = "babgbag",t = "bag"Output:5Constraints:
1 <= s.length, t.length <= 1000sandtconsist of lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Longest Common Subsequence — similar 2D DP table structure
- Subsequences — understanding that elements must maintain relative order
1. Brute Force / Recursive
Intuition
Match t against s from left to right. At each position i in s, we decide whether to use s[i] to match the current character t[j]. If s[i] == t[j], we have two options: use this character (advance both i and j) or skip it (advance only i). If they don’t match, we can only skip. We count all ways that exhaust t completely.
Algorithm
- Define
dfs(i, j)= number of ways to matcht[j:]using characters froms[i:]. - Base case:
j == len(t)→ return1(matched all of t). - Base case:
i == len(s)→ return0(ran out of s). - If
s[i] == t[j]: returndfs(i+1, j+1) + dfs(i+1, j). - Else: return
dfs(i+1, j).
Solution
def numDistinct(s, t):
def dfs(i, j):
if j == len(t):
return 1
if i == len(s):
return 0
if s[i] == t[j]:
return dfs(i + 1, j + 1) + dfs(i + 1, j)
return dfs(i + 1, j)
return dfs(0, 0)
print(numDistinct("rabbbit", "rabbit")) # 3
print(numDistinct("babgbag", "bag")) # 5
print(numDistinct("a", "a")) # 1
print(numDistinct("a", "b")) # 0
Complexity
- Time:
O(2^m)— exponential without memoization - Space:
O(m+n)— recursion depth
2. Bottom-Up DP (Tabulation)
Intuition
Build a (m+1) x (n+1) table where dp[i][j] = number of distinct subsequences of s[:i] that equal t[:j]. Base cases: dp[i][0] = 1 for all i (empty t is a subsequence of any prefix of s exactly once). dp[0][j] = 0 for j > 0 (can’t match non-empty t with empty s).
Transition:
- If
s[i-1] == t[j-1]:dp[i][j] = dp[i-1][j-1] + dp[i-1][j](use or skip this character). - Else:
dp[i][j] = dp[i-1][j](must skip, character doesn’t match).
Algorithm
- Create
(m+1) x (n+1)dp table. - Set
dp[i][0] = 1for alliin0..m. - Fill row by row, applying the transition.
- Return
dp[m][n].
Solution
def numDistinct(s, t):
m, n = len(s), len(t)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = 1 # empty t is always a subsequence
for i in range(1, m + 1):
for j in range(1, n + 1):
dp[i][j] = dp[i - 1][j] # always carry down (skip s[i-1])
if s[i - 1] == t[j - 1]:
dp[i][j] += dp[i - 1][j - 1] # also use s[i-1]
return dp[m][n]
print(numDistinct("rabbbit", "rabbit")) # 3
print(numDistinct("babgbag", "bag")) # 5
print(numDistinct("a", "a")) # 1
print(numDistinct("a", "b")) # 0
print(numDistinct("", "")) # 1
# Space-optimised: 1D rolling array
def numDistinct_1d(s, t):
m, n = len(s), len(t)
dp = [0] * (n + 1)
dp[0] = 1
for i in range(m):
for j in range(n - 1, -1, -1): # iterate right-to-left to avoid overwriting
if s[i] == t[j]:
dp[j + 1] += dp[j]
return dp[n]
print(numDistinct_1d("rabbbit", "rabbit")) # 3
print(numDistinct_1d("babgbag", "bag")) # 5
Complexity
- Time:
O(m * n) - Space:
O(m * n)for 2D;O(n)for 1D rolling array
Common Pitfalls
Mixing up rows and columns. dp[i][j] represents s[:i] vs t[:j]. Row i is indexed into s, column j into t. Getting this backwards gives wrong results.
Forgetting the “carry down” part. Even when s[i-1] == t[j-1], we still add dp[i-1][j] (the case where we skip s[i-1]). Only adding dp[i-1][j-1] undercounts.
Wrong 1D iteration direction. In the space-optimised version, iterate j from right to left. If you go left to right, dp[j] will already be updated for the current i when you compute dp[j+1], causing overcounting.
Edit Distance
Difficulty: Hard Source: NeetCode
Problem
Given two strings
word1andword2, return the minimum number of operations required to convertword1toword2. The three allowed operations are: Insert a character, Delete a character, or Replace a character.Example 1: Input:
word1 = "horse",word2 = "ros"Output:3Explanation: horse → rorse (replace h→r), rorse → rose (delete r), rose → ros (delete e)Example 2: Input:
word1 = "intention",word2 = "execution"Output:5Constraints:
0 <= word1.length, word2.length <= 500- Both strings consist of lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- LCS — edit distance is closely related to the longest common subsequence
- 2D DP — the classic table-filling approach for two-string problems
1. Brute Force / Recursive
Intuition
Compare the last characters of both strings. If they match, no operation needed — just recurse on word1[:i-1] and word2[:j-1]. If they don’t match, we can insert, delete, or replace, and we take the minimum cost of all three options plus 1.
- Insert: insert
word2[j-1]at end ofword1→ nowword2[j-1]is matched, recurse on(i, j-1). - Delete: delete
word1[i-1]→ recurse on(i-1, j). - Replace: replace
word1[i-1]withword2[j-1]→ recurse on(i-1, j-1).
Algorithm
- Define
dfs(i, j)= min edits to convertword1[:i]toword2[:j]. - Base:
i == 0→ returnj(insert all of word2). - Base:
j == 0→ returni(delete all of word1). - If characters match:
dfs(i-1, j-1). - Else:
1 + min(dfs(i, j-1), dfs(i-1, j), dfs(i-1, j-1)).
Solution
def minDistance(word1, word2):
def dfs(i, j):
if i == 0:
return j
if j == 0:
return i
if word1[i - 1] == word2[j - 1]:
return dfs(i - 1, j - 1)
return 1 + min(dfs(i, j - 1), # insert
dfs(i - 1, j), # delete
dfs(i - 1, j - 1)) # replace
return dfs(len(word1), len(word2))
print(minDistance("horse", "ros")) # 3
print(minDistance("intention", "execution")) # 5
print(minDistance("", "abc")) # 3
print(minDistance("abc", "")) # 3
Complexity
- Time:
O(3^(m+n))— exponential without memoization - Space:
O(m+n)— recursion depth
2. Bottom-Up DP (Tabulation)
Intuition
Build a (m+1) x (n+1) table. dp[i][j] = minimum edit distance between word1[:i] and word2[:j].
Base cases:
dp[0][j] = j— convert empty string toword2[:j]requiresjinsertions.dp[i][0] = i— convertword1[:i]to empty string requiresideletions.
Transition (same as recursive):
- If
word1[i-1] == word2[j-1]:dp[i][j] = dp[i-1][j-1](no cost). - Else:
dp[i][j] = 1 + min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1]).
Algorithm
- Create
(m+1) x (n+1)dp table. - Initialise first row and column with
0..nand0..m. - Fill row by row using the transition.
- Return
dp[m][n].
Solution
def minDistance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(
dp[i][j - 1], # insert
dp[i - 1][j], # delete
dp[i - 1][j - 1] # replace
)
return dp[m][n]
print(minDistance("horse", "ros")) # 3
print(minDistance("intention", "execution")) # 5
print(minDistance("", "abc")) # 3
print(minDistance("abc", "abc")) # 0
# Space-optimised: two rows
def minDistance_opt(word1, word2):
m, n = len(word1), len(word2)
prev = list(range(n + 1))
for i in range(1, m + 1):
curr = [i] + [0] * n
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
curr[j] = prev[j - 1]
else:
curr[j] = 1 + min(curr[j - 1], prev[j], prev[j - 1])
prev = curr
return prev[n]
print(minDistance_opt("horse", "ros")) # 3
Complexity
- Time:
O(m * n) - Space:
O(m * n)for 2D;O(n)for optimised version
Common Pitfalls
Confusing insert/delete direction. dp[i][j-1] is “insert into word1” (we matched one more char of word2 without advancing word1). dp[i-1][j] is “delete from word1”. The diagonal dp[i-1][j-1] is “replace”.
Not initialising the base cases. Row 0 and column 0 must be filled with 0, 1, 2, ..., n and 0, 1, 2, ..., m respectively. Leaving them at 0 will silently produce wrong answers for strings with empty prefixes.
Thinking replace costs 0 when characters match. When chars match, we use dp[i-1][j-1] directly (0 cost). When they don’t match, replace costs exactly 1 + dp[i-1][j-1]. Don’t add 1 for the matching case.
Burst Balloons
Difficulty: Hard Source: NeetCode
Problem
You are given
nballoons, indexed from0ton-1. Each balloon has a numbernums[i]. If you burst ballooni, you gainnums[i-1] * nums[i] * nums[i+1]coins. The balloons at the boundaries use1for out-of-bound indices. Find the maximum coins you can collect by bursting all the balloons.Example 1: Input:
nums = [3, 1, 5, 8]Output:167Explanation:3*1*5 + 3*5*8 + 1*3*8 + 1*8*1 = 15 + 120 + 24 + 8 = 167Example 2: Input:
nums = [1, 5]Output:10Constraints:
1 <= nums.length <= 3000 <= nums[i] <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Interval DP — solving problems defined over subarrays
- Thinking in reverse — choosing which element to process last instead of first
1. Brute Force / Recursive
Intuition
Naively, try every permutation of bursting order. At each step, choose any balloon to burst, collect coins, recurse on what’s left. This is O(n!) — too slow. The reason is that bursting a balloon changes the neighbours of remaining balloons, making subproblems entangle.
Solution
def maxCoins(nums):
nums = [1] + nums + [1] # add boundary sentinels
def dfs(balloons):
if not balloons:
return 0
best = 0
for i, b in enumerate(balloons):
left = balloons[i - 1] if i > 0 else 1
right = balloons[i + 1] if i < len(balloons) - 1 else 1
coins = left * b * right
remaining = balloons[:i] + balloons[i + 1:]
best = max(best, coins + dfs(remaining))
return best
return dfs(nums[1:-1]) # don't burst the sentinels
# Warning: This is O(n!) — only works for tiny inputs
print(maxCoins([3, 1, 5, 8])) # 167
print(maxCoins([1, 5])) # 10
Complexity
- Time:
O(n!)— factorial, unusable for large inputs - Space:
O(n)— recursion depth
2. Interval DP — Think in Reverse
Intuition
The trick: instead of thinking about which balloon to burst first, think about which balloon to burst last within an interval (i, j). If balloon k is the last one burst in (i, j), its neighbours at that moment are the sentinels nums[i] and nums[j] (the boundaries of the interval). Everything between i and k, and between k and j, was already burst.
So: dp[i][j] = max over k in (i,j) of: dp[i][k] + nums[i]*nums[k]*nums[j] + dp[k][j].
We pad nums with 1s on both ends as sentinels. The answer is dp[0][n+1].
Algorithm
- Pad
numswith sentinels:nums = [1] + nums + [1], lengthn+2. - Create
dptable of size(n+2) x (n+2). - Fill by increasing interval length (from 2 upward).
- For each interval
(i, j), try eachkin(i, j)as the last balloon burst. dp[i][j] = max(dp[i][k] + nums[i]*nums[k]*nums[j] + dp[k][j]).- Return
dp[0][n+1].
Solution
def maxCoins(nums):
nums = [1] + nums + [1]
n = len(nums)
dp = [[0] * n for _ in range(n)]
# Interval length from 2 (at least one balloon inside)
for length in range(2, n):
for i in range(n - length):
j = i + length
for k in range(i + 1, j):
coins = nums[i] * nums[k] * nums[j]
dp[i][j] = max(dp[i][j], dp[i][k] + coins + dp[k][j])
return dp[0][n - 1]
print(maxCoins([3, 1, 5, 8])) # 167
print(maxCoins([1, 5])) # 10
print(maxCoins([5])) # 5
# Top-down memoization version
from functools import lru_cache
def maxCoins_memo(nums):
nums = [1] + nums + [1]
n = len(nums)
@lru_cache(maxsize=None)
def dfs(i, j):
if j - i < 2:
return 0
best = 0
for k in range(i + 1, j):
coins = nums[i] * nums[k] * nums[j]
best = max(best, dfs(i, k) + coins + dfs(k, j))
return best
return dfs(0, n - 1)
print(maxCoins_memo([3, 1, 5, 8])) # 167
print(maxCoins_memo([1, 5])) # 10
Complexity
- Time:
O(n³)— three nested loops/dimensions - Space:
O(n²)— dp table
Common Pitfalls
Thinking about which balloon to burst first. This is the wrong framing — it entangles subproblems because bursting a balloon changes its neighbours. The “last burst” framing keeps intervals independent.
Not padding with sentinels. The boundary condition (1 outside the array) must be represented explicitly. Padding nums with [1, ...original..., 1] makes the interval DP formula clean.
Interval endpoints are exclusive. In dp[i][j], we burst all balloons strictly between i and j. Balloons i and j are the boundaries (already burst or sentinels), not included in the current subproblem.
Regular Expression Matching
Difficulty: Hard Source: NeetCode
Problem
Given an input string
sand a patternp, implement regular expression matching with support for.and*:
.matches any single character.*matches zero or more of the preceding element. The match must cover the entire string, not just a substring.Example 1: Input:
s = "aa",p = "a"Output:falseExample 2: Input:
s = "aa",p = "a*"Output:trueExample 3: Input:
s = "ab",p = ".*"Output:trueConstraints:
1 <= s.length <= 201 <= p.length <= 30scontains only lowercase letters.pcontains lowercase letters,., and*. It is guaranteed*is never the first character.
Prerequisites
Before attempting this problem, you should be comfortable with:
- String matching and recursion — comparing characters with branching logic
- 2D DP on two strings — same table structure as edit distance
1. Brute Force / Recursive
Intuition
Process the pattern one “token” at a time. A token is either a plain character/dot, or a char* pair. If the current pattern character is followed by *, we can choose to use it zero times (skip the char*) or one or more times (consume a matching character from s and stay at the same pattern position). Otherwise, match the current characters directly and recurse on the rest.
Algorithm
- Define
dfs(i, j)= doess[i:]matchp[j:]? - Base:
j == len(p)→ returni == len(s). - Check if current
p[j]matchess[i]:first_match = i < len(s) and p[j] in {s[i], '.'}. - If
j + 1 < len(p)andp[j+1] == '*':- Zero uses:
dfs(i, j+2). - One or more:
first_match and dfs(i+1, j). - Return OR of these.
- Zero uses:
- Else: return
first_match and dfs(i+1, j+1).
Solution
def isMatch(s, p):
def dfs(i, j):
if j == len(p):
return i == len(s)
first_match = i < len(s) and p[j] in (s[i], '.')
if j + 1 < len(p) and p[j + 1] == '*':
# zero uses OR one-or-more uses
return dfs(i, j + 2) or (first_match and dfs(i + 1, j))
return first_match and dfs(i + 1, j + 1)
return dfs(0, 0)
print(isMatch("aa", "a")) # False
print(isMatch("aa", "a*")) # True
print(isMatch("ab", ".*")) # True
print(isMatch("aab", "c*a*b")) # True
print(isMatch("mississippi", "mis*is*p*.")) # False
Complexity
- Time:
O(2^(m+n))— exponential without memoization - Space:
O(m+n)— recursion depth
2. Bottom-Up DP (Tabulation)
Intuition
Build a (m+1) x (n+1) boolean table where dp[i][j] = does s[:i] match p[:j]? Base case: dp[0][0] = True (empty string matches empty pattern). For dp[0][j], only patterns like a*b*c* can match empty strings.
The transitions mirror the recursion exactly:
- If
p[j-1] == '*':dp[i][j] = dp[i][j-2](zero uses) ordp[i-1][j]ifp[j-2]matchess[i-1](extend a match). - Else if
p[j-1]matchess[i-1](direct char or.):dp[i][j] = dp[i-1][j-1].
Algorithm
- Create
(m+1) x (n+1)dp table,dp[0][0] = True. - Fill row 0 for patterns that match empty strings (
a*,a*b*, etc.). - Fill the rest using the transition above.
- Return
dp[m][n].
Solution
def isMatch(s, p):
m, n = len(s), len(p)
dp = [[False] * (n + 1) for _ in range(m + 1)]
dp[0][0] = True
# Patterns that match empty string: a*, a*b*, a*b*c*, ...
for j in range(2, n + 1):
if p[j - 1] == '*':
dp[0][j] = dp[0][j - 2]
for i in range(1, m + 1):
for j in range(1, n + 1):
if p[j - 1] == '*':
# zero occurrences of preceding char
dp[i][j] = dp[i][j - 2]
# one or more occurrences if preceding char matches s[i-1]
if p[j - 2] == s[i - 1] or p[j - 2] == '.':
dp[i][j] = dp[i][j] or dp[i - 1][j]
elif p[j - 1] == s[i - 1] or p[j - 1] == '.':
dp[i][j] = dp[i - 1][j - 1]
return dp[m][n]
print(isMatch("aa", "a")) # False
print(isMatch("aa", "a*")) # True
print(isMatch("ab", ".*")) # True
print(isMatch("aab", "c*a*b")) # True
print(isMatch("mississippi", "mis*is*p*.")) # False
print(isMatch("", "a*")) # True
print(isMatch("", "")) # True
Complexity
- Time:
O(m * n) - Space:
O(m * n)
Common Pitfalls
Treating * as its own character. The * always applies to the character immediately before it (p[j-2] in 1-indexed j). Never process * in isolation — always look back one position.
Forgetting the “zero uses” path for *. When p[j-1] == '*', even if p[j-2] doesn’t match s[i-1], we can always skip the char* pair entirely (dp[i][j-2]). This case must always be included.
Initialising row 0 incorrectly. Only patterns like a*, a*b*, etc. can match the empty string. For j=1, a lone a cannot match empty. Your base case loop must check for * specifically.
Bit Manipulation
Computers speak binary. Bit manipulation lets you talk directly to the hardware — unlocking operations 100x faster than arithmetic.
Every number stored in a computer is ultimately a sequence of 0s and 1s. Most of the time, high-level languages hide this from you. But when you need raw speed, minimal memory, or elegant solutions to certain problems, reaching past the abstraction layer and working directly on bits is one of the most powerful tools available.
Binary Representation
Before manipulating bits, you need to read them. In Python, bin() shows the binary representation of any integer.
numbers = [0, 1, 2, 3, 4, 5, 8, 15, 16, 255]
print(f"{'Decimal':>10} {'Binary':>10} {'Hex':>6}")
print("-" * 32)
for n in numbers:
print(f"{n:>10} {bin(n):>10} {hex(n):>6}")
Every power of 2 has exactly one 1 bit. 255 is eight consecutive 1s — the maximum value of one byte. Recognising these patterns instantly is a core skill.
The Six Bitwise Operators
Python provides six operators that work directly on the binary representation.
| Operator | Symbol | What it does |
|---|---|---|
| AND | & | Keeps a bit only if both inputs have it |
| OR | | | Keeps a bit if either input has it |
| XOR | ^ | Keeps a bit only if the inputs differ |
| NOT | ~ | Flips every bit (returns -(n+1) in Python) |
| Left shift | << | Shifts bits left, filling with 0s (multiplies by 2) |
| Right shift | >> | Shifts bits right, dropping low bits (divides by 2) |
Truth Table for Each Operator
graph TD
subgraph AND
A1["0 & 0 = 0"]
A2["0 & 1 = 0"]
A3["1 & 0 = 0"]
A4["1 & 1 = 1"]
end
subgraph OR
O1["0 | 0 = 0"]
O2["0 | 1 = 1"]
O3["1 | 0 = 1"]
O4["1 | 1 = 1"]
end
subgraph XOR
X1["0 ^ 0 = 0"]
X2["0 ^ 1 = 1"]
X3["1 ^ 0 = 1"]
X4["1 ^ 1 = 0"]
end
subgraph SHIFT
S1["5 << 1 = 10 (×2)"]
S2["5 >> 1 = 2 (÷2)"]
end
XOR is the most interesting operator: it outputs 1 only when inputs differ, and 0 when they are the same. This makes it perfect for toggling and cancellation.
Quick Intuition
a = 0b1010 # 10 in decimal
b = 0b1100 # 12 in decimal
print(f"a = {a:04b} ({a})")
print(f"b = {b:04b} ({b})")
print(f"a & b = {a & b:04b} ({a & b}) AND: only bits both have")
print(f"a | b = {a | b:04b} ({a | b}) OR: bits either has")
print(f"a ^ b = {a ^ b:04b} ({a ^ b}) XOR: bits exactly one has")
print(f"a << 1 = {a << 1:04b} ({a << 1}) Left shift: double")
print(f"a >> 1 = {a >> 1:04b} ({a >> 1}) Right shift: halve")
What’s in This Section
- Bit Operations — all six operators in action, common tricks (
n & 1,n & (n-1), XOR swap), and real-world applications in cryptography, image processing, network masks, chess engines, and Unix permissions
Bit Operations
You can check, set, clear, and flip any single bit — the foundation of cryptography, compression, and graphics.
This page is a practical tour of all six bitwise operators, the most useful tricks built from them, and the real systems that depend on them every day.
Reading Binary in Python
Before anything else, get comfortable reading and writing numbers in binary.
# bin() shows the binary string, format strings let you control width
n = 42
print(f"Decimal: {n}")
print(f"Binary: {bin(n)}")
print(f"Fixed-width: {n:08b}") # 8-bit zero-padded
print(f"Hex: {hex(n)}")
print()
# You can write literals directly in binary
x = 0b00101010 # Same as 42
print(f"0b00101010 = {x}")
print(f"Bit length: {x.bit_length()} bits")
AND ( & ) — Masking and Checking Bits
AND keeps a bit only when both operands have it set. Its primary use is masking: isolating specific bits by ANDing with a pattern (the mask) that has 1s only in the positions you care about.
# --- Check if a number is even or odd ---
# The lowest bit is 1 for odd numbers, 0 for even numbers
for n in range(8):
parity = "odd" if n & 1 else "even"
print(f"{n} = {n:04b} -> {parity}")
print()
# --- Extract specific bits with a mask ---
byte = 0b11010110 # 214
lower_nibble = byte & 0b00001111 # Mask: keep only the lower 4 bits
upper_nibble = (byte >> 4) & 0b00001111 # Shift down, then mask
print(f"byte = {byte:08b} ({byte})")
print(f"lower nibble = {lower_nibble:08b} ({lower_nibble})")
print(f"upper nibble = {upper_nibble:08b} ({upper_nibble})")
OR ( | ) — Setting Bits
OR sets a bit to 1 wherever either operand has a 1. Use it to switch specific bits on without disturbing the rest.
flags = 0b00000000 # All flags off
READ = 0b00000100 # bit 2
WRITE = 0b00000010 # bit 1
EXECUTE = 0b00000001 # bit 0
# Grant read and write permission
flags = flags | READ | WRITE
print(f"flags = {flags:08b} ({flags})")
# Check individual permissions
print(f"Can read? {'yes' if flags & READ else 'no'}")
print(f"Can write? {'yes' if flags & WRITE else 'no'}")
print(f"Can execute? {'yes' if flags & EXECUTE else 'no'}")
print()
# This is exactly how Unix file permissions (rwxrwxrwx) work
# chmod 644 sets: owner=110 (rw-), group=100 (r--), other=100 (r--)
chmod_644 = 0b110100100
print(f"chmod 644 = {chmod_644:09b} (owner: rw-, group: r--, other: r--)")
XOR ( ^ ) — Flipping Bits and Cancellation
XOR outputs 1 only when the two input bits differ. It has two elegant properties:
a ^ a == 0— a value XOR’d with itself cancels outa ^ 0 == a— a value XOR’d with zero is unchanged
These properties make XOR the go-to operator for toggling bits and finding the unique element in a list.
# --- Flip (toggle) specific bits ---
byte = 0b10110100
mask = 0b00001111 # Flip the lower 4 bits
flipped = byte ^ mask
print(f"Original: {byte:08b}")
print(f"Mask: {mask:08b}")
print(f"Flipped: {flipped:08b}")
print()
# --- Find the unique number in a list ---
# Every number appears twice EXCEPT one.
# XOR of a number with itself is 0, so all pairs cancel out.
nums = [4, 1, 2, 1, 2, 4, 7, 3, 3]
result = 0
for n in nums:
result ^= n
print(f"Numbers: {nums}")
print(f"Unique number: {result}")
print()
# --- Swap two variables without a temporary variable ---
a, b = 13, 27
print(f"Before: a={a}, b={b}")
a ^= b
b ^= a
a ^= b
print(f"After: a={a}, b={b}")
NOT ( ~ ) — Complement
NOT flips every bit. In Python, integers have arbitrary precision and use two’s complement representation, so ~n always equals -(n+1).
for n in [0, 1, 5, 42, 127]:
print(f"~{n} = {~n}")
print()
# In fixed-width contexts (like 8-bit hardware), NOT is simpler.
# For an 8-bit value, you AND with 0xFF to mask to 8 bits:
n = 0b10110100 # 180
complement_8bit = (~n) & 0xFF
print(f"n = {n:08b} ({n})")
print(f"~n (8-bit) = {complement_8bit:08b} ({complement_8bit})")
print(f"n + ~n (8-bit) = {n + complement_8bit} (always 255 for 8-bit)")
Left Shift ( << ) — Multiply by Powers of 2
Shifting bits left by k positions is equivalent to multiplying by 2^k, and it executes in a single CPU instruction.
n = 1
print("Left shifts — powers of 2:")
for k in range(9):
shifted = n << k
print(f" 1 << {k} = {shifted:>4} ({shifted:08b})")
print()
# Practical: building bitmasks for specific positions
print("Building position masks:")
for position in [0, 1, 3, 7]:
mask = 1 << position
print(f" bit {position}: {mask:08b} (decimal {mask})")
Right Shift ( >> ) — Divide by Powers of 2
Shifting bits right by k positions is equivalent to integer division by 2^k.
n = 256
print("Right shifts — halving repeatedly:")
for k in range(9):
shifted = n >> k
print(f" 256 >> {k} = {shifted:>4}")
print()
# Practical: extract which 'chunk' a value falls in
# (Used in hash tables, image processing, memory addressing)
value = 0b11010110 # 214
print(f"value = {value:08b} ({value})")
print(f"upper 4 bits = {value >> 4:04b} ({value >> 4})")
print(f"lower 4 bits = {value & 0xF:04b} ({value & 0xF})")
Common Bit Tricks
These patterns appear so frequently they are worth memorising.
# --- n & 1: Check parity (even/odd) ---
print("=== n & 1: parity check ===")
for n in [0, 1, 6, 7, 100, 101]:
print(f" {n} is {'odd' if n & 1 else 'even'}")
print()
# --- n & (n-1): Clear the lowest set bit ---
# Counts bits, detects powers of 2
print("=== n & (n-1): clear lowest set bit ===")
for n in [12, 8, 7, 6]:
result = n & (n - 1)
print(f" {n:04b} & {n-1:04b} = {result:04b} ({n} & {n-1} = {result})")
print()
# --- Power of 2 check: a power of 2 has exactly one 1-bit ---
print("=== Power of 2 detection ===")
def is_power_of_two(n):
return n > 0 and (n & (n - 1)) == 0
for n in [1, 2, 3, 4, 5, 8, 16, 12, 1024, 1000]:
print(f" {n:>5}: {is_power_of_two(n)}")
print()
# --- Count set bits (Brian Kernighan's algorithm) ---
print("=== Count set bits ===")
def count_bits(n):
count = 0
while n:
n &= n - 1 # Clear the lowest set bit each iteration
count += 1
return count
for n in [0, 1, 7, 8, 255, 42]:
print(f" {n:>3} = {n:08b} -> {count_bits(n)} bit(s) set")
Real-World Applications
# === 1. RGB Colour Channels (Image Processing) ===
# A 24-bit colour packs red, green, blue into one integer
colour = 0xFF8C00 # Dark orange
red = (colour >> 16) & 0xFF
green = (colour >> 8) & 0xFF
blue = colour & 0xFF
print(f"Colour: #{colour:06X}")
print(f" R = {red} ({red:08b})")
print(f" G = {green} ({green:08b})")
print(f" B = {blue} ({blue:08b})")
print()
# === 2. Network Subnet Masks ===
# A subnet mask isolates the network portion of an IP address
ip = (192 << 24) | (168 << 16) | (1 << 8) | 42 # 192.168.1.42
mask = (255 << 24) | (255 << 16) | (255 << 8) | 0 # 255.255.255.0
network = ip & mask
host = ip & (~mask & 0xFFFFFFFF)
def ip_str(n):
return f"{(n>>24)&0xFF}.{(n>>16)&0xFF}.{(n>>8)&0xFF}.{n&0xFF}"
print(f"IP address: {ip_str(ip)}")
print(f"Subnet mask: {ip_str(mask)}")
print(f"Network: {ip_str(network)}")
print(f"Host: {host}")
print()
# === 3. XOR in Cryptography ===
# XOR is the core of stream ciphers: ciphertext = plaintext XOR key
message = [72, 101, 108, 108, 111] # "Hello" as ASCII
key = [0xAB, 0xCD, 0xEF, 0x12, 0x34]
encrypted = [m ^ k for m, k in zip(message, key)]
decrypted = [e ^ k for e, k in zip(encrypted, key)] # XOR again to reverse
print(f"Message: {[chr(b) for b in message]}")
print(f"Encrypted: {[hex(b) for b in encrypted]}")
print(f"Decrypted: {[chr(b) for b in decrypted]}")
print()
# === 4. Chess Bitboards ===
# A standard chessboard has 64 squares — fits in a 64-bit integer.
# Each piece type gets its own 64-bit mask.
# White pawns on starting rank (rank 2 = bits 8-15):
white_pawns = 0b0000000000000000000000000000000000000000000000001111111100000000
print("White pawns starting position:")
for rank in range(7, -1, -1):
row = (white_pawns >> (rank * 8)) & 0xFF
print(f" Rank {rank+1}: {row:08b}")
print()
# === 5. Unix File Permissions ===
# rwxrwxrwx is stored as a 9-bit integer
OWNER_R = 0o400
OWNER_W = 0o200
OWNER_X = 0o100
GROUP_R = 0o040
GROUP_W = 0o020
GROUP_X = 0o010
OTHER_R = 0o004
OTHER_W = 0o002
OTHER_X = 0o001
# chmod 755 (rwxr-xr-x)
perms = OWNER_R | OWNER_W | OWNER_X | GROUP_R | GROUP_X | OTHER_R | OTHER_X
print(f"chmod 755:")
print(f" Octal: {oct(perms)}")
print(f" Binary: {perms:09b}")
print(f" owner write? {'yes' if perms & OWNER_W else 'no'}")
print(f" group write? {'yes' if perms & GROUP_W else 'no'}")
print(f" other write? {'yes' if perms & OTHER_W else 'no'}")
Summary Table
| Trick | Code | Use Case |
|---|---|---|
| Check odd/even | n & 1 | Parity, loop branching |
| Clear lowest set bit | n & (n-1) | Counting bits, power-of-2 check |
| Check power of 2 | n > 0 and n & (n-1) == 0 | Alignment checks |
| Set bit k | n | (1 << k) | Grant permissions, set flags |
| Clear bit k | n & ~(1 << k) | Revoke permissions, clear flags |
| Toggle bit k | n ^ (1 << k) | Flip a flag |
| Check bit k | (n >> k) & 1 | Read a flag |
| Multiply by 2^k | n << k | Fast scaling |
| Divide by 2^k | n >> k | Fast downscaling, extracting channels |
| XOR swap | a^=b; b^=a; a^=b | No temp variable needed |
Bit Manipulation Problems
Practice problems for the Bit Manipulation section. Each problem includes multiple approaches, from brute force to optimal, with a focus on understanding the underlying bit tricks.
| # | Problem | Difficulty |
|---|---|---|
| 1 | Single Number | Easy |
| 2 | Number of 1 Bits | Easy |
| 3 | Counting Bits | Easy |
| 4 | Add Binary | Easy |
| 5 | Reverse Bits | Easy |
| 6 | Missing Number | Easy |
| 7 | Sum of Two Integers | Medium |
| 8 | Reverse Integer | Medium |
| 9 | Bitwise AND of Numbers Range | Medium |
| 10 | Minimum Array End | Hard |
Single Number
Difficulty: Easy Source: NeetCode
Problem
Given a non-empty array of integers
nums, every element appears twice except for one. Find that single one.You must implement a solution with a linear runtime complexity and use only constant extra space.
Example 1: Input:
nums = [2, 2, 1]Output:1Example 2: Input:
nums = [4, 1, 2, 1, 2]Output:4Example 3: Input:
nums = [1]Output:1Constraints:
1 <= nums.length <= 3 * 10⁴-3 * 10⁴ <= nums[i] <= 3 * 10⁴- Each element appears exactly twice, except for one element which appears exactly once.
Prerequisites
Before attempting this problem, you should be comfortable with:
- XOR (exclusive OR) — a bitwise operation where bits are 1 if they differ and 0 if they match
- Bit manipulation properties — specifically
a ^ a = 0anda ^ 0 = a
1. Brute Force
Intuition
The straightforward approach is to count how many times each number appears. Any number whose count is odd (specifically 1) is the answer. A hash map makes this easy: add each number as a key and track the frequency. One final pass to find the key with count 1 gives us the answer.
Algorithm
- Create an empty hash map
count. - For each number in
nums, incrementcount[num]by 1. - For each key in
count, return the key whose value equals 1.
Solution
def singleNumber(nums):
count = {}
for num in nums:
count[num] = count.get(num, 0) + 1
for num, freq in count.items():
if freq == 1:
return num
print(singleNumber([2, 2, 1])) # 1
print(singleNumber([4, 1, 2, 1, 2])) # 4
print(singleNumber([1])) # 1
Complexity
- Time:
O(n) - Space:
O(n)
2. XOR Bit Trick
Intuition
XOR has two magical properties that make this problem trivial:
a ^ a = 0— a number XORed with itself is always 0 (every bit cancels)a ^ 0 = a— a number XORed with 0 is itself
Because XOR is also commutative (a ^ b = b ^ a) and associative ((a ^ b) ^ c = a ^ (b ^ c)), the order doesn’t matter. When you XOR all numbers together, every pair of duplicates cancels to 0, and only the single number survives.
For [4, 1, 2, 1, 2]:
4 ^ 1 ^ 2 ^ 1 ^ 2
= 4 ^ (1 ^ 1) ^ (2 ^ 2)
= 4 ^ 0 ^ 0
= 4
In binary: 4 = 100, 1 = 001, 2 = 010. The pairs zero themselves out, leaving 100 = 4.
Algorithm
- Initialize
result = 0. - XOR every number in
numsintoresult. - Return
result.
Solution
def singleNumber(nums):
result = 0
for num in nums:
result ^= num
return result
print(singleNumber([2, 2, 1])) # 1
print(singleNumber([4, 1, 2, 1, 2])) # 4
print(singleNumber([1])) # 1
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Assuming sorted input. The XOR trick works regardless of order — no sorting needed. Don’t add a sort step unnecessarily.
Thinking XOR only works on positive integers. In Python, XOR works on negative integers too because they are represented in two’s complement. The trick is safe for the full input range.
Forgetting result must start at 0. 0 ^ a = a, so starting at 0 is the correct identity element for XOR accumulation. Starting at any other value will corrupt the result.
Number of 1 Bits
Difficulty: Easy Source: NeetCode
Problem
Given a positive integer
n, write a function that returns the number of set bits in its binary representation (also known as the Hamming weight).Example 1: Input:
n = 11Output:3Explanation:11in binary is1011, which has three 1s.Example 2: Input:
n = 128Output:1Explanation:128in binary is10000000, which has one 1.Example 3: Input:
n = 2147483645Output:30Constraints:
1 <= n <= 2³¹ - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Bitwise AND (
&) —n & 1isolates the least significant bit - Right shift (
>>) —n >> 1divides by 2, dropping the last bit - Bit masking — using a mask to check or clear individual bits
1. Brute Force
Intuition
The simplest approach: convert the number to its binary string representation in Python, then count the '1' characters. Python’s bin() built-in does the heavy lifting. This is readable but doesn’t teach you anything about how the bits actually work.
Algorithm
- Convert
nto a binary string usingbin(n)— this produces something like'0b1011'. - Count the occurrences of
'1'in that string. - Return the count.
Solution
def hammingWeight(n):
return bin(n).count('1')
print(hammingWeight(11)) # 3 (1011 in binary)
print(hammingWeight(128)) # 1 (10000000 in binary)
print(hammingWeight(2147483645)) # 30
Complexity
- Time:
O(log n)— proportional to the number of bits - Space:
O(log n)— the binary string length
2. Bit Shifting
Intuition
Instead of converting to a string, inspect the bits directly. The last bit of any number is n & 1 — AND with 1 isolates the rightmost bit. After reading it, right-shift n by 1 to discard that bit and expose the next one. Repeat until n becomes 0.
For n = 11 = 1011 in binary:
step 1: n & 1 = 1 → count = 1, n = 101 (5)
step 2: n & 1 = 1 → count = 2, n = 10 (2)
step 3: n & 1 = 0 → count = 2, n = 1 (1)
step 4: n & 1 = 1 → count = 3, n = 0
done
Algorithm
- Initialize
count = 0. - While
n > 0:- Add
n & 1tocount(this is 1 if the last bit is set, 0 otherwise). - Right-shift
nby 1:n >>= 1.
- Add
- Return
count.
Solution
def hammingWeight(n):
count = 0
while n > 0:
count += n & 1
n >>= 1
return count
print(hammingWeight(11)) # 3
print(hammingWeight(128)) # 1
print(hammingWeight(2147483645)) # 30
Complexity
- Time:
O(log n) - Space:
O(1)
3. Brian Kernighan’s Algorithm
Intuition
Here’s a clever trick: n & (n - 1) clears the lowest set bit of n. That’s it. So instead of checking every bit one by one, we jump directly from one set bit to the next — the loop runs exactly as many times as there are 1-bits.
Why does n & (n - 1) work? Subtracting 1 from n flips the lowest set bit to 0 and turns all lower bits to 1. ANDing with the original n cancels all those lower bits, leaving everything above unchanged and the lowest set bit cleared.
For n = 12 = 1100 in binary:
step 1: n = 1100, n-1 = 1011, n & (n-1) = 1000 → count = 1
step 2: n = 1000, n-1 = 0111, n & (n-1) = 0000 → count = 2
n = 0, done
Two iterations for two set bits — elegant!
Algorithm
- Initialize
count = 0. - While
n > 0:- Increment
count. - Set
n = n & (n - 1)to clear the lowest set bit.
- Increment
- Return
count.
Solution
def hammingWeight(n):
count = 0
while n > 0:
n &= n - 1
count += 1
return count
print(hammingWeight(11)) # 3
print(hammingWeight(128)) # 1
print(hammingWeight(2147483645)) # 30
Complexity
- Time:
O(k)where k is the number of set bits — faster than O(log n) when bits are sparse - Space:
O(1)
Common Pitfalls
Using right shift on negative numbers. Python’s >> on negative integers does arithmetic shift (sign-extending), so n may never reach 0 if it starts negative. Always ensure n is non-negative, or mask with 0xFFFFFFFF if handling signed 32-bit input.
Confusing bin() output. bin(11) returns '0b1011', not '1011'. The '0b' prefix contains a '0' but no '1', so .count('1') still works correctly — but be aware the prefix is there.
Thinking Brian Kernighan’s is only a micro-optimization. For numbers with very few set bits across a large bit width, this approach is significantly faster than scanning every bit position one at a time.
Counting Bits
Difficulty: Easy Source: NeetCode
Problem
Given an integer
n, return an arrayansof lengthn + 1such that for eachi(0 <= i <= n),ans[i]is the number of 1’s in the binary representation ofi.Example 1: Input:
n = 2Output:[0, 1, 1]Explanation:0 → 0,1 → 1,2 → 10Example 2: Input:
n = 5Output:[0, 1, 1, 2, 1, 2]Explanation:0 → 0,1 → 1,2 → 10,3 → 11,4 → 100,5 → 101Constraints:
0 <= n <= 10⁵Follow up: Can you do it in
O(n)without any built-in functions?
Prerequisites
Before attempting this problem, you should be comfortable with:
- Right shift (
>>) —i >> 1is the same asi // 2, removing the last bit - Bitwise AND (
&) —i & 1extracts the last bit (0 or 1) - Dynamic programming — building answers for larger inputs from previously computed smaller inputs
1. Brute Force
Intuition
Count the set bits for every number from 0 to n independently. We already know how to count bits in a single number (see Number of 1 Bits). Just call that logic for each number and collect the results. It’s correct but wasteful — we’re recomputing work we’ve already done for smaller numbers.
Algorithm
- Initialize
ans = []. - For each
ifrom 0 ton(inclusive):- Count the 1-bits in
iusing the bit-shifting method. - Append the count to
ans.
- Count the 1-bits in
- Return
ans.
Solution
def countBits(n):
def count_ones(x):
count = 0
while x > 0:
count += x & 1
x >>= 1
return count
return [count_ones(i) for i in range(n + 1)]
print(countBits(2)) # [0, 1, 1]
print(countBits(5)) # [0, 1, 1, 2, 1, 2]
print(countBits(0)) # [0]
Complexity
- Time:
O(n log n)— for each of the n numbers, we inspect up to log n bits - Space:
O(n)— for the output array
2. Dynamic Programming with Bit Trick
Intuition
Here’s the key insight: every number i is just i >> 1 (i.e., i // 2) with possibly one extra bit appended on the right.
i >> 1removes the last bit — we already know how many 1s are ini >> 1from our earlier computation.- The last bit is either 0 or 1, and we can read it with
i & 1.
So: dp[i] = dp[i >> 1] + (i & 1)
Let’s see this for a few numbers:
i = 0 (0) → dp[0 >> 1] + (0 & 1) = dp[0] + 0 = 0 + 0 = 0
i = 1 (1) → dp[1 >> 1] + (1 & 1) = dp[0] + 1 = 0 + 1 = 1
i = 2 (10) → dp[2 >> 1] + (2 & 1) = dp[1] + 0 = 1 + 0 = 1
i = 3 (11) → dp[3 >> 1] + (3 & 1) = dp[1] + 1 = 1 + 1 = 2
i = 4 (100) → dp[4 >> 1] + (4 & 1) = dp[2] + 0 = 1 + 0 = 1
i = 5 (101) → dp[5 >> 1] + (5 & 1) = dp[2] + 1 = 1 + 1 = 2
Notice i >> 1 is always less than i, so the value we need is always already computed by the time we get to i.
Algorithm
- Initialize
dp = [0] * (n + 1). - For
ifrom 1 ton:- Set
dp[i] = dp[i >> 1] + (i & 1).
- Set
- Return
dp.
Solution
def countBits(n):
dp = [0] * (n + 1)
for i in range(1, n + 1):
dp[i] = dp[i >> 1] + (i & 1)
return dp
print(countBits(2)) # [0, 1, 1]
print(countBits(5)) # [0, 1, 1, 2, 1, 2]
print(countBits(0)) # [0]
Complexity
- Time:
O(n) - Space:
O(n)— for the output array (no extra space beyond the answer)
Common Pitfalls
Starting the loop at 0. dp[0] is 0 by initialization and we’d compute dp[0 >> 1] + (0 & 1) = dp[0] + 0 = 0 anyway — it’s harmless, but starting at 1 is cleaner and avoids any confusion.
Confusing i >> 1 with i - 1. Right shift by 1 is integer division by 2, not subtraction. 5 >> 1 = 2, not 4.
Using // instead of >>. Both work correctly for non-negative integers, but >> makes the bitwise intent explicit and is idiomatic for this type of problem.
Add Binary
Difficulty: Easy Source: NeetCode
Problem
Given two binary strings
aandb, return their sum as a binary string.Example 1: Input:
a = "11",b = "1"Output:"100"Example 2: Input:
a = "1010",b = "1011"Output:"10101"Constraints:
1 <= a.length, b.length <= 10⁴aandbconsist only of'0'or'1'characters.- Neither string contains leading zeros except for the string
"0"itself.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Binary arithmetic — how carry works when adding binary digits
- String manipulation — processing characters from right to left, building a result string
ord()andint()— converting characters to integer values in Python
1. Brute Force (Integer Conversion)
Intuition
Python can convert binary strings to integers with int(s, 2) and convert integers back to binary strings with bin(n). So the “brute force” here is barely any work at all — just let Python do the arithmetic. The trick is stripping the '0b' prefix that bin() adds.
This is completely valid for interviews if the problem allows it, but interviewers sometimes explicitly ask you to avoid built-in conversion — that’s when the next approach matters.
Algorithm
- Convert
ato an integer usingint(a, 2). - Convert
bto an integer usingint(b, 2). - Add them.
- Convert the result back to binary with
bin(), then strip the'0b'prefix.
Solution
def addBinary(a, b):
return bin(int(a, 2) + int(b, 2))[2:]
print(addBinary("11", "1")) # "100"
print(addBinary("1010", "1011")) # "10101"
print(addBinary("0", "0")) # "0"
Complexity
- Time:
O(n + m)— converting and adding strings of length n and m - Space:
O(max(n, m))— for the result string
2. Bit-by-Bit Addition with Carry
Intuition
This is how you’d add two binary numbers by hand — just like decimal addition, but simpler since each digit is only 0 or 1.
Start from the rightmost (least significant) bit of both strings and work left. At each position, add the two bits plus any carry from the previous step. The possible sums are 0, 1, 2, or 3:
- Sum 0 → digit
0, carry0 - Sum 1 → digit
1, carry0 - Sum 2 → digit
0, carry1 - Sum 3 → digit
1, carry1
In general: digit = sum % 2, carry = sum // 2.
After processing both strings, if there’s still a carry left, prepend a 1.
For "11" + "1":
position 0 (rightmost): 1 + 1 + carry(0) = 2 → digit=0, carry=1
position 1: 1 + 0 + carry(1) = 2 → digit=0, carry=1
no more digits, carry=1 → prepend 1
result (reversed): "100"
Algorithm
- Initialize pointers
i = len(a) - 1,j = len(b) - 1,carry = 0, andresult = []. - While
i >= 0orj >= 0orcarry:- Add
int(a[i])ifi >= 0, else 0. - Add
int(b[j])ifj >= 0, else 0. - Add
carry. - Append
total % 2as a string character toresult. - Set
carry = total // 2. - Decrement
iandj.
- Add
- Reverse
resultand join into a string.
Solution
def addBinary(a, b):
i, j = len(a) - 1, len(b) - 1
carry = 0
result = []
while i >= 0 or j >= 0 or carry:
total = carry
if i >= 0:
total += int(a[i])
i -= 1
if j >= 0:
total += int(b[j])
j -= 1
result.append(str(total % 2))
carry = total // 2
return ''.join(reversed(result))
print(addBinary("11", "1")) # "100"
print(addBinary("1010", "1011")) # "10101"
print(addBinary("0", "0")) # "0"
print(addBinary("1111", "1111")) # "11110"
Complexity
- Time:
O(max(n, m))— one pass through both strings - Space:
O(max(n, m))— for the result list
Common Pitfalls
Forgetting the final carry. After both strings are exhausted, there may still be a carry of 1 that needs to become a leading 1 in the result. Always keep the loop running while carry is nonzero.
Building the result in reverse. Since we process from right to left, digits are appended in reverse order. Reversing at the end (or using append + reversed) is necessary.
Using ord(c) - ord('0') vs int(c). Both work for single binary digits. int('1') == 1 is clean and readable. ord('1') - ord('0') == 1 is the same value, just more explicit about the character arithmetic.
Reverse Bits
Difficulty: Easy Source: NeetCode
Problem
Reverse bits of a given 32-bit unsigned integer.
Example 1: Input:
n = 00000010100101000001111010011100(binary) Output:964176192(00111001011110000010100101000000in binary)Example 2: Input:
n = 11111111111111111111111111111101(binary) Output:3221225471(10111111111111111111111111111111in binary)Constraints:
- The input must be a binary string of length 32.
Follow up: If you call this function many times, how would you optimize it?
Prerequisites
Before attempting this problem, you should be comfortable with:
- Left shift (
<<) — multiplies by 2, moving bits to higher positions - Right shift (
>>) — divides by 2, moving bits to lower positions - Bitwise OR (
|) — sets a bit without disturbing others - Bitwise AND (
&) — isolates specific bits
1. Brute Force (String Conversion)
Intuition
Convert the number to a 32-character binary string (zero-padded), reverse it, then convert back to an integer. Python makes this a one-liner, though it sidesteps the actual bit manipulation. The zfill(32) call is important — without it, numbers smaller than 2³¹ would produce a string shorter than 32 characters, and reversing would give the wrong answer.
Algorithm
- Convert
nto a binary string withbin(n)[2:]. - Zero-pad to exactly 32 characters using
.zfill(32). - Reverse the string with
[::-1]. - Convert back to integer with
int(..., 2).
Solution
def reverseBits(n):
return int(bin(n)[2:].zfill(32)[::-1], 2)
print(reverseBits(0b00000010100101000001111010011100)) # 964176192
print(reverseBits(0b11111111111111111111111111111101)) # 3221225471
print(reverseBits(0)) # 0
print(reverseBits(1)) # 2147483648
Complexity
- Time:
O(1)— always exactly 32 bits - Space:
O(1)— fixed-length string
2. Bit-by-Bit Reversal
Intuition
Do it the bit manipulation way: run a loop 32 times and move each bit from n to its mirror position in result.
In each iteration:
- Shift
resultone position left to make room for the next bit. - Read the last bit of
nwithn & 1and OR it intoresult. - Right-shift
nto discard the bit we just read.
After 32 iterations, result holds all 32 bits of n in reversed order.
Let’s trace a small example with n = 5 = 101 (pretending it’s 4-bit for clarity):
start: result = 0000, n = 0101
iter 1: result = 0001, n = 0010 (took bit 1)
iter 2: result = 0010, n = 0001 (took bit 0)
iter 3: result = 0100, n = 0000 (took bit 0)
iter 4: result = 1000, n = 0000 (took bit 0... wait, n is 0!)
Hmm — but the loop still runs all 32 (or 4) times even when n becomes 0. That’s correct: those remaining left-shifts push the already-captured bits into the higher positions of result, which is exactly what we want for the reverse.
Algorithm
- Initialize
result = 0. - Repeat 32 times:
- Left-shift
resultby 1:result <<= 1. - OR in the last bit of
n:result |= n & 1. - Right-shift
nby 1:n >>= 1.
- Left-shift
- Return
result.
Solution
def reverseBits(n):
result = 0
for _ in range(32):
result = (result << 1) | (n & 1)
n >>= 1
return result
print(reverseBits(0b00000010100101000001111010011100)) # 964176192
print(reverseBits(0b11111111111111111111111111111101)) # 3221225471
print(reverseBits(0)) # 0
print(reverseBits(1)) # 2147483648
Complexity
- Time:
O(1)— exactly 32 iterations - Space:
O(1)
Common Pitfalls
Not zero-padding to 32 bits in the string approach. bin(1) gives '0b1', not 32 characters. Without .zfill(32), the reversed string represents a different number entirely.
Reversing only the significant bits. Reversing 101 gives 101 — but the full 32-bit reversal of 5 = 00000000000000000000000000000101 is 10100000000000000000000000000000 = 2684354560. Always work with all 32 bit positions.
Python integer overflow confusion. Python integers grow arbitrarily, so there’s no overflow here. The result will always be a non-negative 32-bit unsigned integer (0 to 2³² − 1), which Python handles without masking.
Missing Number
Difficulty: Easy Source: NeetCode
Problem
Given an array
numscontainingndistinct numbers in the range[0, n], return the only number in the range that is missing from the array.Example 1: Input:
nums = [3, 0, 1]Output:2Example 2: Input:
nums = [0, 1]Output:2Example 3: Input:
nums = [9, 6, 4, 2, 3, 5, 7, 0, 1]Output:8Constraints:
n == nums.length1 <= n <= 10⁴0 <= nums[i] <= n- All the numbers of
numsare unique.Follow up: Can you implement a solution using only
O(1)extra space andO(n)time?
Prerequisites
Before attempting this problem, you should be comfortable with:
- Gaussian sum formula — the sum of integers from 0 to n is
n * (n + 1) / 2 - XOR properties —
a ^ a = 0,a ^ 0 = a, XOR is commutative and associative
1. Math — Gauss Sum
Intuition
If all numbers from 0 to n were present, their sum would be exactly n * (n + 1) / 2. We can compute the actual sum of the array. The difference is the missing number — it’s the only one that didn’t contribute to the total.
For [3, 0, 1] where n = 3:
- Expected sum:
3 * 4 / 2 = 6 - Actual sum:
3 + 0 + 1 = 4 - Missing:
6 - 4 = 2
Clean and simple, no extra data structures needed.
Algorithm
- Let
n = len(nums). - Compute
expected = n * (n + 1) // 2. - Return
expected - sum(nums).
Solution
def missingNumber(nums):
n = len(nums)
expected = n * (n + 1) // 2
return expected - sum(nums)
print(missingNumber([3, 0, 1])) # 2
print(missingNumber([0, 1])) # 2
print(missingNumber([9, 6, 4, 2, 3, 5, 7, 0, 1])) # 8
print(missingNumber([0])) # 1
Complexity
- Time:
O(n) - Space:
O(1)
2. XOR Bit Trick
Intuition
This is the same XOR cancellation idea from Single Number. If we XOR all the indices (0 through n) together with all the values in the array, every number that appears in both cancels out (a ^ a = 0). The only number that doesn’t appear in both is the missing one — it remains.
For [3, 0, 1] where n = 3:
indices XOR values
= (0 ^ 1 ^ 2 ^ 3) ^ (3 ^ 0 ^ 1)
= 0 ^ (1 ^ 1) ^ (3 ^ 3) ^ 2 ^ 0
= 0 ^ 0 ^ 0 ^ 2 ^ 0
= 2
The index 2 appears in the index list but not in the values list, so it survives.
In binary, 2 = 10. Every other number paired up and cancelled.
Algorithm
- Initialize
result = len(nums)(start with n, the index that has no paired value). - For each
ifrom 0 tolen(nums) - 1:- XOR
resultwith bothiandnums[i].
- XOR
- Return
result.
Solution
def missingNumber(nums):
result = len(nums)
for i, num in enumerate(nums):
result ^= i ^ num
return result
print(missingNumber([3, 0, 1])) # 2
print(missingNumber([0, 1])) # 2
print(missingNumber([9, 6, 4, 2, 3, 5, 7, 0, 1])) # 8
print(missingNumber([0])) # 1
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Forgetting to include n in the XOR. The index list goes from 0 to n, but enumerate only gives you 0 to n-1. Starting result = len(nums) (which equals n) covers that last index, ensuring the XOR covers all values from 0 to n.
Integer overflow in other languages. In Java or C++, the Gauss sum n * (n + 1) / 2 can overflow a 32-bit int for large n. Python’s integers are arbitrary precision so this isn’t a concern here, but it matters if you’re translating the solution.
Trying to use sorting. Sorting works (O(n log n)) but it’s slower and misses the point of the problem. The math and XOR approaches are both O(n) with O(1) space, which is what the follow-up asks for.
Sum of Two Integers
Difficulty: Medium Source: NeetCode
Problem
Given two integers
aandb, return the sum of the two integers without using the operators+or-.Example 1: Input:
a = 1,b = 2Output:3Example 2: Input:
a = 2,b = 3Output:5Constraints:
-1000 <= a, b <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- XOR (
^) — adds bits without carrying - AND (
&) — finds bits where both inputs are 1 (i.e., where a carry will occur) - Left shift (
<<) — shifts carry bits one position left (to the correct position) - Two’s complement — how negative numbers are represented in binary (important for Python’s unbounded integers)
1. Brute Force
Intuition
In Python, you can get around the restriction trivially using subtraction-based counting or sum(). But that’s clearly cheating the spirit of the problem. The intended approach is pure bit manipulation, so let’s focus there.
For completeness, here’s the iterative increment approach that technically avoids + and - — but don’t actually use this in an interview!
Algorithm
- Use
operator.addorfunctools.reduce— these call__add__under the hood. - Or just admit the brute force is meaningless here and skip to the real solution.
Solution
import operator
def getSum_trivial(a, b):
# This "avoids" + and - syntactically but uses the same operation internally.
# Don't use in an interview — show the bit manipulation approach instead.
return operator.add(a, b)
print(getSum_trivial(1, 2)) # 3
print(getSum_trivial(2, 3)) # 5
Complexity
- Time:
O(1) - Space:
O(1)
2. Bit Manipulation (XOR + Carry)
Intuition
Think about how addition works at the bit level. When you add two bits:
0 + 0 = 0 (no carry)
0 + 1 = 1 (no carry)
1 + 0 = 1 (no carry)
1 + 1 = 0 (carry 1 to the next position)
This is exactly what XOR computes for the sum bits (ignoring carry) and what AND computes for the carry bits:
a ^ b— sum without carry(a & b) << 1— carry bits shifted into the correct position
Now, add the partial sum and the carry the same way. Repeat until there’s no more carry.
For a = 3 (011), b = 5 (101):
iteration 1:
sum = 011 ^ 101 = 110 (6)
carry = (011 & 101) << 1 = 001 << 1 = 010 (2)
iteration 2:
sum = 110 ^ 010 = 100 (4)
carry = (110 & 010) << 1 = 010 << 1 = 100 (4)
iteration 3:
sum = 100 ^ 100 = 000 (0)... wait that's wrong
Hmm, let me redo: 3 + 5 = 8. Let me retrace:
a=3=011, b=5=101
iter 1: sum=110(6), carry=001<<1=010(2)
iter 2: a=6=110, b=2=010 → sum=100(4), carry=010<<1=100(4)
iter 3: a=4=100, b=4=100 → sum=000(0), carry=100<<1=1000(8)
iter 4: a=0, b=8 → sum=1000(8), carry=0
done: 8 ✓
Python-specific issue: Python integers are unbounded — they never overflow. But (a & b) << 1 can grow infinitely for negative numbers, creating an infinite loop. The fix is to mask to 32 bits with & 0xFFFFFFFF at each step and convert back to a signed integer at the end.
Algorithm
- Use a 32-bit mask
MASK = 0xFFFFFFFF. - While
b != 0:- Compute
carry = (a & b) << 1masked to 32 bits. - Compute
a = (a ^ b)masked to 32 bits. - Set
b = carry.
- Compute
- If the result fits in a 31-bit positive integer (
a <= 0x7FFFFFFF), returna. - Otherwise, convert from unsigned 32-bit to Python signed integer: return
~(a ^ MASK).
Solution
def getSum(a, b):
MASK = 0xFFFFFFFF
MAX = 0x7FFFFFFF
while b != 0:
carry = (a & b) << 1
a = (a ^ b) & MASK
b = carry & MASK
# If a is a valid positive 32-bit int, return it directly.
# Otherwise, interpret the 32-bit unsigned value as a signed negative.
if a <= MAX:
return a
else:
return ~(a ^ MASK)
print(getSum(1, 2)) # 3
print(getSum(2, 3)) # 5
print(getSum(-1, 1)) # 0
print(getSum(-10, 4)) # -6
print(getSum(0, 0)) # 0
Complexity
- Time:
O(1)— at most 32 iterations (one per bit) - Space:
O(1)
Common Pitfalls
Infinite loop with negative numbers in Python. Unlike C++ or Java, Python integers don’t wrap around at 32 bits. Without masking, the carry can keep growing indefinitely for negative inputs. Always mask intermediate results with & 0xFFFFFFFF.
Not converting back to signed. After masking, values >= 2³¹ represent negative numbers in 32-bit two’s complement. The expression ~(a ^ MASK) effectively flips the sign: a ^ MASK inverts all 32 bits, and ~ then adds -1 via Python’s bitwise complement, giving the correct negative value.
Forgetting that b must also be masked. The carry (a & b) << 1 can exceed 32 bits too. Mask both a and b at every iteration.
Reverse Integer
Difficulty: Medium Source: NeetCode
Problem
Given a signed 32-bit integer
x, returnxwith its digits reversed. If reversingxcauses the value to go outside the signed 32-bit integer range[-2³¹, 2³¹ - 1], return0.Assume the environment does not allow you to store 64-bit integers (signed or unsigned).
Example 1: Input:
x = 123Output:321Example 2: Input:
x = -123Output:-321Example 3: Input:
x = 120Output:21Example 4: Input:
x = 1534236469Output:0Explanation: Reversed integer9646324351overflows 32-bit range.Constraints:
-2³¹ <= x <= 2³¹ - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Modulo operator (
%) — extracts the last digit of a number - Integer division (
//) — removes the last digit from a number - 32-bit integer bounds —
[-2147483648, 2147483647] - Python’s modulo behavior for negative numbers — Python’s
%always returns a non-negative result, which differs from C/Java
1. Brute Force (String Reversal)
Intuition
Convert to string, reverse it, check the sign, convert back. Strip the negative sign before reversing, then reapply it. The overflow check is easy — Python integers are unbounded so we just compare against the 32-bit limits before returning.
Algorithm
- Record the sign of
x. - Reverse the absolute value as a string.
- Convert back to integer and reapply the sign.
- Return 0 if the result is outside
[-2³¹, 2³¹ - 1].
Solution
def reverse(x):
sign = -1 if x < 0 else 1
reversed_str = str(abs(x))[::-1]
result = sign * int(reversed_str)
if result < -(2**31) or result > 2**31 - 1:
return 0
return result
print(reverse(123)) # 321
print(reverse(-123)) # -321
print(reverse(120)) # 21
print(reverse(1534236469)) # 0
print(reverse(0)) # 0
Complexity
- Time:
O(log x)— proportional to number of digits - Space:
O(log x)— for the string
2. Digit-by-Digit with Overflow Check
Intuition
Pop digits from the end of x one at a time and push them onto the reversed number. “Popping” a digit means taking x % 10 to get the last digit, then doing x //= 10 to discard it. “Pushing” means doing result = result * 10 + digit.
The tricky part is detecting overflow before it happens (since we can’t store 64-bit integers in the spirit of the problem). Check that result * 10 + digit won’t exceed the bounds before actually computing it.
Python note: Python’s % operator returns a non-negative result even for negative numbers — (-123) % 10 = 7 in Python, not -3. We handle the sign separately and work on abs(x).
For x = 123:
pop 3 → result = 3, x = 12
pop 2 → result = 32, x = 1
pop 1 → result = 321, x = 0
done
For x = 1534236469:
...at some point result will exceed 2147483647, so return 0
Algorithm
- Determine the sign; work with
x = abs(x). - Initialize
result = 0andINT_MAX = 2**31 - 1. - While
x != 0:- Pop the last digit:
digit = x % 10,x //= 10. - Before pushing, check overflow: if
result > (INT_MAX - digit) // 10, return 0. - Push:
result = result * 10 + digit.
- Pop the last digit:
- Return
sign * result.
Solution
def reverse(x):
INT_MAX = 2**31 - 1 # 2147483647
INT_MIN = -(2**31) # -2147483648
sign = -1 if x < 0 else 1
x = abs(x)
result = 0
while x != 0:
digit = x % 10
x //= 10
# Check overflow before committing to the push.
# result * 10 + digit > INT_MAX iff result > (INT_MAX - digit) / 10
if result > (INT_MAX - digit) // 10:
return 0
result = result * 10 + digit
return sign * result
print(reverse(123)) # 321
print(reverse(-123)) # -321
print(reverse(120)) # 21
print(reverse(1534236469)) # 0
print(reverse(0)) # 0
print(reverse(-2147483648)) # 0 (reversed would be 8463847412, overflows)
Complexity
- Time:
O(log x)— one iteration per digit - Space:
O(1)
Common Pitfalls
Python’s negative modulo. (-7) % 10 in Python is 3, not -7. Always work with abs(x) and track the sign separately to avoid surprising results.
Off-by-one in the overflow check. The check result > (INT_MAX - digit) // 10 uses integer division. For INT_MAX = 2147483647: when digit = 7 and result = 214748364, we’d get result * 10 + digit = 2147483647 which is exactly INT_MAX — that’s fine! Only return 0 if it would strictly exceed the limit.
Not handling the minimum bound separately. INT_MIN = -2147483648 has a larger absolute value than INT_MAX = 2147483647. In Python, the sign * result at the end handles this correctly since Python supports arbitrary precision — the final comparison covers both bounds.
Bitwise AND of Numbers Range
Difficulty: Medium Source: NeetCode
Problem
Given two integers
leftandrightthat represent the range[left, right], return the bitwise AND of all numbers in this range, inclusive.Example 1: Input:
left = 5,right = 7Output:4Example 2: Input:
left = 0,right = 0Output:0Example 3: Input:
left = 1,right = 2147483647Output:0Constraints:
0 <= left <= right <= 2³¹ - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Bitwise AND (
&) — a bit is 1 only if it is 1 in both operands - Right shift (
>>) — removes the least significant bit - Common binary prefix — the shared leading bits of two numbers
1. Brute Force
Intuition
AND every number in the range together. Since AND can only turn bits off (never on), the result gets smaller with every additional number. This is correct but extremely slow for large ranges — a range like [1, 2147483647] has over 2 billion numbers to AND together.
Algorithm
- Initialize
result = left. - For each number from
left + 1toright, AND it intoresult. - Return
result.
Solution
def rangeBitwiseAnd(left, right):
result = left
for num in range(left + 1, right + 1):
result &= num
if result == 0: # Early exit: once 0, it stays 0
return 0
return result
# Only test with small ranges — large ranges will be very slow
print(rangeBitwiseAnd(5, 7)) # 4
print(rangeBitwiseAnd(0, 0)) # 0
print(rangeBitwiseAnd(1, 10)) # 0
Complexity
- Time:
O(right - left)— potentially 2³¹ iterations - Space:
O(1)
2. Common Prefix (Right Shift)
Intuition
Here’s the key insight: when you AND a range of consecutive numbers, any bit that changes at any point in the range becomes 0 in the result. A bit can only survive if it stays the same across every number in [left, right].
Which bits stay the same? Only the common prefix — the leading bits that left and right share before their binary representations diverge.
Consider left = 5 = 101 and right = 7 = 111:
5 = 101
6 = 110
7 = 111
AND = 100 = 4
The numbers 5, 6, 7 all start with 1 in the 4’s place, but the lower two bits vary. So only the leading 1 survives → result is 100 = 4.
Now consider left = 12 = 1100 and right = 15 = 1111:
12 = 1100
13 = 1101
14 = 1110
15 = 1111
AND = 1100 = 12
Both share the prefix 11, so the result is 1100 = 12.
Algorithm: Keep right-shifting both left and right until they are equal — at that point we’ve stripped away all the bits that differed. Count how many shifts we made (shift), then left-shift the common prefix back by shift to restore its position.
For left = 5 (101), right = 7 (111):
shift 1: left = 10 (2), right = 11 (3)
shift 2: left = 01 (1), right = 01 (1) → equal!
shift count = 2
result = 1 << 2 = 4 = 100 ✓
Algorithm
- Initialize
shift = 0. - While
left != right:- Right-shift both:
left >>= 1,right >>= 1. - Increment
shift.
- Right-shift both:
- Return
left << shift.
Solution
def rangeBitwiseAnd(left, right):
shift = 0
while left != right:
left >>= 1
right >>= 1
shift += 1
return left << shift
print(rangeBitwiseAnd(5, 7)) # 4
print(rangeBitwiseAnd(0, 0)) # 0
print(rangeBitwiseAnd(1, 2147483647)) # 0
print(rangeBitwiseAnd(12, 15)) # 12
print(rangeBitwiseAnd(6, 7)) # 6
Complexity
- Time:
O(log n)— at most 32 right-shift iterations - Space:
O(1)
Common Pitfalls
Thinking the result equals left & right. It’s tempting to just AND the two endpoints, but that misses all the numbers in between. 5 & 7 = 5 = 101, but the actual answer is 4 = 100 because 6 = 110 kills the last bit.
Why do the intermediate numbers flip bits? In a consecutive range [left, right], if the lower bits of left and right differ (i.e., left != right), there must be some number in between that has a 0 in the position where right has a 1 and vice versa — ANDing those together wipes that bit.
Off-by-one thinking. Even a range of just two consecutive numbers can wipe bits. 6 = 110 and 7 = 111 AND to 110 = 6, but 4 = 100 and 5 = 101 AND to 100 = 4. Always find the common prefix, not just AND the endpoints.
Minimum Array End
Difficulty: Hard Source: NeetCode
Problem
You are given two integers
nandx. You have to construct an array of positive integersnumsof sizenwhere for every0 <= i < n - 1,nums[i + 1]is greater thannums[i], and the result of the bitwiseANDof all elements ofnumsisx.Return the minimum possible value of
nums[n - 1].Example 1: Input:
n = 3,x = 4Output:6Explanation: The array[4, 5, 6]has AND =4 & 5 & 6 = 4, and the last element is6.Example 2: Input:
n = 2,x = 7Output:15Explanation: The array[7, 15]has AND =7 & 15 = 7, and the last element is15.Constraints:
1 <= n <= 10⁸1 <= x <= 10⁸
Prerequisites
Before attempting this problem, you should be comfortable with:
- Bitwise AND (
&) — a bit is 1 only if it is 1 in all operands - Bit masking — using a mask to isolate or embed bits in specific positions
- Bit construction — building a number by placing bits into chosen positions
1. Brute Force (Greedy Iteration)
Intuition
The AND of all elements must equal x. That means every element must have at least the bits of x set — any element with a 0 in a position where x has a 1 would break the AND constraint.
So every array element must be a “superset” of x’s bits: it has all of x’s bits set, plus potentially some extra bits.
To minimize the last element, we want the array to grow as slowly as possible. Start with x itself (the smallest number with exactly x’s bits), then count up through numbers that still have all of x’s bits set. We need to pick n such numbers; the last one is our answer.
For n = 3, x = 4 = 100:
- Numbers with bit 2 set: 4 (100), 5 (101), 6 (110), 7 (111), 12 (1100), …
- First 3: [4, 5, 6] → last element = 6
For n = 2, x = 7 = 111:
- Numbers with all 3 lowest bits set: 7 (0111), 15 (1111), 23 (10111), …
- First 2: [7, 15] → last element = 15
This works but can be slow for large n since we iterate one by one.
Algorithm
- Initialize
current = x. - Repeat
n - 1times:- Increment
currentby 1. - Use OR to ensure all bits of
xare still set:current = current | x.
- Increment
- Return
current.
Solution
def minEnd(n, x):
current = x
for _ in range(n - 1):
current = (current + 1) | x
return current
print(minEnd(3, 4)) # 6
print(minEnd(2, 7)) # 15
print(minEnd(1, 5)) # 5 (array of one element: [5])
print(minEnd(3, 7)) # 23 ([7, 15, 23])
Complexity
- Time:
O(n)— too slow fornup to 10⁸ - Space:
O(1)
2. Bit Embedding (O(log n))
Intuition
The brute force insight was that array elements must have all of x’s bits set, plus any additional bits in the “free” positions (bit positions where x has a 0). Those free positions are where we do our counting.
The crucial realization: as we step through valid numbers (all having x’s bits set), the free bits count in binary from 0 upward. The k-th valid number (0-indexed) has exactly the binary representation of k embedded into the free bit positions of x.
We want the (n-1)-th valid number (0-indexed), so we need to embed the binary representation of n - 1 into the free bit positions of x.
Example: n = 2, x = 7 = 0111
- Free bits: position 3, 4, 5, … (anywhere
xhas a 0) - We want the 1st valid number (0-indexed), so embed
1 = ...001into free bits. - Position 3 is the first free bit. Place the bit
1of(n-1) = 1there. - Result:
0111 | 1000 = 1111 = 15✓
Example: n = 3, x = 4 = 100
- Free bits: positions 0, 1, 3, 4, … (anywhere
xhas a 0) - Embed
n-1 = 2 = 10into free bits. We need 2 free bits.- Free bit 0 (position 0): embed bit 0 of
2=0 - Free bit 1 (position 1): embed bit 1 of
2=1
- Free bit 0 (position 0): embed bit 0 of
- Result:
100 | 010 = 110 = 6✓
Example: n = 3, x = 7 = 111
- Free bits: positions 3, 4, 5, …
- Embed
n-1 = 2 = 10into free bits.- Free bit 0 (position 3): embed bit 0 of
2=0 - Free bit 1 (position 4): embed bit 1 of
2=1
- Free bit 0 (position 3): embed bit 0 of
- Result:
00111 | 10000 = 10111 = 23✓
The algorithm scans through all bit positions. When it finds a 0 in x (a free position), it takes the next bit from n - 1 and places it at that position in the result.
Algorithm
- Let
m = n - 1(the index of the element we want, 0-based). - Start with
result = x. - Scan bit positions from 0 upward, tracking two separate bit indices:
i— current bit position in the final resultj— current bit position inm
- While
m >> j > 0(there are still bits to embed fromm):- If bit
iofxis 0 (it’s a free position):- Extract bit
jofmand place it at positioniofresult. - Advance
jto the next bit ofm.
- Extract bit
- Advance
ito the next bit position.
- If bit
- Return
result.
Solution
def minEnd(n, x):
m = n - 1 # We want the (n-1)-th element, 0-indexed
result = x
i = 0 # bit position in result / x
j = 0 # bit position in m (n-1)
while m >> j: # while there are still bits to embed from m
if not (x >> i & 1): # if position i is a free bit (0 in x)
# Place bit j of m into position i of result
result |= ((m >> j) & 1) << i
j += 1
i += 1
return result
print(minEnd(3, 4)) # 6
print(minEnd(2, 7)) # 15
print(minEnd(1, 5)) # 5
print(minEnd(3, 7)) # 23
print(minEnd(5, 3)) # 15 ([3,7,11,13,15])
Complexity
- Time:
O(log n + log x)— we scan through bits of both n and x - Space:
O(1)
Common Pitfalls
Not understanding why (current + 1) | x works in the brute force. Adding 1 to current may clear some of x’s bits (e.g., if there’s a carry into a set bit of x). ORing with x restores all of x’s required bits, jumping to the next valid number that has all of x’s bits set.
Confusing “free bits” with “zero bits”. A “free bit” is a position where x has a 0. These positions can be freely set to 0 or 1 in array elements without breaking the AND constraint. x’s 1-bits are “locked” — they must be 1 in every element.
Off-by-one on the index. We want the (n-1)-th element (0-indexed) because the first element is index 0 (which is x itself). Embed n - 1 into the free bits, not n.
Large n values. For n up to 10⁸, the brute force O(n) loop would run 100 million iterations. The bit embedding approach runs in about 58 iterations (log₂(10⁸) ≈ 27 bits for m, plus up to 27 locked bits in x, ≤ 54 iterations total), which is orders of magnitude faster.
Greedy
This section groups greedy strategy problems where making the best local choice leads to the optimal global result.
Greedy Problems
Practice problems for interval-style choices, resource allocation, and greedy optimization.
Lemonade Change
Difficulty: Easy Source: NeetCode
Problem
At a lemonade stand, each cup costs
$5. Customers pay with$5,$10, or$20bills. You start with no change. Returntrueif you can provide every customer with the exact change,falseotherwise.Example 1: Input:
bills = [5, 5, 5, 10, 20]Output:trueExample 2: Input:
bills = [5, 5, 10, 10, 20]Output:falseConstraints:
1 <= bills.length <= 10^5bills[i]is5,10, or20
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — making locally optimal choices that lead to a globally correct answer
- Simulation — tracking state as you process each event
1. Brute Force
Intuition
For each customer, just simulate what happens. Track how many $5 and $10 bills you have. If a customer pays $5, no change needed — keep the bill. If they pay $10, give back a $5 (if you have one). If they pay $20, preferably give back a $10 and a $5 (keep $5s for flexibility), otherwise give three $5s. If you can’t make change, return false.
Algorithm
- Initialise
five = 0,ten = 0. - For each
billinbills:$5: incrementfive.$10: iffive > 0, decrementfive, incrementten. Else returnFalse.$20: preferten + fiveoverfive * 3. If neither works, returnFalse.
- Return
True.
Solution
def lemonadeChange(bills):
five = 0
ten = 0
for bill in bills:
if bill == 5:
five += 1
elif bill == 10:
if five == 0:
return False
five -= 1
ten += 1
else: # bill == 20
if ten > 0 and five > 0:
ten -= 1
five -= 1
elif five >= 3:
five -= 3
else:
return False
return True
print(lemonadeChange([5, 5, 5, 10, 20])) # True
print(lemonadeChange([5, 5, 10, 10, 20])) # False
print(lemonadeChange([5, 5, 10])) # True
print(lemonadeChange([10, 10])) # False (no $5 to start)
Complexity
- Time:
O(n) - Space:
O(1)
2. Greedy — Why Prefer $10 When Giving $15 Change
Intuition
This problem has only one real decision point: when giving $15 change (for a $20 bill), should we use $10 + $5 or $5 + $5 + $5? The greedy choice is to prefer $10 + $5. Why? $5 bills are more versatile — they can make change for both $10 and $20 bills. $10 bills can only be used to make change for $20 bills. So we should conserve $5s and spend $10s first when possible. This is the same code as above, just understanding why it works.
Solution
def lemonadeChange(bills):
five = 0
ten = 0
for bill in bills:
if bill == 5:
five += 1
elif bill == 10:
if not five:
return False
five -= 1
ten += 1
else:
# Greedy: use one $10 (less flexible) before using $5s
if ten and five:
ten -= 1
five -= 1
elif five >= 3:
five -= 3
else:
return False
return True
print(lemonadeChange([5, 5, 5, 10, 20])) # True
print(lemonadeChange([5, 5, 10, 10, 20])) # False
print(lemonadeChange([5, 5, 5, 20])) # True
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Not tracking $10 bills separately. You might think tracking only $5s is enough, but when giving change for $20 you need to know if you have a $10. Track five and ten as separate counters.
Giving $5 + $5 + $5 before $10 + $5. This is the classic greedy mistake — using more versatile bills when a less versatile one would suffice. Always prefer to spend the $10 first.
Accepting $20 as valid change. $20 bills are never useful as change (lemonade costs $5, so the most change you’d give is $15). You never need to track $20 bills.
Jump Game
Difficulty: Medium Source: NeetCode
Problem
You are given an integer array
nums. You are initially positioned at the first index. Each elementnums[i]represents your maximum jump length from that position. Returntrueif you can reach the last index.Example 1: Input:
nums = [2, 3, 1, 1, 4]Output:trueExample 2: Input:
nums = [3, 2, 1, 0, 4]Output:falseExplanation: You always land on index 3 which has jump length 0.Constraints:
1 <= nums.length <= 10^40 <= nums[i] <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — tracking the farthest reachable index
- Sliding window / scan — linear pass through the array
1. Brute Force / BFS
Intuition
Model this as a graph problem. From index i, you can jump to any index from i+1 to i + nums[i]. Do a BFS/DFS from index 0 and check if n-1 is reachable. This works but is slow due to revisiting indices.
Algorithm
- Use a set
reachableinitialised with{0}. - For each index
iinreachable:- Add all indices from
i+1toi + nums[i].
- Add all indices from
- Return
n-1 in reachable.
Solution
def canJump(nums):
n = len(nums)
reachable = {0}
queue = [0]
while queue:
i = queue.pop(0)
for j in range(i + 1, min(i + nums[i] + 1, n)):
if j not in reachable:
if j == n - 1:
return True
reachable.add(j)
queue.append(j)
return (n - 1) in reachable
print(canJump([2, 3, 1, 1, 4])) # True
print(canJump([3, 2, 1, 0, 4])) # False
print(canJump([0])) # True
print(canJump([1, 0, 0])) # False
Complexity
- Time:
O(n²)— each index can be visited and expanded - Space:
O(n)— reachable set
2. Greedy — Track Maximum Reachable Index
Intuition
Scan left to right, tracking the farthest index we can reach (max_reach). At each index i, if i > max_reach, we’re stuck — we can’t even get to i, so return false. Otherwise, update max_reach = max(max_reach, i + nums[i]). If we finish the loop without getting stuck, we can reach the end.
Algorithm
- Initialise
max_reach = 0. - For each index
ifrom0ton-1:- If
i > max_reach, returnFalse. max_reach = max(max_reach, i + nums[i]).
- If
- Return
True.
Solution
def canJump(nums):
max_reach = 0
for i in range(len(nums)):
if i > max_reach:
return False
max_reach = max(max_reach, i + nums[i])
return True
print(canJump([2, 3, 1, 1, 4])) # True
print(canJump([3, 2, 1, 0, 4])) # False
print(canJump([0])) # True
print(canJump([1, 0, 0])) # False
print(canJump([2, 0, 0])) # True (can jump from 0 to 2)
# Alternative: scan from right (DP-like)
def canJump_right(nums):
goal = len(nums) - 1
for i in range(len(nums) - 2, -1, -1):
if i + nums[i] >= goal:
goal = i
return goal == 0
print(canJump_right([2, 3, 1, 1, 4])) # True
print(canJump_right([3, 2, 1, 0, 4])) # False
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Returning False when nums[i] == 0. A zero jump length at index i only blocks you if i == max_reach (you can’t jump past it). If i < max_reach, you don’t even need to use index i — you can skip over it.
Checking i >= n as the success condition. Just let the loop complete normally. If you survive the entire loop without ever being stuck (i > max_reach), you’ve succeeded.
Confusing maximum jump with exact jump. nums[i] is the maximum jump, not a required jump. You can jump less. That’s why tracking max_reach = max(max_reach, i + nums[i]) is correct — we update with the farthest possible, knowing any intermediate index is also reachable.
Jump Game II
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, you start at index0. Each elementnums[i]is your maximum jump length from that position. Return the minimum number of jumps to reach the last index. It is guaranteed you can reach the last index.Example 1: Input:
nums = [2, 3, 1, 1, 4]Output:2Explanation: Jump2 → 3, then3 → last.Example 2: Input:
nums = [2, 3, 0, 1, 4]Output:2Constraints:
1 <= nums.length <= 10^40 <= nums[i] <= 1000- The answer is guaranteed to exist.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Jump Game I — can you reach the end?
- BFS level-by-level — each “level” corresponds to one jump
- Greedy interval tracking — deciding when to commit to a jump
1. Brute Force / BFS
Intuition
Model each index as a node. From index i, you can jump to any index in [i+1, i+nums[i]]. Do a BFS starting from 0. The BFS level when we first reach n-1 is the minimum jumps.
Algorithm
- BFS from index
0. - For each index in the current level, add all reachable indices to the next level.
- Count levels until we reach
n-1.
Solution
from collections import deque
def jump(nums):
n = len(nums)
if n == 1:
return 0
visited = {0}
queue = deque([0])
jumps = 0
while queue:
jumps += 1
for _ in range(len(queue)):
i = queue.popleft()
for j in range(i + 1, min(i + nums[i] + 1, n)):
if j == n - 1:
return jumps
if j not in visited:
visited.add(j)
queue.append(j)
return jumps
print(jump([2, 3, 1, 1, 4])) # 2
print(jump([2, 3, 0, 1, 4])) # 2
print(jump([1])) # 0
print(jump([1, 2, 3])) # 2
Complexity
- Time:
O(n²)— each index visited and expanded - Space:
O(n)
2. Greedy — Implicit BFS with Current/Next Window
Intuition
Think of BFS level by level, but without an explicit queue. Each “level” is the range of indices reachable in exactly k jumps. Track two variables:
cur_end: the farthest index reachable with the current number of jumps.farthest: the farthest index reachable with one more jump.
When we reach cur_end, we must make another jump — increment jump count and set cur_end = farthest. We never need to enumerate which index to jump to; we just need to know how far we can reach.
Algorithm
- Initialise
jumps = 0,cur_end = 0,farthest = 0. - For
ifrom0ton-2(don’t need to jump from the last index):farthest = max(farthest, i + nums[i]).- If
i == cur_end:- Increment
jumps. cur_end = farthest.
- Increment
- Return
jumps.
Solution
def jump(nums):
n = len(nums)
jumps = 0
cur_end = 0
farthest = 0
for i in range(n - 1): # no jump needed from last index
farthest = max(farthest, i + nums[i])
if i == cur_end:
jumps += 1
cur_end = farthest
return jumps
print(jump([2, 3, 1, 1, 4])) # 2
print(jump([2, 3, 0, 1, 4])) # 2
print(jump([1])) # 0
print(jump([1, 2, 3])) # 2
print(jump([0])) # 0
# With annotations to show the "levels"
def jump_verbose(nums):
n = len(nums)
if n == 1:
return 0
jumps = 0
cur_end = 0
farthest = 0
for i in range(n - 1):
farthest = max(farthest, i + nums[i])
if i == cur_end:
jumps += 1
cur_end = farthest
print(f"Jump {jumps}: can now reach index {cur_end}")
return jumps
jump_verbose([2, 3, 1, 1, 4])
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Looping to n-1 instead of n-2. If we’re already at the last index, there’s no need to jump. The loop should go up to n-2. If your loop includes n-1, you might take an extra jump.
Incrementing jumps before checking farthest. Always update farthest before checking if we’ve hit cur_end. If you increment first, you might commit to a jump based on stale info.
Confusing this with Jump Game I. In Jump Game I we ask “can we reach the end?” In Jump Game II we ask “what’s the minimum jumps?” The greedy here is different — we track window boundaries, not just a max_reach.
Jump Game VII
Difficulty: Medium Source: NeetCode
Problem
You are given a binary string
s(only'0'and'1'), and two integersminJumpandmaxJump. Start at index0(which is always'0'). On each move, jump from indexito any indexjwherei + minJump <= j <= i + maxJumpands[j] == '0'. Returntrueif you can reach indexn - 1.Example 1: Input:
s = "011010",minJump = 2,maxJump = 3Output:trueExplanation: Jump from index 0 → 3 → 5.Example 2: Input:
s = "01101110",minJump = 2,maxJump = 3Output:falseConstraints:
2 <= s.length <= 10^5s[0] == '0'1 <= minJump <= maxJump < s.length
Prerequisites
Before attempting this problem, you should be comfortable with:
- Jump Game I & II — simpler jump variants
- Prefix sums — counting elements in a range efficiently
- Sliding window — maintaining a running count over a window
1. Brute Force / BFS
Intuition
Do a BFS from index 0. At each reachable index i that contains '0', add all indices j in [i + minJump, i + maxJump] that also contain '0' and haven’t been visited. Return true if we ever reach n - 1.
Algorithm
- Start BFS from
{0}. - For each reachable index
i, explorejfromi + minJumptoi + maxJump. - Add unvisited
'0'indices to the next level. - Return
Trueifn-1is reached.
Solution
from collections import deque
def canReach(s, minJump, maxJump):
n = len(s)
if s[-1] == '1':
return False
visited = {0}
queue = deque([0])
while queue:
i = queue.popleft()
for j in range(i + minJump, min(i + maxJump + 1, n)):
if j not in visited and s[j] == '0':
if j == n - 1:
return True
visited.add(j)
queue.append(j)
return (n - 1) in visited
print(canReach("011010", 2, 3)) # True
print(canReach("01101110", 2, 3)) # False
print(canReach("00", 1, 1)) # True
Complexity
- Time:
O(n * maxJump)— slow when window is large - Space:
O(n)
2. Greedy — Sliding Window with Prefix Reachability
Intuition
Use a boolean array reachable[i] = can we reach index i. reachable[0] = True. For each index j (where s[j] == '0'), it’s reachable if any index in [j - maxJump, j - minJump] is reachable.
Naively checking this range is O(n * maxJump). To speed it up, maintain a running count of reachable indices in a sliding window. Use a prefix sum (or an integer counter updated as the window slides).
Algorithm
reachable = [False] * n,reachable[0] = True.window_count = 0— count of reachable positions in the current window.- For
jfrom1ton-1:- If
j - minJump >= 0andreachable[j - minJump]: incrementwindow_count. - If
j - maxJump - 1 >= 0andreachable[j - maxJump - 1]: decrementwindow_count. - If
s[j] == '0'andwindow_count > 0:reachable[j] = True.
- If
- Return
reachable[n-1].
Solution
def canReach(s, minJump, maxJump):
n = len(s)
if s[-1] == '1':
return False
reachable = [False] * n
reachable[0] = True
window_count = 0 # reachable positions in [j - maxJump, j - minJump]
for j in range(1, n):
# Add left boundary entering the window
if j >= minJump and reachable[j - minJump]:
window_count += 1
# Remove element leaving the window from the right
if j > maxJump and reachable[j - maxJump - 1]:
window_count -= 1
# Mark j reachable if it's '0' and there's a valid predecessor
if s[j] == '0' and window_count > 0:
reachable[j] = True
return reachable[n - 1]
print(canReach("011010", 2, 3)) # True
print(canReach("01101110", 2, 3)) # False
print(canReach("00", 1, 1)) # True
print(canReach("0000000000", 2, 3)) # True
# Alternatively, using prefix sums
def canReach_prefix(s, minJump, maxJump):
n = len(s)
if s[-1] == '1':
return False
reachable = [0] * (n + 1) # prefix sum of reachable flags
reachable[1] = 1 # index 0 is reachable (1-indexed prefix)
dp = [False] * n
dp[0] = True
for j in range(1, n):
if s[j] == '0':
lo = max(0, j - maxJump)
hi = j - minJump
if hi >= 0 and reachable[hi + 1] - reachable[lo] > 0:
dp[j] = True
reachable[j + 1] = reachable[j] + (1 if dp[j] else 0)
return dp[n - 1]
print(canReach_prefix("011010", 2, 3)) # True
print(canReach_prefix("01101110", 2, 3)) # False
Complexity
- Time:
O(n) - Space:
O(n)
Common Pitfalls
Sliding the window incorrectly. The window for position j is [j - maxJump, j - minJump]. When we process j, we add the incoming element j - minJump and remove the outgoing element j - maxJump - 1. Off-by-one here is very easy.
Not checking s[-1] == '1' early. If the last position is '1', we can never land there — short-circuit immediately.
Using BFS when maxJump is large. The BFS approach is O(n * maxJump), which can be O(n²) in the worst case. The sliding window / prefix sum approach is strictly O(n).
Gas Station
Difficulty: Medium Source: NeetCode
Problem
There are
ngas stations in a circular route. At stationi, you getgas[i]fuel and it costscost[i]to travel to the next station. You start with an empty tank. Find the starting station index from which you can complete the circuit, or return-1if no solution exists. The answer is guaranteed to be unique if it exists.Example 1: Input:
gas = [1,2,3,4,5],cost = [3,4,5,1,2]Output:3Example 2: Input:
gas = [2,3,4],cost = [3,4,3]Output:-1Constraints:
1 <= n <= 10^50 <= gas[i], cost[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — key insight about when to abandon a starting position
- Prefix sums — understanding running surplus/deficit
1. Brute Force
Intuition
Try each station as the starting point. Simulate the full circuit from that station. If at any point the tank goes negative, abort and try the next start. If we complete the full loop, return the start index.
Algorithm
- For each start
ifrom0ton-1:- Set
tank = 0. - Simulate going through
nstations (wrapping with modulo). - If tank goes negative at any point, break.
- If we complete
nsteps, returni.
- Set
- Return
-1.
Solution
def canCompleteCircuit(gas, cost):
n = len(gas)
for start in range(n):
tank = 0
can_complete = True
for step in range(n):
i = (start + step) % n
tank += gas[i] - cost[i]
if tank < 0:
can_complete = False
break
if can_complete:
return start
return -1
print(canCompleteCircuit([1, 2, 3, 4, 5], [3, 4, 5, 1, 2])) # 3
print(canCompleteCircuit([2, 3, 4], [3, 4, 3])) # -1
print(canCompleteCircuit([5], [4])) # 0
Complexity
- Time:
O(n²) - Space:
O(1)
2. Greedy — Single Pass
Intuition
Two key observations:
- Feasibility: if
sum(gas) < sum(cost), it’s impossible — return-1. - Finding the start: if we start from some station and the cumulative surplus (
tank) goes negative at stationk, then no starting station from the original start up tokcan work. Why? Because we already had a positive running sum when passing through those intermediate stations — if they were the start, they’d also fail by stationk. So we skip ahead and tryk + 1as the new candidate.
A single pass tracks total_surplus (overall feasibility) and tank (current candidate’s viability).
Algorithm
total = 0,tank = 0,start = 0.- For each station
i:net = gas[i] - cost[i].total += net,tank += net.- If
tank < 0: resetstart = i + 1,tank = 0.
- If
total < 0, return-1. - Return
start.
Solution
def canCompleteCircuit(gas, cost):
total = 0
tank = 0
start = 0
for i in range(len(gas)):
net = gas[i] - cost[i]
total += net
tank += net
if tank < 0:
# Can't start from 'start' through i; try i+1
start = i + 1
tank = 0
# If overall surplus is negative, it's impossible
return start if total >= 0 else -1
print(canCompleteCircuit([1, 2, 3, 4, 5], [3, 4, 5, 1, 2])) # 3
print(canCompleteCircuit([2, 3, 4], [3, 4, 3])) # -1
print(canCompleteCircuit([5], [4])) # 0
print(canCompleteCircuit([3, 3, 4], [3, 4, 4])) # -1
print(canCompleteCircuit([1, 2, 3, 4, 5], [2, 3, 4, 3, 3])) # 0
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Not checking total feasibility. The greedy always produces a start candidate, but that candidate is only valid if sum(gas) >= sum(cost). Always check total >= 0 at the end.
Setting start = i instead of start = i + 1. When we fail at station i, station i itself can’t be part of a valid prefix — we start fresh at i + 1.
Resetting total along with tank. Only reset tank when the candidate fails. total accumulates over the whole circuit to check overall feasibility — never reset it.
Hand of Straights
Difficulty: Medium Source: NeetCode
Problem
Alice has a hand of cards given as an integer array
hand. She wants to rearrange the cards into groups of sizegroupSizeconsecutive cards. Returntrueif she can,falseotherwise.Example 1: Input:
hand = [1,2,3,6,2,3,4,7,8],groupSize = 3Output:trueExplanation: Groups:[1,2,3],[2,3,4],[6,7,8]Example 2: Input:
hand = [1,2,3,4,5],groupSize = 4Output:falseConstraints:
1 <= hand.length <= 10^40 <= hand[i] <= 10^91 <= groupSize <= hand.length
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash maps — counting card frequencies
- Greedy algorithms — always process the smallest available card
- Sorted iteration — using
SortedDictor sorted keys
1. Brute Force
Intuition
Repeatedly sort the hand and find the smallest card. Try to form a consecutive group starting from that card. Remove the used cards and repeat until either all cards are used (return True) or a group can’t be formed (return False).
Algorithm
- While
handis non-empty:- Sort
hand. - Take the smallest card
start = hand[0]. - For
ifromstarttostart + groupSize - 1:- If
iis not inhand, returnFalse. - Remove one occurrence of
i.
- If
- Sort
- Return
True.
Solution
def isNStraightHand(hand, groupSize):
if len(hand) % groupSize != 0:
return False
hand_list = list(hand)
while hand_list:
hand_list.sort()
start = hand_list[0]
for card in range(start, start + groupSize):
if card not in hand_list:
return False
hand_list.remove(card)
return True
print(isNStraightHand([1, 2, 3, 6, 2, 3, 4, 7, 8], 3)) # True
print(isNStraightHand([1, 2, 3, 4, 5], 4)) # False
print(isNStraightHand([1, 1, 2, 2, 3, 3], 3)) # True
Complexity
- Time:
O(n² log n)— sort andlist.removeper group - Space:
O(n)
2. Greedy with Ordered Frequency Map
Intuition
Always process groups starting from the smallest available card. Use a frequency counter. Iterate the sorted unique card values. For the smallest card with count > 0, try to form count groups starting at that card (each group uses cards card, card+1, ..., card+groupSize-1). If any of those successor cards don’t have enough copies, return False. This greedy is optimal because you must eventually use the smallest card, and it can only be the start of a group.
Algorithm
- If
len(hand) % groupSize != 0, returnFalse. - Count frequencies:
count = Counter(hand). - Sort the unique cards.
- For each unique card
c(in sorted order):- If
count[c] == 0, skip. n = count[c]— need to startngroups here.- For
ifrom0togroupSize - 1:- If
count[c + i] < n, returnFalse. count[c + i] -= n.
- If
- If
- Return
True.
Solution
from collections import Counter
def isNStraightHand(hand, groupSize):
if len(hand) % groupSize != 0:
return False
count = Counter(hand)
sorted_keys = sorted(count.keys())
for c in sorted_keys:
if count[c] == 0:
continue
n = count[c] # how many groups start at c
for i in range(groupSize):
if count[c + i] < n:
return False
count[c + i] -= n
return True
print(isNStraightHand([1, 2, 3, 6, 2, 3, 4, 7, 8], 3)) # True
print(isNStraightHand([1, 2, 3, 4, 5], 4)) # False
print(isNStraightHand([1, 1, 2, 2, 3, 3], 3)) # True
print(isNStraightHand([1], 1)) # True
print(isNStraightHand([1, 2, 3, 4, 5, 6], 3)) # True
Complexity
- Time:
O(n log n)— sorting the unique keys - Space:
O(n)— frequency map
Common Pitfalls
Not checking divisibility first. If len(hand) % groupSize != 0, it’s immediately impossible — no need to even look at the cards.
Processing cards in wrong order. You must process in sorted order. If you pick a random card to start a group, you might skip over cards that can only be group starters.
Accessing count[c + i] when that key doesn’t exist. Use Counter which defaults missing keys to 0, or check if c + i not in count explicitly. If count[c + i] is less than n, the group can’t be formed.
Dota2 Senate
Difficulty: Medium Source: NeetCode
Problem
In Dota2’s world, there are two parties: Radiant (
R) and Dire (D). The senate is a string ofRs andDs. In each round, senators take turns in the order they appear in the string. Each senator can ban one senator from the opposing party (removes them from all future rounds). Once only one party’s senators remain, that party announces victory. Return the name of the winning party ("Radiant"or"Dire").Example 1: Input:
senate = "RD"Output:"Radiant"Explanation: R bans D, R wins.Example 2: Input:
senate = "RDD"Output:"Dire"Explanation: R bans first D, second D bans R, second D wins.Constraints:
1 <= senate.length <= 10^4senate[i]is'R'or'D'
Prerequisites
Before attempting this problem, you should be comfortable with:
- Queues — simulating turns in order
- Greedy algorithms — always ban the next opponent, not a random one
1. Brute Force — Repeated Simulation
Intuition
Simulate the rounds. In each round, go through the senate string. Each senator gets a “banning token”. If a senator has an available token and there’s an opponent, use the token to ban the next opponent. Repeat until one party is gone.
Algorithm
- Represent the senate as a list.
- In each round, track available bans per party.
- Remove banned senators.
- Repeat until only one party remains.
Solution
def predictPartyVictory(senate):
senate = list(senate)
while True:
radiant_bans = 0
dire_bans = 0
next_senate = []
for senator in senate:
if senator == 'R':
if dire_bans > 0:
dire_bans -= 1 # this R gets banned
else:
radiant_bans += 1
next_senate.append('R')
else:
if radiant_bans > 0:
radiant_bans -= 1 # this D gets banned
else:
dire_bans += 1
next_senate.append('D')
senate = next_senate
if all(s == 'R' for s in senate):
return "Radiant"
if all(s == 'D' for s in senate):
return "Dire"
print(predictPartyVictory("RD")) # Radiant
print(predictPartyVictory("RDD")) # Dire
print(predictPartyVictory("RDDRD")) # Radiant
Complexity
- Time:
O(n²)— each round might only eliminate one senator - Space:
O(n)
2. Greedy — Two Queues of Indices
Intuition
The greedy insight: when a senator acts, they should always ban the next opponent (the one with the smallest index among remaining opponents). This maximises their advantage — banning a later opponent leaves an earlier opponent free to act first next round.
Use two queues: one for Radiant senator indices, one for Dire. Each round, pop from both queues. The senator with the smaller index acts first and bans the other. The winner re-queues at index + n (to represent being at the back of the next round). Repeat until one queue is empty.
Algorithm
- Build
radiantqueue anddirequeue of indices. - While both queues are non-empty:
- Pop
r = radiant.popleft()andd = dire.popleft(). - Smaller index wins and re-queues at
+ n.
- Pop
- Return
"Radiant"if radiant queue is non-empty, else"Dire".
Solution
from collections import deque
def predictPartyVictory(senate):
n = len(senate)
radiant = deque()
dire = deque()
for i, s in enumerate(senate):
if s == 'R':
radiant.append(i)
else:
dire.append(i)
while radiant and dire:
r = radiant.popleft()
d = dire.popleft()
# Smaller index acts first and survives into the next round
if r < d:
radiant.append(r + n) # re-queue at the back
else:
dire.append(d + n)
return "Radiant" if radiant else "Dire"
print(predictPartyVictory("RD")) # Radiant
print(predictPartyVictory("RDD")) # Dire
print(predictPartyVictory("RDDRD")) # Radiant
print(predictPartyVictory("RRDD")) # Radiant
print(predictPartyVictory("DRRR")) # Radiant
Complexity
- Time:
O(n) - Space:
O(n)
Common Pitfalls
Not re-queuing the winner with offset + n. After a senator bans an opponent, they’re still active next round. By appending index + n, we ensure the senator stays at the “end” relative to senators with original indices — correctly simulating their position in the next round’s order.
Randomly choosing which opponent to ban. The optimal strategy is always to ban the opponent who would act soonest (smallest remaining index). The two-queue approach enforces this automatically.
Stopping too early. The simulation ends when one queue is completely empty. Don’t stop at the end of a single round — senators in queues might still have more rounds to act.
Merge Triplets to Form Target Triplet
Difficulty: Medium Source: NeetCode
Problem
A triplet is an array of three integers. You are given a 2D array
tripletsand a target triplettarget. Merging two triplets means:[max(a,d), max(b,e), max(c,f)]. You can merge any two triplets any number of times. Returntrueif you can formtargetfrom the given triplets.Example 1: Input:
triplets = [[2,5,3],[1,8,4],[1,7,5]],target = [2,7,5]Output:trueExplanation: Merge[2,5,3]and[1,7,5]→[2,7,5].Example 2: Input:
triplets = [[3,4,5],[4,5,6]],target = [3,2,5]Output:falseConstraints:
1 <= triplets.length <= 10^5triplets[i].length == target.length == 31 <= triplets[i][j], target[j] <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy filtering — eliminating candidates that can’t contribute
- Component-wise max — understanding what merging does
1. Brute Force
Intuition
Try all subsets of triplets, merge them, and check if any subset produces the target. A subset is merged by taking the component-wise max. Checking all 2^n subsets is too slow for large inputs.
Algorithm
- For every subset of
triplets:- Merge by taking
maxat each position. - If the result equals
target, returnTrue.
- Merge by taking
- Return
False.
Solution
def mergeTriplets(triplets, target):
n = len(triplets)
for mask in range(1, 1 << n):
merged = [0, 0, 0]
for i in range(n):
if mask & (1 << i):
for j in range(3):
merged[j] = max(merged[j], triplets[i][j])
if merged == target:
return True
return False
print(mergeTriplets([[2, 5, 3], [1, 8, 4], [1, 7, 5]], [2, 7, 5])) # True
print(mergeTriplets([[3, 4, 5], [4, 5, 6]], [3, 2, 5])) # False
print(mergeTriplets([[2, 5, 3], [2, 3, 4], [1, 2, 5]], [2, 5, 5])) # True
Complexity
- Time:
O(2^n * n)— exponential, only feasible for tiny inputs - Space:
O(1)
2. Greedy — Filter Then Check Component Coverage
Intuition
Key observation: if any element of a triplet exceeds the corresponding target element, using that triplet in a merge would permanently push that position above the target (since merge is max). So we can safely ignore such “bad” triplets.
After filtering, take the component-wise max of all remaining triplets. If the result equals target, we can form it. Otherwise, we can’t.
Why does this work? Any triplet that doesn’t exceed the target in any position can only help, never hurt. And if the remaining triplets’ max per position equals the target, we’ve shown each target value is achievable.
Algorithm
- Filter out triplets where any element exceeds the corresponding
targetelement. - Take the component-wise
maxacross all remaining triplets. - Return
Trueif the result equalstarget.
Solution
def mergeTriplets(triplets, target):
result = [0, 0, 0]
for t in triplets:
# Skip triplets that would overshoot any target component
if t[0] > target[0] or t[1] > target[1] or t[2] > target[2]:
continue
# Merge into result
for j in range(3):
result[j] = max(result[j], t[j])
return result == target
print(mergeTriplets([[2, 5, 3], [1, 8, 4], [1, 7, 5]], [2, 7, 5])) # True
print(mergeTriplets([[3, 4, 5], [4, 5, 6]], [3, 2, 5])) # False
print(mergeTriplets([[2, 5, 3], [2, 3, 4], [1, 2, 5]], [2, 5, 5])) # True
print(mergeTriplets([[1, 1, 1]], [1, 1, 1])) # True
print(mergeTriplets([[1, 1, 1], [1, 2, 1]], [1, 2, 1])) # True
# Using any() for cleaner filtering
def mergeTriplets_v2(triplets, target):
result = [0, 0, 0]
for t in triplets:
if any(t[j] > target[j] for j in range(3)):
continue
for j in range(3):
result[j] = max(result[j], t[j])
return result == target
print(mergeTriplets_v2([[2, 5, 3], [1, 8, 4], [1, 7, 5]], [2, 7, 5])) # True
Complexity
- Time:
O(n)— single pass through triplets - Space:
O(1)
Common Pitfalls
Not filtering triplets that exceed the target. If you merge a triplet with value 8 in position 1 when target[1] = 7, the merged result will have 8 at position 1 — permanently exceeding the target. Always skip such triplets.
Returning True when result covers target but has higher values. The result must equal the target exactly, not just dominate it. After merging filtered triplets, check result == target, not result >= target.
Thinking order of merging matters. Merge is commutative and associative (it’s just component-wise max). Order doesn’t matter — we can merge in any order and get the same final result.
Partition Labels
Difficulty: Medium Source: NeetCode
Problem
You are given a string
s. Partition the string into as many parts as possible so that each letter appears in at most one part. Return a list of integers representing the size of these parts.Example 1: Input:
s = "ababcbacadefegdehijhklij"Output:[9, 7, 8]Explanation: Partitions are"ababcbaca","defegde","hijhklij".Example 2: Input:
s = "eccbbbbdec"Output:[10]Constraints:
1 <= s.length <= 500sconsists of lowercase English letters.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash maps — storing the last occurrence of each character
- Greedy interval merging — expanding the current partition’s end greedily
1. Brute Force
Intuition
For each partition attempt, find the last occurrence of the first character. Then check if all characters in that range also have their last occurrence within the range. If not, expand the range. Repeat until the partition is stable, then record its size.
Algorithm
- For each starting position
start:end = last_occurrence[s[start]].- For each index
ifromstarttoend:end = max(end, last_occurrence[s[i]]).
- Record
end - start + 1, movestart = end + 1.
Solution
def partitionLabels(s):
last = {c: i for i, c in enumerate(s)}
result = []
start = 0
while start < len(s):
end = last[s[start]]
i = start
while i <= end:
end = max(end, last[s[i]])
i += 1
result.append(end - start + 1)
start = end + 1
return result
print(partitionLabels("ababcbacadefegdehijhklij")) # [9, 7, 8]
print(partitionLabels("eccbbbbdec")) # [10]
print(partitionLabels("a")) # [1]
Complexity
- Time:
O(n)— each character processed at most twice - Space:
O(1)— at most 26 characters in the map
2. Greedy — Single Pass with Expanding Window
Intuition
Pre-compute the last occurrence of every character. Then scan left to right, maintaining the farthest endpoint needed for the current partition (end). For each character at index i, expand end to max(end, last[s[i]]). When i == end, we’ve found a complete partition — everything from start to end is self-contained. Record the size and start a new partition.
This is greedy because at each step we greedily include only what’s necessary: the last occurrence of every character seen so far.
Algorithm
- Precompute
last[c]= last index of characterc. start = 0,end = 0.- For each
i, cinenumerate(s):end = max(end, last[c]).- If
i == end: partition complete, appendend - start + 1, setstart = i + 1.
- Return result.
Solution
def partitionLabels(s):
last = {c: i for i, c in enumerate(s)}
result = []
start = 0
end = 0
for i, c in enumerate(s):
end = max(end, last[c])
if i == end:
result.append(end - start + 1)
start = i + 1
return result
print(partitionLabels("ababcbacadefegdehijhklij")) # [9, 7, 8]
print(partitionLabels("eccbbbbdec")) # [10]
print(partitionLabels("a")) # [1]
print(partitionLabels("abcabc")) # [6]
print(partitionLabels("abcd")) # [1, 1, 1, 1]
Complexity
- Time:
O(n) - Space:
O(1)— 26-char alphabet, constant space
Common Pitfalls
Using first_occurrence instead of last_occurrence. The partition end must extend to the last time each character appears, not the first. Using first occurrence would allow the same character to appear in two partitions.
Not expanding end for every character in the range. When you extend the partition end, you must also check that the newly added characters don’t require further extension. The inner scan (or the single-pass greedy) handles this.
Using end = last[s[start]] and not updating as you go. If you only look at the starting character’s last occurrence, you’ll miss characters in the middle that extend the partition further.
Valid Parenthesis String
Difficulty: Medium Source: NeetCode
Problem
Given a string
scontaining'(',')', and'*', returntrueif it’s valid. The rules are:
- Any left parenthesis
'('must have a corresponding right parenthesis.- Any right parenthesis
')'must have a corresponding left parenthesis.'*'can be treated as'(',')', or an empty string.Example 1: Input:
s = "()"Output:trueExample 2: Input:
s = "(*)"Output:trueExample 3: Input:
s = "(*))"Output:trueConstraints:
1 <= s.length <= 100s[i]is'(',')', or'*'
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack-based parenthesis matching — classic valid parentheses problem
- Greedy range tracking — maintaining a range of possible open counts
1. Brute Force / Recursive
Intuition
Try all possible interpretations of '*'. For each '*', branch into three possibilities: treat as '(', ')', or empty. Recurse and check if any interpretation leads to a valid string. This is O(3^n) in the worst case.
Algorithm
- Define
dfs(i, open_count):- Base:
i == len(s)→ returnopen_count == 0. - If
open_count < 0, returnFalse. - If
s[i] == '(':dfs(i+1, open+1). - If
s[i] == ')':dfs(i+1, open-1). - If
s[i] == '*': try all three.
- Base:
- Return
dfs(0, 0).
Solution
def checkValidString(s):
def dfs(i, open_count):
if open_count < 0:
return False
if i == len(s):
return open_count == 0
if s[i] == '(':
return dfs(i + 1, open_count + 1)
elif s[i] == ')':
return dfs(i + 1, open_count - 1)
else: # '*'
return (dfs(i + 1, open_count + 1) or # treat as '('
dfs(i + 1, open_count - 1) or # treat as ')'
dfs(i + 1, open_count)) # treat as empty
return dfs(0, 0)
print(checkValidString("()")) # True
print(checkValidString("(*)")) # True
print(checkValidString("(*))")) # True
print(checkValidString("((*)")) # True
print(checkValidString("(()))")) # False
Complexity
- Time:
O(3^n)— three branches per'*' - Space:
O(n)— recursion depth
2. Greedy — Track Range of Valid Open Counts
Intuition
Instead of branching, track a range [lo, hi] of possible open bracket counts at each position. lo is the minimum possible open count (assuming all '*'s are ')' or empty), and hi is the maximum (assuming all '*'s are '(').
'('increments bothloandhi.')'decrements both.'*'decrementslo(treat as')') but incrementshi(treat as'(').
If hi < 0 at any point, there are too many closing brackets — invalid. Clamp lo to max(lo, 0) since negative open counts are meaningless. At the end, valid if lo == 0 (the minimum achievable open count is 0).
Algorithm
lo = hi = 0.- For each char
cins:- If
'(':lo += 1,hi += 1. - If
')':lo -= 1,hi -= 1. - If
'*':lo -= 1,hi += 1. - If
hi < 0, returnFalse. lo = max(lo, 0).
- If
- Return
lo == 0.
Solution
def checkValidString(s):
lo = hi = 0 # [min possible opens, max possible opens]
for c in s:
if c == '(':
lo += 1
hi += 1
elif c == ')':
lo -= 1
hi -= 1
else: # '*'
lo -= 1 # best case: treat as ')'
hi += 1 # worst case: treat as '('
if hi < 0:
return False # even max opens < 0, too many ')'
lo = max(lo, 0) # open count can't be negative
return lo == 0 # minimum achievable open count is 0
print(checkValidString("()")) # True
print(checkValidString("(*)")) # True
print(checkValidString("(*))")) # True
print(checkValidString("((*)")) # True
print(checkValidString("(()))")) # False
print(checkValidString("")) # True
print(checkValidString("*")) # True
print(checkValidString("**")) # True
print(checkValidString("*((")) # False
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Not clamping lo to max(lo, 0). If lo goes negative, it means we’d need a negative number of unmatched (s — which is impossible. Clamp it to 0 to keep the range valid.
Checking lo < 0 instead of hi < 0. hi is the maximum achievable open count. If even the maximum is negative, no valid interpretation exists. lo going negative is normal and handled by clamping.
Returning lo == 0 and hi == 0 at the end. Only lo == 0 is required. If lo == 0 but hi > 0, there are valid interpretations (the '*'s can absorb the remaining opens). Requiring hi == 0 would be overly strict.
Candy
Difficulty: Hard Source: NeetCode
Problem
There are
nchildren standing in a line. Each child has a rating value. You are giving candies to these children with the following requirements:
- Each child must have at least one candy.
- Children with a higher rating than an adjacent child must get more candies than that neighbour. Return the minimum number of candies you need to have.
Example 1: Input:
ratings = [1, 0, 2]Output:5Explanation: Candies:[2, 1, 2]Example 2: Input:
ratings = [1, 2, 2]Output:4Explanation: Candies:[1, 2, 1]Constraints:
1 <= n <= 2 * 10^40 <= ratings[i] <= 2 * 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Two-pass greedy — making one pass left-to-right, another right-to-left
- Array manipulation — maintaining a separate result array
1. Brute Force — Repeated Passes
Intuition
Start with everyone getting 1 candy. Repeatedly scan for violations (a child with a higher rating than a neighbour but not more candies). Fix each violation. Repeat until no violations remain. This is correct but slow — it may take many passes.
Algorithm
- Initialise
candies = [1] * n. - Repeat until stable:
- For each
ifrom0ton-1:- If
ratings[i] > ratings[i-1]andcandies[i] <= candies[i-1]:candies[i] = candies[i-1] + 1. - If
ratings[i] > ratings[i+1]andcandies[i] <= candies[i+1]:candies[i] = candies[i+1] + 1.
- If
- For each
- Return
sum(candies).
Solution
def candy(ratings):
n = len(ratings)
candies = [1] * n
changed = True
while changed:
changed = False
for i in range(n):
if i > 0 and ratings[i] > ratings[i - 1] and candies[i] <= candies[i - 1]:
candies[i] = candies[i - 1] + 1
changed = True
if i < n - 1 and ratings[i] > ratings[i + 1] and candies[i] <= candies[i + 1]:
candies[i] = candies[i + 1] + 1
changed = True
return sum(candies)
print(candy([1, 0, 2])) # 5
print(candy([1, 2, 2])) # 4
print(candy([1, 3, 2, 2, 1])) # 7
Complexity
- Time:
O(n²)— up tonpasses, eachO(n) - Space:
O(n)
2. Greedy — Two Passes
Intuition
Do it in exactly two passes:
Left-to-right pass: If ratings[i] > ratings[i-1], then candies[i] = candies[i-1] + 1. This ensures children with higher ratings than their left neighbour get more candies.
Right-to-left pass: If ratings[i] > ratings[i+1], then candies[i] = max(candies[i], candies[i+1] + 1). This ensures children with higher ratings than their right neighbour also get more candies. We take the max to respect both constraints simultaneously.
The answer is the sum of the final candy array.
Algorithm
candies = [1] * n.- Left-to-right: for
iin1..n-1, ifratings[i] > ratings[i-1],candies[i] = candies[i-1] + 1. - Right-to-left: for
iinn-2..0, ifratings[i] > ratings[i+1],candies[i] = max(candies[i], candies[i+1] + 1). - Return
sum(candies).
Solution
def candy(ratings):
n = len(ratings)
candies = [1] * n
# Left-to-right: enforce left neighbour constraint
for i in range(1, n):
if ratings[i] > ratings[i - 1]:
candies[i] = candies[i - 1] + 1
# Right-to-left: enforce right neighbour constraint
for i in range(n - 2, -1, -1):
if ratings[i] > ratings[i + 1]:
candies[i] = max(candies[i], candies[i + 1] + 1)
return sum(candies)
print(candy([1, 0, 2])) # 5 → [2, 1, 2]
print(candy([1, 2, 2])) # 4 → [1, 2, 1]
print(candy([1, 3, 2, 2, 1])) # 7 → [1, 3, 2, 2, 1] wait... let's check
# ratings: [1, 3, 2, 2, 1]
# L-R: [1, 2, 1, 1, 1]
# R-L: i=3: 2>1? no. i=2: 2>2? no. i=1: 3>2? yes -> max(2, 1+1)=2. i=0: 1>3? no.
# Result: [1, 2, 1, 1, 1] → sum = 6
print(candy([1, 3, 2, 2, 1])) # 6 → [1, 2, 1, 1, 1]
print(candy([1, 2, 87, 87, 87, 2, 1])) # 13
print(candy([3])) # 1
print(candy([1, 2, 3, 4, 5])) # 15 → [1, 2, 3, 4, 5]
print(candy([5, 4, 3, 2, 1])) # 15 → [5, 4, 3, 2, 1]
Complexity
- Time:
O(n) - Space:
O(n)
Common Pitfalls
Using only one pass. A single left-to-right pass misses the case where a child needs more candy because of their right neighbour. You need both passes — left-to-right for left constraints, right-to-left for right constraints.
Assigning in the right-to-left pass without max. If you do candies[i] = candies[i+1] + 1 without max, you overwrite the left-to-right result and may violate the left neighbour constraint. Always take max(candies[i], candies[i+1] + 1).
Thinking equal ratings need equal candies. The problem only says children with higher ratings than a neighbour need more candy. Equal ratings have no constraint between them — each just needs at least 1. [1, 2, 2] correctly gives [1, 2, 1], not [1, 2, 2].
Intervals
This section groups interval problems focused on merging, overlap detection, and schedule allocation.
Interval Problems
Practice problems for overlaps, meeting rooms, and interval-query reasoning.
Insert Interval
Difficulty: Medium Source: NeetCode
Problem
You are given an array of non-overlapping intervals
intervalswhereintervals[i] = [start_i, end_i]represent the start and end of the i-th interval andintervalsis sorted in ascending order bystart_i. You are also given an intervalnewInterval = [start, end]that represents the start and end of another interval.Insert
newIntervalintointervalsso thatintervalsis still sorted in ascending order by start_i andintervalsstill does not have any overlapping intervals (merge overlapping intervals if necessary).Return the resulting array of intervals.
Example 1: Input:
intervals = [[1,3],[6,9]],newInterval = [2,5]Output:[[1,5],[6,9]]Example 2: Input:
intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]],newInterval = [4,8]Output:[[1,2],[3,10],[12,16]]Constraints:
0 <= intervals.length <= 10^4intervals[i].length == 20 <= start_i <= end_i <= 10^5intervalsis sorted bystart_iin ascending ordernewInterval.length == 20 <= start <= end <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Intervals — understanding how intervals overlap (two intervals [a,b] and [c,d] overlap if a <= d and c <= b)
- Greedy — making locally optimal choices (extend the merged interval as far right as possible)
- Arrays — iterating and building a result list
1. Brute Force
Intuition
Insert newInterval into the list, then re-sort by start time, then run the standard merge-intervals algorithm. It is simple and correct, but the sort costs extra time that we do not actually need since the input is already sorted.
Algorithm
- Append
newIntervaltointervals. - Sort
intervalsby start time. - Initialize
resultwith the first interval. - For each remaining interval, if it overlaps the last entry in
result, extend that entry’s end. Otherwise append the interval toresult. - Return
result.
Solution
def insert_brute(intervals, newInterval):
intervals.append(newInterval)
intervals.sort(key=lambda x: x[0])
result = [intervals[0]]
for start, end in intervals[1:]:
last_end = result[-1][1]
if start <= last_end:
result[-1][1] = max(last_end, end)
else:
result.append([start, end])
return result
print(insert_brute([[1,3],[6,9]], [2,5])) # [[1,5],[6,9]]
print(insert_brute([[1,2],[3,5],[6,7],[8,10],[12,16]], [4,8])) # [[1,2],[3,10],[12,16]]
print(insert_brute([], [5,7])) # [[5,7]]
Complexity
- Time:
O(n log n)— dominated by the sort - Space:
O(n)— result list
2. Three-Phase Linear Scan
Intuition
Because the input is already sorted by start time, we can handle this in one pass without sorting. Think of it as three distinct phases: first, collect all intervals that end before the new interval starts (they can never overlap); second, merge all intervals that overlap with the new interval by extending it; third, collect everything that starts after the merged interval ends. This runs in O(n) time.
Algorithm
- Initialize
result = []andi = 0. - Phase 1 — Add intervals before new one: While
i < len(intervals)andintervals[i][1] < newInterval[0], appendintervals[i]toresultand advancei. - Phase 2 — Merge overlapping intervals: While
i < len(intervals)andintervals[i][0] <= newInterval[1], expandnewIntervalto cover the union:newInterval[0] = min(newInterval[0], intervals[i][0]),newInterval[1] = max(newInterval[1], intervals[i][1]), advancei. Then append the mergednewInterval. - Phase 3 — Add remaining intervals: Append
intervals[i:]toresult. - Return
result.
Solution
def insert(intervals, newInterval):
result = []
i = 0
n = len(intervals)
# Phase 1: intervals that end before newInterval starts — no overlap possible
while i < n and intervals[i][1] < newInterval[0]:
result.append(intervals[i])
i += 1
# Phase 2: merge all overlapping intervals into newInterval
while i < n and intervals[i][0] <= newInterval[1]:
newInterval[0] = min(newInterval[0], intervals[i][0])
newInterval[1] = max(newInterval[1], intervals[i][1])
i += 1
result.append(newInterval)
# Phase 3: intervals that start after newInterval ends — no overlap possible
while i < n:
result.append(intervals[i])
i += 1
return result
print(insert([[1,3],[6,9]], [2,5])) # [[1,5],[6,9]]
print(insert([[1,2],[3,5],[6,7],[8,10],[12,16]], [4,8])) # [[1,2],[3,10],[12,16]]
print(insert([], [5,7])) # [[5,7]]
print(insert([[1,5]], [2,3])) # [[1,5]]
print(insert([[1,5]], [6,8])) # [[1,5],[6,8]]
Complexity
- Time:
O(n)— single pass through the list - Space:
O(n)— result list
Common Pitfalls
Forgetting the edge case where the list is empty. When intervals = [], phases 1 and 3 are both skipped, and phase 2 just appends newInterval immediately — the code handles this naturally without a special case.
Off-by-one on overlap conditions. An interval [1,5] and newInterval = [5,7] DO overlap (they share the point 5). The condition for “no overlap before” is intervals[i][1] < newInterval[0] (strictly less), and for “overlapping” it is intervals[i][0] <= newInterval[1] (less than or equal).
Mutating the input. The merging step modifies newInterval in place. If the caller still needs the original newInterval, pass a copy instead.
Merge Intervals
Difficulty: Medium Source: NeetCode
Problem
Given an array of
intervalswhereintervals[i] = [start_i, end_i], merge all overlapping intervals, and return an array of the non-overlapping intervals that cover all the intervals in the input.Example 1: Input:
intervals = [[1,3],[2,6],[8,10],[15,18]]Output:[[1,6],[8,10],[15,18]]Example 2: Input:
intervals = [[1,4],[4,5]]Output:[[1,5]]Constraints:
1 <= intervals.length <= 10^4intervals[i].length == 20 <= start_i <= end_i <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sorting — sorting a list of pairs by the first element
- Intervals — recognising when two intervals overlap and how to merge them
- Greedy — extending the current merged interval as far right as possible before committing
1. Brute Force
Intuition
For every pair of intervals check whether they overlap, merge them if they do, and repeat until no further merges are possible. This is very slow but makes the logic crystal clear: two intervals [a,b] and [c,d] overlap whenever a <= d and c <= b.
Algorithm
- Loop until no merges happen in a full pass:
- For every ordered pair
(i, j), ifintervals[i]andintervals[j]overlap, replace them with their union and remove the other.
- For every ordered pair
- Return the remaining intervals.
Solution
def merge_brute(intervals):
intervals = [list(x) for x in intervals] # make a mutable copy
merged = True
while merged:
merged = False
for i in range(len(intervals)):
for j in range(i + 1, len(intervals)):
a, b = intervals[i]
c, d = intervals[j]
if a <= d and c <= b: # overlap
intervals[i] = [min(a, c), max(b, d)]
intervals.pop(j)
merged = True
break
if merged:
break
return sorted(intervals)
print(merge_brute([[1,3],[2,6],[8,10],[15,18]])) # [[1,6],[8,10],[15,18]]
print(merge_brute([[1,4],[4,5]])) # [[1,5]]
Complexity
- Time:
O(n³)— two nested loops, repeated until no merges remain - Space:
O(n)— copy of the input
2. Sort then Sweep
Intuition
Once we sort intervals by their start time, any interval that overlaps the previous one must start before or at the previous interval’s end. We walk through sorted intervals and either extend the current merged interval’s end or start a new one. A single pass after sorting is all we need.
Algorithm
- Sort
intervalsby start time. - Push the first interval onto
result. - For each subsequent interval
[start, end]:- If
start <= result[-1][1](overlaps with last merged interval), updateresult[-1][1] = max(result[-1][1], end). - Otherwise append
[start, end]as a new entry.
- If
- Return
result.
Solution
def merge(intervals):
intervals.sort(key=lambda x: x[0])
result = [intervals[0]]
for start, end in intervals[1:]:
last_end = result[-1][1]
if start <= last_end:
# Overlapping — extend the last merged interval
result[-1][1] = max(last_end, end)
else:
# No overlap — start a new merged interval
result.append([start, end])
return result
print(merge([[1,3],[2,6],[8,10],[15,18]])) # [[1,6],[8,10],[15,18]]
print(merge([[1,4],[4,5]])) # [[1,5]]
print(merge([[1,4],[0,4]])) # [[0,4]]
print(merge([[1,4],[2,3]])) # [[1,4]]
print(merge([[1,2]])) # [[1,2]]
Complexity
- Time:
O(n log n)— sorting dominates; the sweep is O(n) - Space:
O(n)— result list (O(log n) extra for the sort stack)
Common Pitfalls
Not taking the max of end times. When merging, use max(last_end, end) not just end. An interval can be completely contained inside another (e.g., [1,10] and [2,4]), so the contained interval’s end should not shrink the merged result.
Forgetting that touching intervals also merge. [1,4] and [4,5] share the point 4 and must merge into [1,5]. The overlap condition is start <= last_end (not strictly less than).
Sorting in place vs returning a copy. Python’s list.sort() sorts in place, which modifies the caller’s list. If that is unwanted, use sorted(intervals, ...) to get a fresh copy instead.
Non-Overlapping Intervals
Difficulty: Medium Source: NeetCode
Problem
Given an array of intervals
intervalswhereintervals[i] = [start_i, end_i], return the minimum number of intervals you need to remove to make the rest of the intervals non-overlapping.Note that intervals which only touch at a point are non-overlapping. For example,
[1,2]and[2,3]are non-overlapping.Example 1: Input:
intervals = [[1,2],[2,3],[3,4],[1,3]]Output:1Example 2: Input:
intervals = [[1,2],[1,2],[1,2]]Output:2Example 3: Input:
intervals = [[1,2],[2,3]]Output:0Constraints:
1 <= intervals.length <= 10^5intervals[i].length == 2-5 * 10^4 <= start_i < end_i <= 5 * 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy algorithms — the classic interval scheduling maximization problem
- Sorting — sorting intervals by end time to make the greedy choice easy
- Intervals — understanding when two intervals overlap
1. Brute Force
Intuition
Try removing every possible subset of intervals and check if the remainder is non-overlapping. Return the size of the smallest subset that achieves this. This is exponential and only works on tiny inputs, but it proves the answer by exhaustive search.
Algorithm
- For every subset of
intervals(via bitmask):- Check if removing that subset leaves a non-overlapping set.
- Track the minimum subset size that works.
- Return the minimum.
Solution
def erase_overlap_intervals_brute(intervals):
n = len(intervals)
def is_non_overlapping(keep_mask):
kept = [intervals[i] for i in range(n) if keep_mask & (1 << i)]
kept.sort()
for i in range(1, len(kept)):
if kept[i][0] < kept[i-1][1]:
return False
return True
min_remove = n
for mask in range(1 << n):
removed = bin(mask).count('1')
if removed < min_remove and is_non_overlapping(mask ^ ((1 << n) - 1)):
min_remove = removed
return min_remove
print(erase_overlap_intervals_brute([[1,2],[2,3],[3,4],[1,3]])) # 1
print(erase_overlap_intervals_brute([[1,2],[1,2],[1,2]])) # 2
print(erase_overlap_intervals_brute([[1,2],[2,3]])) # 0
Complexity
- Time:
O(2^n * n)— exponential; only feasible for n ≤ ~20 - Space:
O(n)
2. Greedy — Interval Scheduling Maximization
Intuition
The key insight is that minimizing removals is the same as maximizing the number of intervals we keep. This is the classic interval scheduling problem: sort by end time and greedily keep each interval that does not overlap the last kept one. By always picking the interval that ends earliest, we leave as much room as possible for future intervals — this is provably optimal.
The answer is then total intervals - max intervals we can keep.
Algorithm
- Sort
intervalsby end time. - Initialize
keep = 1(always keep the first interval) andlast_end = intervals[0][1]. - For each subsequent interval
[start, end]:- If
start >= last_end(no overlap), keep it: incrementkeep, updatelast_end = end.
- If
- Return
len(intervals) - keep.
Solution
def erase_overlap_intervals(intervals):
intervals.sort(key=lambda x: x[1]) # sort by end time
keep = 1
last_end = intervals[0][1]
for start, end in intervals[1:]:
if start >= last_end:
# No overlap with last kept interval — keep this one
keep += 1
last_end = end
# Otherwise skip (remove) this interval
return len(intervals) - keep
print(erase_overlap_intervals([[1,2],[2,3],[3,4],[1,3]])) # 1
print(erase_overlap_intervals([[1,2],[1,2],[1,2]])) # 2
print(erase_overlap_intervals([[1,2],[2,3]])) # 0
print(erase_overlap_intervals([[-100,-87],[-99,-44],[-98,-19],[-97,7]])) # 1
Complexity
- Time:
O(n log n)— sorting dominates; the greedy sweep is O(n) - Space:
O(1)— only a few variables beyond the sort
Common Pitfalls
Sorting by start time instead of end time. Sorting by start gives the wrong greedy choice. The classic result says you must sort by end time to maximise the number of non-overlapping intervals kept.
Using strict less-than for the overlap check. Intervals that only touch at a point ([1,2] and [2,3]) are considered non-overlapping by this problem. Use start >= last_end (not >), so touching intervals are kept.
Returning keep instead of len(intervals) - keep. The question asks for the count of removals, not the count of intervals kept. Subtract from total.
Meeting Rooms
Difficulty: Easy Source: NeetCode
Problem
Given an array of meeting time intervals
intervalswhereintervals[i] = [start_i, end_i], determine if a person could attend all meetings (i.e., no two meetings overlap).Example 1: Input:
intervals = [[0,30],[5,10],[15,20]]Output:falseExample 2: Input:
intervals = [[7,10],[2,4]]Output:trueConstraints:
0 <= intervals.length <= 10^4intervals[i].length == 20 <= start_i < end_i <= 10^6
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sorting — sorting intervals by start time to bring potentially conflicting meetings next to each other
- Intervals — knowing that two intervals
[a,b]and[c,d](with a ≤ c) overlap wheneverc < b
1. Brute Force
Intuition
Check every pair of meetings to see if any two overlap. If the start of one meeting falls inside another, it is a conflict. This is straightforward but quadratic — fine for small inputs, but we can do better.
Algorithm
- For every pair
(i, j)withi < j:- If
intervals[i][0] < intervals[j][1]andintervals[j][0] < intervals[i][1], there is an overlap — returnFalse.
- If
- If no pair overlaps, return
True.
Solution
def can_attend_meetings_brute(intervals):
n = len(intervals)
for i in range(n):
for j in range(i + 1, n):
a_start, a_end = intervals[i]
b_start, b_end = intervals[j]
# Two intervals overlap iff each starts before the other ends
if a_start < b_end and b_start < a_end:
return False
return True
print(can_attend_meetings_brute([[0,30],[5,10],[15,20]])) # False
print(can_attend_meetings_brute([[7,10],[2,4]])) # True
print(can_attend_meetings_brute([])) # True
print(can_attend_meetings_brute([[5,8],[9,15]])) # True
Complexity
- Time:
O(n²)— all pairs - Space:
O(1)
2. Sort by Start Time
Intuition
Once we sort meetings by start time, any overlap must occur between two adjacent meetings in the sorted order. If meeting i ends after meeting i+1 starts, there is a conflict. A single pass after sorting is enough — we never need to compare non-adjacent pairs.
Algorithm
- Sort
intervalsby start time. - For each consecutive pair
(intervals[i], intervals[i+1]):- If
intervals[i][1] > intervals[i+1][0], returnFalse(the current meeting has not ended when the next one starts).
- If
- Return
True.
Solution
def can_attend_meetings(intervals):
intervals.sort(key=lambda x: x[0])
for i in range(len(intervals) - 1):
# If current meeting ends after the next one starts — conflict
if intervals[i][1] > intervals[i + 1][0]:
return False
return True
print(can_attend_meetings([[0,30],[5,10],[15,20]])) # False
print(can_attend_meetings([[7,10],[2,4]])) # True
print(can_attend_meetings([])) # True
print(can_attend_meetings([[5,8],[9,15]])) # True
print(can_attend_meetings([[5,8],[8,15]])) # True (touching is OK)
Complexity
- Time:
O(n log n)— sorting dominates - Space:
O(1)— in-place sort, constant extra variables
Common Pitfalls
Using >= instead of > for the overlap check. Meetings that only touch at a boundary ([5,8] and [8,10]) do NOT conflict — the first ends exactly when the second begins. Use strict > so touching meetings are allowed.
Forgetting to handle an empty list. When intervals is empty, the loop body never executes and True is returned correctly — but make sure your code does not index into an empty list before the loop.
Not sorting before scanning. Without sorting, a late-starting meeting could appear before an early-starting one, making adjacent comparisons miss real conflicts.
Meeting Rooms II
Difficulty: Medium Source: NeetCode
Problem
Given an array of meeting time intervals
intervalswhereintervals[i] = [start_i, end_i], return the minimum number of conference rooms required.Example 1: Input:
intervals = [[0,30],[5,10],[15,20]]Output:2Example 2: Input:
intervals = [[7,10],[2,4]]Output:1Constraints:
1 <= intervals.length <= 10^40 <= start_i < end_i <= 10^6
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sorting — sorting meetings by start time to process them in order
- Min-heap (priority queue) — quickly finding the room that frees up soonest
- Two pointers — the alternative approach using sorted start and end arrays
1. Brute Force
Intuition
Simulate the process directly: for each meeting, scan all existing rooms to find one that has already finished. If none is free, open a new room. This is correct but slow because the “find a free room” scan is linear.
Algorithm
- Sort
intervalsby start time. - Maintain a list
roomswhere each entry is the end time of the meeting currently occupying that room. - For each meeting
[start, end]:- Find any room whose current meeting ends by
start. If found, update that room’s end time toend. - Otherwise add a new room with end time
end.
- Find any room whose current meeting ends by
- Return
len(rooms).
Solution
def min_meeting_rooms_brute(intervals):
intervals.sort(key=lambda x: x[0])
rooms = [] # each entry = end time of meeting in that room
for start, end in intervals:
# Try to find a free room
assigned = False
for i in range(len(rooms)):
if rooms[i] <= start:
rooms[i] = end
assigned = True
break
if not assigned:
rooms.append(end)
return len(rooms)
print(min_meeting_rooms_brute([[0,30],[5,10],[15,20]])) # 2
print(min_meeting_rooms_brute([[7,10],[2,4]])) # 1
print(min_meeting_rooms_brute([[1,5],[2,6],[3,7]])) # 3
Complexity
- Time:
O(n²)— for each meeting, we scan all rooms - Space:
O(n)— rooms list
2. Min-Heap of End Times
Intuition
Sort meetings by start time. Maintain a min-heap that holds the end times of all currently occupied rooms — so the top of the heap is the room that frees up soonest. When the next meeting starts, peek at the heap: if the earliest-ending room is already free (its end time ≤ current meeting’s start), pop it and reuse it; otherwise push a new room. The heap size at the end equals the number of rooms needed.
Algorithm
- Sort
intervalsby start time. - Initialize a min-heap
heap. - For each meeting
[start, end]:- If
heapis non-empty andheap[0] <= start, pop the earliest-ending room (it is free). - Push
endonto the heap (allocate or reuse a room).
- If
- Return
len(heap).
Solution
import heapq
def min_meeting_rooms(intervals):
intervals.sort(key=lambda x: x[0])
heap = [] # min-heap of end times
for start, end in intervals:
if heap and heap[0] <= start:
heapq.heappop(heap) # reuse the earliest-freed room
heapq.heappush(heap, end)
return len(heap)
print(min_meeting_rooms([[0,30],[5,10],[15,20]])) # 2
print(min_meeting_rooms([[7,10],[2,4]])) # 1
print(min_meeting_rooms([[1,5],[2,6],[3,7]])) # 3
print(min_meeting_rooms([[1,10]])) # 1
Complexity
- Time:
O(n log n)— sorting + heap operations (each push/pop is O(log n)) - Space:
O(n)— heap can hold all n meetings in the worst case
3. Two Pointers on Sorted Start and End Arrays
Intuition
Separate the start times and end times into two sorted arrays. Use two pointers s and e. Process events in chronological order: if the next event is a start, we need a new room (increment rooms); if the next event is an end, a room is freed (decrement rooms, advance e). Track the maximum rooms used at any point.
Algorithm
- Create
starts = sorted([i[0] for i in intervals])andends = sorted([i[1] for i in intervals]). - Initialize
rooms = 0,max_rooms = 0,s = 0,e = 0. - While
s < n:- If
starts[s] < ends[e]: a meeting starts before the earliest end — new room needed. Incrementrooms, advances. - Else: a meeting ends before or when this one starts — free a room. Decrement
rooms, advancee. - Update
max_rooms = max(max_rooms, rooms).
- If
- Return
max_rooms.
Solution
def min_meeting_rooms_two_pointers(intervals):
n = len(intervals)
starts = sorted(i[0] for i in intervals)
ends = sorted(i[1] for i in intervals)
rooms = 0
max_rooms = 0
e = 0
for s in range(n):
if starts[s] < ends[e]:
rooms += 1
else:
rooms -= 1
e += 1
max_rooms = max(max_rooms, rooms)
return max_rooms
print(min_meeting_rooms_two_pointers([[0,30],[5,10],[15,20]])) # 2
print(min_meeting_rooms_two_pointers([[7,10],[2,4]])) # 1
print(min_meeting_rooms_two_pointers([[1,5],[2,6],[3,7]])) # 3
Complexity
- Time:
O(n log n)— two sorts - Space:
O(n)— two extra arrays
Common Pitfalls
Using < vs <= when checking if a room is free. If a meeting ends at time 5 and another starts at time 5, the room IS free. Use heap[0] <= start (not <) to allow back-to-back meetings in the same room.
Forgetting to sort before using the heap. The heap approach only works correctly when meetings are processed in start-time order. Sorting first is mandatory.
Confusing rooms opened vs maximum simultaneous rooms. The question asks for the peak concurrent rooms. The heap size at the end gives you this naturally — it counts rooms that were never freed.
Meeting Rooms III
Difficulty: Hard Source: NeetCode
Problem
You are given an integer
nrepresenting the number of rooms numbered0ton - 1. You are also given a 2D integer arraymeetingswheremeetings[i] = [start_i, end_i]represents the start and end times of meetingi(end time is exclusive — the room is free again atend_i).All meetings must be held. Each meeting is assigned to the lowest-numbered available room. If no room is available, the meeting is delayed until the earliest room becomes free. The delayed meeting retains its original duration. Return the room number that held the most meetings. If multiple rooms held the same number, return the room with the lowest number.
Example 1: Input:
n = 2,meetings = [[0,10],[1,5],[2,7],[3,4]]Output:0Example 2: Input:
n = 3,meetings = [[1,20],[2,10],[3,5],[4,9],[6,8]]Output:1Constraints:
1 <= n <= 1001 <= meetings.length <= 10^50 <= start_i < end_i <= 5 * 10^5- All values of
start_iare unique.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Min-heap (priority queue) — quickly finding the room that becomes free soonest and the lowest-numbered available room
- Sorting — processing meetings in chronological order
- Simulation — carefully tracking the state of each room at each step
1. Brute Force
Intuition
Sort meetings by start time and process them one by one. For each meeting, scan all rooms in order to find the lowest-numbered free one. If none is free, find which room ends soonest, delay the meeting to that time, and use that room. Simple but slow for large inputs.
Algorithm
- Sort
meetingsby start time. - Maintain
end_times[i]= when roominext becomes free (initially 0). - Maintain
count[i]= meetings held in roomi. - For each meeting
[start, end]:- Find the lowest-indexed room
rwhereend_times[r] <= start. If found, assign this meeting. - Otherwise, find the room
rwith the smallestend_times[r], delay the meeting: new end =end_times[r] + (end - start). - Update
end_times[r]andcount[r].
- Find the lowest-indexed room
- Return the room with the highest count (lowest index on ties).
Solution
def most_booked_brute(n, meetings):
meetings.sort()
end_times = [0] * n
count = [0] * n
for start, end in meetings:
duration = end - start
# Try to find the lowest-indexed free room
assigned = -1
for i in range(n):
if end_times[i] <= start:
assigned = i
break
if assigned == -1:
# No room available — use the one that frees up soonest
earliest_end = min(end_times)
assigned = end_times.index(earliest_end)
end_times[assigned] = earliest_end + duration
else:
end_times[assigned] = end
count[assigned] += 1
# Return room with most meetings (lowest index on tie)
return count.index(max(count))
print(most_booked_brute(2, [[0,10],[1,5],[2,7],[3,4]])) # 0
print(most_booked_brute(3, [[1,20],[2,10],[3,5],[4,9],[6,8]])) # 1
Complexity
- Time:
O(m * n)— for each meeting, scan all n rooms - Space:
O(n)
2. Two Min-Heaps
Intuition
Use two heaps to track room state efficiently. One min-heap holds available room numbers (so the smallest is always at the top). Another min-heap holds occupied rooms as (end_time, room_number) pairs (so the earliest-freeing room is always at the top).
For each meeting in sorted order: first, pop all rooms from the occupied heap that have already ended and move them back to the available heap. Then, if the available heap is non-empty, grab the lowest-numbered room. If not, pop the earliest-ending room from the occupied heap, delay the meeting to start when that room is free, and reuse it. Push the room onto the occupied heap with its new end time.
Algorithm
- Sort
meetingsby start time. - Initialize
available = min-heap([0, 1, ..., n-1])andoccupied = min-heap()(pairs of(end_time, room_id)). - Initialize
count = [0] * n. - For each meeting
[start, end]:- Move rooms from
occupiedtoavailableif their end time ≤start. - If
availableis non-empty: pop the lowest-numbered room, schedule meeting ending atend. - Else: pop
(earliest_end, room)fromoccupied, schedule ending atearliest_end + (end - start). - Push
(new_end, room)ontooccupied, incrementcount[room].
- Move rooms from
- Return the room with the highest count (ties broken by lowest index).
Solution
import heapq
def most_booked(n, meetings):
meetings.sort()
available = list(range(n)) # min-heap of free room numbers
heapq.heapify(available)
occupied = [] # min-heap of (end_time, room_id)
count = [0] * n
for start, end in meetings:
# Free rooms whose meetings have ended
while occupied and occupied[0][0] <= start:
end_time, room = heapq.heappop(occupied)
heapq.heappush(available, room)
if available:
# Assign to lowest-numbered free room
room = heapq.heappop(available)
heapq.heappush(occupied, (end, room))
else:
# All rooms busy — wait for the earliest-ending one
earliest_end, room = heapq.heappop(occupied)
new_end = earliest_end + (end - start)
heapq.heappush(occupied, (new_end, room))
count[room] += 1
# Room with most meetings; lowest index wins on tie
max_count = max(count)
return count.index(max_count)
print(most_booked(2, [[0,10],[1,5],[2,7],[3,4]])) # 0
print(most_booked(3, [[1,20],[2,10],[3,5],[4,9],[6,8]])) # 1
print(most_booked(2, [[0,10],[1,5],[2,7],[3,4]])) # 0
Complexity
- Time:
O(m log m + m log n)— sorting meetings + heap operations per meeting - Space:
O(n + m)— two heaps
Common Pitfalls
Freeing rooms too eagerly. Only free a room when end_time <= start (the room is free by the time the meeting begins). Using strict < would incorrectly prevent a room from being reused when back-to-back scheduling is possible.
Forgetting to preserve original duration when delaying. A delayed meeting keeps its original length. If a meeting [3,7] (duration 4) is delayed until room frees at time 10, the new end is 10 + 4 = 14, not 10 + 7 = 17.
Not breaking ties correctly. When multiple rooms free up at the same time, the problem requires the lowest-numbered room. The tuple comparison (end_time, room_id) in the occupied heap handles this naturally — Python compares tuples lexicographically.
Minimum Interval to Include Each Query
Difficulty: Hard Source: NeetCode
Problem
You are given a 2D integer array
intervals, whereintervals[i] = [left_i, right_i]describes the i-th interval starting atleft_iand ending atright_i(inclusive). The size of an interval is defined asright_i - left_i + 1.You are also given an integer array
queries. The answer to the j-th query is the size of the smallest interval that containsqueries[j]. If no such interval exists, the answer is-1.Return an array containing the answers to the queries.
Example 1: Input:
intervals = [[1,4],[2,4],[3,6],[4,4]],queries = [2,3,4,5]Output:[3,3,1,4]Example 2: Input:
intervals = [[2,3],[2,5],[1,8],[20,25]],queries = [2,19,5,22]Output:[2,-1,4,6]Constraints:
1 <= intervals.length <= 10^51 <= queries.length <= 10^51 <= left_i <= right_i <= 10^71 <= queries[j] <= 10^7
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sorting — processing intervals and queries in order to avoid redundant work
- Min-heap (priority queue) — efficiently retrieving the smallest active interval covering a query point
- Offline query processing — sorting queries and answering them out of original order, then reassembling results
1. Brute Force
Intuition
For each query, scan every interval and check if the query point lies within it. Among all matching intervals, return the smallest one. Simple and correct, but O(n * m) which is too slow for large inputs.
Algorithm
- For each query
qinqueries:- Initialize
best = -1. - For each interval
[left, right]: ifleft <= q <= right, compute sizeright - left + 1and updatebest = min(best, size)ifbest == -1or size is smaller. - Append
bestto the result.
- Initialize
- Return result.
Solution
def min_interval_brute(intervals, queries):
result = []
for q in queries:
best = -1
for left, right in intervals:
if left <= q <= right:
size = right - left + 1
if best == -1 or size < best:
best = size
result.append(best)
return result
print(min_interval_brute([[1,4],[2,4],[3,6],[4,4]], [2,3,4,5])) # [3,3,1,4]
print(min_interval_brute([[2,3],[2,5],[1,8],[20,25]], [2,19,5,22])) # [2,-1,4,6]
Complexity
- Time:
O(n * m)— n intervals, m queries - Space:
O(m)— result array
2. Sort + Sweep with Min-Heap
Intuition
Sort both intervals by start and queries by value. Use a pointer to sweep through intervals. For each query (in sorted order), add all intervals whose start ≤ query to a min-heap keyed by size. Then remove from the heap’s top any intervals that have already ended (end < query). The heap’s minimum is the answer. Store answers indexed by original query position so we can return them in the original order.
This works because: once we’ve sorted both, we only add each interval once and remove each once — so total work is O((n + m) log n).
Algorithm
- Sort
intervalsby start time. Create sortedqueriespaired with original indices. - Initialize a min-heap
heap(entries:(size, end)), interval pointeri = 0, and result dict. - For each
(q, original_idx)in sorted queries:- Push all intervals with
start <= qonto the heap as(size, end). - Pop from heap top all intervals where
end < q(they no longer containq). - If heap is non-empty,
result[original_idx] = heap[0][0]; else-1.
- Push all intervals with
- Return
[result[i] for i in range(len(queries))].
Solution
import heapq
def min_interval(intervals, queries):
intervals.sort(key=lambda x: x[0])
indexed_queries = sorted(enumerate(queries), key=lambda x: x[1])
heap = [] # (size, end_time) — min-heap by size
result = {}
i = 0 # pointer into sorted intervals
for original_idx, q in indexed_queries:
# Add all intervals that have started by the time of this query
while i < len(intervals) and intervals[i][0] <= q:
left, right = intervals[i]
size = right - left + 1
heapq.heappush(heap, (size, right))
i += 1
# Remove intervals that ended before this query
while heap and heap[0][1] < q:
heapq.heappop(heap)
result[original_idx] = heap[0][0] if heap else -1
return [result[i] for i in range(len(queries))]
print(min_interval([[1,4],[2,4],[3,6],[4,4]], [2,3,4,5])) # [3,3,1,4]
print(min_interval([[2,3],[2,5],[1,8],[20,25]], [2,19,5,22])) # [2,-1,4,6]
print(min_interval([[1,2]], [3])) # [-1]
print(min_interval([[1,10],[2,3]], [2,3])) # [2,2]
Complexity
- Time:
O((n + m) log n)— each interval is pushed and popped at most once; sorting is O(n log n + m log m) - Space:
O(n + m)— heap and result storage
Common Pitfalls
Returning answers in query order. Queries are sorted for processing, but results must be returned in original order. Track the original index of each query and use a dict or indexed array to reassemble.
Removing intervals too aggressively. Only remove an interval from the heap when its end is strictly less than the current query (end < q). An interval [1,4] with end 4 does contain query point 4 — use < q, not <= q.
Not re-keying the heap. Python’s heapq does not support deletion by key. Instead, lazily remove stale entries from the top of the heap (those with end < q) before reading the result. This is the standard “lazy deletion” pattern.
Math
This section groups DSA problems driven by arithmetic, number properties, and matrix transformations.
Math Problems
Practice problems for number theory, matrix operations, and math-heavy simulation.
Excel Sheet Column Title
Difficulty: Easy Source: NeetCode
Problem
Given an integer
columnNumber, return its corresponding column title as it appears in an Excel spreadsheet.The mapping is: 1 → “A”, 2 → “B”, …, 26 → “Z”, 27 → “AA”, 28 → “AB”, …
Example 1: Input:
columnNumber = 1Output:"A"Example 2: Input:
columnNumber = 28Output:"AB"Example 3: Input:
columnNumber = 701Output:"ZY"Constraints:
1 <= columnNumber <= 2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Modular arithmetic — using
%and//to extract digits of a number - Base conversion — understanding how standard base-26 differs from Excel’s 1-indexed scheme
- String building — prepending characters as you extract digits from least-significant to most-significant
1. Brute Force (Simulation)
Intuition
Count up from 1 and generate column titles in order until we reach columnNumber. The sequence goes A, B, …, Z, AA, AB, … This is almost never used in practice, but it makes the mapping crystal clear.
Algorithm
- Generate column titles one at a time, counting from 1 up to
columnNumber. - To generate the next title, increment the last character. If it wraps past Z, carry over (like binary addition).
- Return the title at position
columnNumber.
Solution
def convert_to_title_brute(columnNumber):
# Iteratively build each title from 1 to columnNumber
title = ""
for n in range(1, columnNumber + 1):
# Recompute from scratch for simplicity
temp = n
chars = []
while temp > 0:
temp -= 1
chars.append(chr(temp % 26 + ord('A')))
temp //= 26
title = ''.join(reversed(chars))
return title
print(convert_to_title_brute(1)) # A
print(convert_to_title_brute(26)) # Z
print(convert_to_title_brute(27)) # AA
print(convert_to_title_brute(28)) # AB
Complexity
- Time:
O(n * log n)— iterates up to columnNumber, each with a log-depth conversion - Space:
O(log n)— the current title
2. Direct Base-26 Conversion (1-Indexed)
Intuition
This looks like base-26 conversion, but with a twist: normal base-26 uses digits 0–25, while Excel uses 1–26 (A–Z). There is no zero — “Z” is 26, not “10”. The fix is to subtract 1 before taking the modulo on each iteration. This maps the range 1–26 to 0–25 perfectly, letting us index into 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'. Extract digits from least significant to most significant, then reverse.
Algorithm
- Initialize
result = []. - While
n > 0:- Subtract 1 from
nto shift to 0-indexed:n -= 1. - Append
chr(n % 26 + ord('A'))toresult. - Integer-divide:
n //= 26.
- Subtract 1 from
- Reverse
resultand join into a string.
Solution
def convert_to_title(n):
result = []
while n > 0:
n -= 1 # shift from 1-indexed to 0-indexed
result.append(chr(n % 26 + ord('A')))
n //= 26
return ''.join(reversed(result))
print(convert_to_title(1)) # A
print(convert_to_title(26)) # Z
print(convert_to_title(27)) # AA
print(convert_to_title(28)) # AB
print(convert_to_title(701)) # ZY
print(convert_to_title(702)) # ZZ
print(convert_to_title(703)) # AAA
Complexity
- Time:
O(log n)— number of digits in base-26 representation - Space:
O(log n)— the result list
Common Pitfalls
Forgetting the n -= 1 step. Without it, the modulo maps 26 to 0 (the null character) instead of Z. The subtract-1 trick is the core insight that makes 1-indexed base-26 work.
Not reversing the result. Characters are extracted from least-significant to most-significant (right to left), so they need to be reversed before joining.
Using ord('A') + (n % 26) - 1 instead of the subtract-first approach. This leads to an edge case when n % 26 == 0 (e.g., for Z=26), producing ord('A') - 1 which is @. Always subtract 1 from n first, then do the modulo.
Greatest Common Divisor of Strings
Difficulty: Easy Source: NeetCode
Problem
For two strings
sandt, we saytdividessif and only ifs = t + t + t + ... + t + t(i.e.,tis concatenated with itself one or more times to forms).Given two strings
str1andstr2, return the largest stringxsuch thatxdivides bothstr1andstr2.Example 1: Input:
str1 = "ABCABC",str2 = "ABC"Output:"ABC"Example 2: Input:
str1 = "ABABAB",str2 = "ABAB"Output:"AB"Example 3: Input:
str1 = "LEET",str2 = "CODE"Output:""Constraints:
1 <= str1.length, str2.length <= 1000str1andstr2consist of English uppercase letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- GCD (Greatest Common Divisor) — Euclid’s algorithm and why it applies to string lengths
- String properties — understanding that if a divisor string exists, both strings must be constructed from the same repeating unit
1. Brute Force
Intuition
Try every possible prefix of the shorter string as a candidate divisor. For each candidate, check whether it divides both strings. Return the longest one that works. A string t divides s if and only if s can be formed by repeating t an integer number of times, which we can check with Python’s string multiplication.
Algorithm
- Let
candidate_leniterate frommin(len(str1), len(str2))down to 1. - For each length, extract
t = str1[:candidate_len]. - Check if
t * (len(str1) // candidate_len) == str1andt * (len(str2) // candidate_len) == str2and both lengths are divisible. - Return the first (longest)
tthat passes. - Return
""if none found.
Solution
def gcd_of_strings_brute(str1, str2):
min_len = min(len(str1), len(str2))
for length in range(min_len, 0, -1):
if len(str1) % length == 0 and len(str2) % length == 0:
t = str1[:length]
if t * (len(str1) // length) == str1 and t * (len(str2) // length) == str2:
return t
return ""
print(gcd_of_strings_brute("ABCABC", "ABC")) # ABC
print(gcd_of_strings_brute("ABABAB", "ABAB")) # AB
print(gcd_of_strings_brute("LEET", "CODE")) # ""
print(gcd_of_strings_brute("AAAAAA", "AAA")) # AAA
Complexity
- Time:
O(min(m,n) * (m + n))— up to min(m,n) candidates, each check takes O(m + n) - Space:
O(m + n)— string multiplication creates temporary copies
2. GCD on Lengths
Intuition
There is a beautiful mathematical shortcut here. A GCD string exists if and only if str1 + str2 == str2 + str1. Why? If both strings are built from the same repeating unit, concatenating them in either order gives the same result. If they are not built from the same unit, the concatenations will differ.
Once we know a GCD string exists, its length must be gcd(len(str1), len(str2)) — by the same logic as Euclid’s algorithm applied to string lengths. So the answer is simply str1[:gcd(len(str1), len(str2))].
Algorithm
- If
str1 + str2 != str2 + str1, return""— no common divisor exists. - Compute
g = gcd(len(str1), len(str2)). - Return
str1[:g].
Solution
from math import gcd
def gcd_of_strings(str1, str2):
# Key insight: if a GCD string exists, concatenations must match
if str1 + str2 != str2 + str1:
return ""
g = gcd(len(str1), len(str2))
return str1[:g]
print(gcd_of_strings("ABCABC", "ABC")) # ABC
print(gcd_of_strings("ABABAB", "ABAB")) # AB
print(gcd_of_strings("LEET", "CODE")) # ""
print(gcd_of_strings("AAAAAA", "AAA")) # AAA
print(gcd_of_strings("A", "A")) # A
Complexity
- Time:
O(m + n)— one concatenation check plus Euclid’s algorithm (O(log(min(m,n)))) - Space:
O(m + n)— for the two concatenated strings
Common Pitfalls
Checking only whether lengths share a GCD without validating the content. The strings “ABCD” and “AB” have gcd(4, 2) = 2, but “AB” does not divide “ABCD” (since “ABAB” ≠ “ABCD”). Always do the concatenation check first.
Skipping the concatenation check and only checking divisibility. “AABAA” and “AA” have lengths with gcd 1, but even gcd-checking the lengths is not sufficient without verifying structure. The str1 + str2 == str2 + str1 check is the correct and complete filter.
Confusing str1[:gcd_len] with str2[:gcd_len]. Both will give the same result if a GCD string exists (that is exactly what the concatenation check guarantees), but using str1 is conventional.
Insert Greatest Common Divisors in Linked List
Difficulty: Medium Source: NeetCode
Problem
Given the head of a linked list, insert a new node between every pair of adjacent nodes whose value is the GCD (greatest common divisor) of both adjacent nodes. Return the linked list after insertion.
Example 1: Input:
head = [18,6,10,3]Output:[18,6,6,2,10,1,3]Example 2: Input:
head = [7]Output:[7]Constraints:
- The number of nodes in the list is in the range
[1, 5000].1 <= Node.val <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Linked lists — traversing and inserting nodes between existing nodes
- GCD (Greatest Common Divisor) — Euclid’s algorithm to find the GCD of two integers
- In-place list manipulation — updating
nextpointers without losing references
1. Brute Force (Convert to Array)
Intuition
Extract all node values into a list, build the result list with GCD values inserted, then reconstruct the linked list from the result list. It uses extra memory but keeps the logic simple and avoids tricky pointer manipulation.
Algorithm
- Traverse the list and collect all values into
vals. - Build
result_valsby interleaving each adjacent pair withgcd(a, b). - Construct a new linked list from
result_valsand return its head.
Solution
from math import gcd
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def insert_gcd_brute(head):
# Collect all values
vals = []
cur = head
while cur:
vals.append(cur.val)
cur = cur.next
if len(vals) == 1:
return head
# Build result list with GCDs inserted
result_vals = [vals[0]]
for i in range(1, len(vals)):
result_vals.append(gcd(vals[i-1], vals[i]))
result_vals.append(vals[i])
# Reconstruct linked list
dummy = ListNode(0)
cur = dummy
for v in result_vals:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
def to_list(head):
result = []
while head:
result.append(head.val)
head = head.next
return result
def build_list(vals):
dummy = ListNode(0)
cur = dummy
for v in vals:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
head = build_list([18, 6, 10, 3])
print(to_list(insert_gcd_brute(head))) # [18, 6, 6, 2, 10, 1, 3]
head = build_list([7])
print(to_list(insert_gcd_brute(head))) # [7]
Complexity
- Time:
O(n * log(max_val))— n GCD computations, each O(log max_val) - Space:
O(n)— value list and new nodes
2. In-Place Pointer Manipulation
Intuition
Walk through the list with a pointer. For every adjacent pair (current, current.next), create a new GCD node and splice it in between them. Then advance the pointer by two steps to skip over the freshly inserted node and land on the original next node. This avoids any extra arrays.
Algorithm
- Start with
cur = head. - While
curandcur.nextare both non-null:- Compute
g = gcd(cur.val, cur.next.val). - Create
new_node = ListNode(g). - Set
new_node.next = cur.next. - Set
cur.next = new_node. - Advance
cur = new_node.next(skip over the inserted node).
- Compute
- Return
head.
Solution
from math import gcd
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def insert_gcd(head):
cur = head
while cur and cur.next:
g = gcd(cur.val, cur.next.val)
new_node = ListNode(g)
new_node.next = cur.next # new node points to original next
cur.next = new_node # current points to new node
cur = new_node.next # advance past the inserted node
return head
def to_list(head):
result = []
while head:
result.append(head.val)
head = head.next
return result
def build_list(vals):
dummy = ListNode(0)
cur = dummy
for v in vals:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
head = build_list([18, 6, 10, 3])
print(to_list(insert_gcd(head))) # [18, 6, 6, 2, 10, 1, 3]
head = build_list([7])
print(to_list(insert_gcd(head))) # [7]
head = build_list([2, 4])
print(to_list(insert_gcd(head))) # [2, 2, 4]
Complexity
- Time:
O(n * log(max_val))— one GCD per adjacent pair, Euclid’s runs in O(log max_val) - Space:
O(1)— only new GCD nodes are created (required by the problem), no extra auxiliary storage
Common Pitfalls
Advancing cur by one instead of two. After inserting a GCD node, cur.next is the new node. Advancing with cur = cur.next would process the new node again on the next iteration. Skip past it with cur = new_node.next or equivalently cur = cur.next.next (after insertion).
Losing the reference to cur.next before inserting. Always save cur.next before modifying pointers, or set new_node.next = cur.next first (before cur.next = new_node) to avoid a dangling reference.
Applying GCD to adjacent inserted nodes. The problem inserts only one GCD node between each original pair. After inserting, skip directly to the next original node — do not accidentally compute GCD between an original node and a newly inserted GCD node.
Transpose Matrix
Difficulty: Easy Source: NeetCode
Problem
Given a 2D integer array
matrix, return the transpose ofmatrix. The transpose of a matrix is the matrix flipped over its main diagonal, switching the matrix’s row and column indices.Example 1: Input:
matrix = [[1,2,3],[4,5,6],[7,8,9]]Output:[[1,4,7],[2,5,8],[3,6,9]]Example 2: Input:
matrix = [[1,2,3],[4,5,6]]Output:[[1,4],[2,5],[3,6]]Constraints:
m == matrix.lengthn == matrix[i].length1 <= m, n <= 10001 <= m * n <= 10^5-10^9 <= matrix[i][j] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- 2D arrays — indexing rows and columns, understanding that
matrix[i][j]refers to rowi, columnj - Nested loops — iterating over every cell of a matrix
1. Brute Force (New Matrix)
Intuition
Build a brand-new matrix where the dimensions are swapped and each element result[j][i] comes from matrix[i][j]. This is the most straightforward interpretation of the transpose definition.
Algorithm
- Let
m = len(matrix)andn = len(matrix[0]). - Create a new matrix of shape
n × mfilled with zeros. - For each
iin0..m-1andjin0..n-1: setresult[j][i] = matrix[i][j]. - Return
result.
Solution
def transpose_brute(matrix):
m = len(matrix)
n = len(matrix[0])
result = [[0] * m for _ in range(n)] # shape is n x m
for i in range(m):
for j in range(n):
result[j][i] = matrix[i][j]
return result
print(transpose_brute([[1,2,3],[4,5,6],[7,8,9]])) # [[1,4,7],[2,5,8],[3,6,9]]
print(transpose_brute([[1,2,3],[4,5,6]])) # [[1,4],[2,5],[3,6]]
print(transpose_brute([[1]])) # [[1]]
Complexity
- Time:
O(m * n)— visit every element once - Space:
O(m * n)— new matrix
2. Python One-Liner with zip
Intuition
Python’s built-in zip(*matrix) is exactly the transpose operation: zip groups the first elements of each row together, the second elements together, and so on — which is precisely swapping rows and columns. Each group becomes a row in the transposed matrix.
Algorithm
- Unpack
matrixinto rows and pass them all tozip:zip(*matrix). zipgroups column-wise across rows, producing tuples.- Convert each tuple to a list and wrap the result.
Solution
def transpose(matrix):
return [list(row) for row in zip(*matrix)]
print(transpose([[1,2,3],[4,5,6],[7,8,9]])) # [[1,4,7],[2,5,8],[3,6,9]]
print(transpose([[1,2,3],[4,5,6]])) # [[1,4],[2,5],[3,6]]
print(transpose([[1]])) # [[1]]
print(transpose([[1,2],[3,4],[5,6]])) # [[1,3,5],[2,4,6]]
Complexity
- Time:
O(m * n)— every element is visited by zip - Space:
O(m * n)— new transposed matrix
Common Pitfalls
Transposing in place for a non-square matrix. In-place transposition only works for square matrices. For rectangular matrices (m ≠ n) you must allocate a new matrix because the dimensions change.
Confusing rows and columns in the index swap. The transpose rule is result[j][i] = matrix[i][j] — note the swap. Writing result[i][j] = matrix[j][i] is the same thing, but initializing result with the wrong dimensions (m × n instead of n × m) will cause an index error.
Using zip and forgetting to convert tuples to lists. zip returns tuples, so if the problem expects lists of lists, wrap with list(row) or use a list comprehension.
Rotate Image
Difficulty: Medium Source: NeetCode
Problem
You are given an
n x n2D matrix representing an image. Rotate the image by 90 degrees clockwise in-place.You must rotate the matrix in-place, which means you have to modify the input 2D matrix directly. Do not allocate another 2D matrix.
Example 1: Input:
matrix = [[1,2,3],[4,5,6],[7,8,9]]Output:[[7,4,1],[8,5,2],[9,6,3]]Example 2: Input:
matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]Output:[[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]Constraints:
n == matrix.length == matrix[i].length1 <= n <= 20-1000 <= matrix[i][j] <= 1000
Prerequisites
Before attempting this problem, you should be comfortable with:
- Matrix transposition — swapping
matrix[i][j]andmatrix[j][i] - In-place array manipulation — reversing rows without extra space
- Index arithmetic — mapping original coordinates to rotated coordinates
1. Brute Force (Extra Matrix)
Intuition
For a 90-degree clockwise rotation, the element at position (i, j) moves to (j, n-1-i). Build a new matrix using this mapping, then copy the result back into the original. This uses O(n²) extra space, which the problem disallows, but it is a clean way to verify the rotation formula.
Algorithm
- Create a copy
tempof the matrix. - For each
(i, j): setmatrix[j][n-1-i] = temp[i][j]. - The matrix is now rotated in place.
Solution
def rotate_brute(matrix):
n = len(matrix)
temp = [row[:] for row in matrix] # deep copy
for i in range(n):
for j in range(n):
matrix[j][n - 1 - i] = temp[i][j]
matrix = [[1,2,3],[4,5,6],[7,8,9]]
rotate_brute(matrix)
print(matrix) # [[7,4,1],[8,5,2],[9,6,3]]
matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
rotate_brute(matrix)
print(matrix) # [[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]
Complexity
- Time:
O(n²) - Space:
O(n²)— extra copy
2. Transpose then Reverse Rows
Intuition
A 90-degree clockwise rotation can be decomposed into two simpler in-place steps that you can derive visually:
- Transpose the matrix (swap
matrix[i][j]withmatrix[j][i]for all i < j). This flips the matrix across its main diagonal. - Reverse each row. This flips each row horizontally.
Both steps are in-place and O(n²) together. The combination achieves exactly a 90-degree clockwise rotation.
Algorithm
- Transpose: For
iin0..n-1andjini+1..n-1, swapmatrix[i][j]andmatrix[j][i]. - Reverse each row: For each row in
matrix, reverse it in place.
Solution
def rotate(matrix):
n = len(matrix)
# Step 1: Transpose (swap across main diagonal)
for i in range(n):
for j in range(i + 1, n):
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
# Step 2: Reverse each row
for row in matrix:
row.reverse()
matrix = [[1,2,3],[4,5,6],[7,8,9]]
rotate(matrix)
print(matrix) # [[7,4,1],[8,5,2],[9,6,3]]
matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
rotate(matrix)
print(matrix) # [[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]
matrix = [[1]]
rotate(matrix)
print(matrix) # [[1]]
Complexity
- Time:
O(n²)— transpose is O(n²/2), reversing rows is O(n²/2) - Space:
O(1)— all operations done in place
3. Four-Way Cycle Swap (Layer by Layer)
Intuition
Process the matrix in concentric square layers. For each layer, rotate four elements at a time in a cycle: top → right → bottom → left → top. Only a single temporary variable is needed. This is slightly trickier to code but avoids the two-step mental model.
Algorithm
- For each layer
lfrom0ton//2 - 1:- For each position
ifromlton - 2 - l:- Save
top = matrix[l][i]. matrix[l][i] = matrix[n-1-i][l](left → top).matrix[n-1-i][l] = matrix[n-1-l][n-1-i](bottom → left).matrix[n-1-l][n-1-i] = matrix[i][n-1-l](right → bottom).matrix[i][n-1-l] = top(saved top → right).
- Save
- For each position
Solution
def rotate_cycle(matrix):
n = len(matrix)
for l in range(n // 2):
r = n - 1 - l
for i in range(l, r):
top = matrix[l][i]
# left → top
matrix[l][i] = matrix[n-1-i][l]
# bottom → left
matrix[n-1-i][l] = matrix[r][n-1-i]
# right → bottom
matrix[r][n-1-i] = matrix[i][r]
# saved top → right
matrix[i][r] = top
matrix = [[1,2,3],[4,5,6],[7,8,9]]
rotate_cycle(matrix)
print(matrix) # [[7,4,1],[8,5,2],[9,6,3]]
Complexity
- Time:
O(n²) - Space:
O(1)
Common Pitfalls
Transposing with the wrong loop bounds. When transposing, only swap pairs where j > i. If you loop j from 0 to n, you swap every pair twice, undoing the transpose and returning the original matrix.
Confusing clockwise and counterclockwise. Clockwise: transpose then reverse rows. Counterclockwise: transpose then reverse columns (or equivalently, reverse rows first then transpose).
Forgetting that the 4-way cycle needs a saved top. In the cycle approach, naively overwriting positions in order loses data. Always save one value first before the chain of assignments.
Spiral Matrix
Difficulty: Medium Source: NeetCode
Problem
Given an
m x nmatrix, return all elements of the matrix in spiral order (clockwise, starting from the top-left).Example 1: Input:
matrix = [[1,2,3],[4,5,6],[7,8,9]]Output:[1,2,3,6,9,8,7,4,5]Example 2: Input:
matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]Output:[1,2,3,4,8,12,11,10,9,5,6,7]Constraints:
m == matrix.lengthn == matrix[i].length1 <= m, n <= 10-100 <= matrix[i][j] <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- 2D arrays — indexed access by row and column
- Boundary tracking — maintaining top, bottom, left, right pointers that shrink after each pass
- Simulation — mimicking the spiral path step by step
1. Brute Force (Direction Array with Visited Set)
Intuition
Simulate the spiral by walking in the current direction until hitting a wall or a visited cell, then turn right. Track visited cells with a set. This is intuitive but uses extra memory for the visited set.
Algorithm
- Directions: right, down, left, up as
(dr, dc)pairs. - Walk cell by cell, adding each to the result.
- Before each step, check if the next cell is in-bounds and unvisited. If not, turn right (rotate direction index).
- Stop when all
m * ncells are collected.
Solution
def spiral_order_brute(matrix):
m, n = len(matrix), len(matrix[0])
visited = set()
result = []
directions = [(0, 1), (1, 0), (0, -1), (-1, 0)] # right, down, left, up
d = 0
r, c = 0, 0
for _ in range(m * n):
result.append(matrix[r][c])
visited.add((r, c))
nr, nc = r + directions[d][0], c + directions[d][1]
if 0 <= nr < m and 0 <= nc < n and (nr, nc) not in visited:
r, c = nr, nc
else:
d = (d + 1) % 4
r, c = r + directions[d][0], c + directions[d][1]
return result
print(spiral_order_brute([[1,2,3],[4,5,6],[7,8,9]])) # [1,2,3,6,9,8,7,4,5]
print(spiral_order_brute([[1,2,3,4],[5,6,7,8],[9,10,11,12]])) # [1,2,3,4,8,12,11,10,9,5,6,7]
Complexity
- Time:
O(m * n) - Space:
O(m * n)— visited set
2. Layer Peeling (Shrinking Boundaries)
Intuition
Think of the matrix as a series of concentric rectangular layers. Peel one layer at a time: traverse right across the top, down the right side, left across the bottom, and up the left side. After completing each layer, shrink all four boundaries inward by one. This avoids any extra memory.
Algorithm
- Initialize
top = 0,bottom = m-1,left = 0,right = n-1. - While
top <= bottomandleft <= right:- Right: add
matrix[top][c]forcinleft..right. Thentop += 1. - Down: add
matrix[r][right]forrintop..bottom. Thenright -= 1. - Left: if
top <= bottom, addmatrix[bottom][c]forcinright..left(reversed). Thenbottom -= 1. - Up: if
left <= right, addmatrix[r][left]forrinbottom..top(reversed). Thenleft += 1.
- Right: add
- Return
result.
Solution
def spiral_order(matrix):
m, n = len(matrix), len(matrix[0])
top, bottom = 0, m - 1
left, right = 0, n - 1
result = []
while top <= bottom and left <= right:
# Traverse right across the top row
for c in range(left, right + 1):
result.append(matrix[top][c])
top += 1
# Traverse down the right column
for r in range(top, bottom + 1):
result.append(matrix[r][right])
right -= 1
# Traverse left across the bottom row (if still a valid row)
if top <= bottom:
for c in range(right, left - 1, -1):
result.append(matrix[bottom][c])
bottom -= 1
# Traverse up the left column (if still a valid column)
if left <= right:
for r in range(bottom, top - 1, -1):
result.append(matrix[r][left])
left += 1
return result
print(spiral_order([[1,2,3],[4,5,6],[7,8,9]])) # [1,2,3,6,9,8,7,4,5]
print(spiral_order([[1,2,3,4],[5,6,7,8],[9,10,11,12]])) # [1,2,3,4,8,12,11,10,9,5,6,7]
print(spiral_order([[1]])) # [1]
print(spiral_order([[1,2,3]])) # [1,2,3]
print(spiral_order([[1],[2],[3]])) # [1,2,3]
Complexity
- Time:
O(m * n)— every element is visited exactly once - Space:
O(1)— only four boundary variables (not counting the output list)
Common Pitfalls
Traversing left and up without checking whether the row/column still exists. After incrementing top and decrementing right, the bottom row and left column traversals might be redundant for a single-row or single-column matrix. Always guard with if top <= bottom and if left <= right before those traversals.
Off-by-one in loop ranges. The right traversal goes from left to right inclusive, so use range(left, right + 1). The up traversal goes from bottom to top inclusive in reverse, so use range(bottom, top - 1, -1).
Confusing which boundary to shrink after each side. Mnemonic: after going right, shrink the top (move it down). After going down, shrink the right. After going left, shrink the bottom. After going up, shrink the left.
Set Matrix Zeroes
Difficulty: Medium Source: NeetCode
Problem
Given an
m x ninteger matrix, if an element is0, set its entire row and column to0. Do this in-place.Example 1: Input:
matrix = [[1,1,1],[1,0,1],[1,1,1]]Output:[[1,0,1],[0,0,0],[1,0,1]]Example 2: Input:
matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]Output:[[0,0,0,0],[0,4,5,0],[0,3,1,0]]Constraints:
m == matrix.lengthn == matrix[i].length1 <= m, n <= 200-2^31 <= matrix[i][j] <= 2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- 2D arrays — iterating rows and columns, modifying cells in place
- Two-pass algorithms — using a first pass to mark, a second pass to apply
- Using the matrix itself as storage — the O(1) space trick of repurposing existing cells as markers
1. Brute Force (Extra Sets)
Intuition
First collect all rows and columns that contain at least one zero. Then zero out all cells in those rows and columns. Using sets ensures we do not accidentally zero out a row or column because of a zero we created ourselves during the process.
Algorithm
- Scan the entire matrix and collect
zero_rowsandzero_cols(sets of indices). - For every cell
(i, j): ifiis inzero_rowsorjis inzero_cols, setmatrix[i][j] = 0.
Solution
def set_zeroes_brute(matrix):
m, n = len(matrix), len(matrix[0])
zero_rows = set()
zero_cols = set()
for i in range(m):
for j in range(n):
if matrix[i][j] == 0:
zero_rows.add(i)
zero_cols.add(j)
for i in range(m):
for j in range(n):
if i in zero_rows or j in zero_cols:
matrix[i][j] = 0
matrix = [[1,1,1],[1,0,1],[1,1,1]]
set_zeroes_brute(matrix)
print(matrix) # [[1,0,1],[0,0,0],[1,0,1]]
matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]
set_zeroes_brute(matrix)
print(matrix) # [[0,0,0,0],[0,4,5,0],[0,3,1,0]]
Complexity
- Time:
O(m * n) - Space:
O(m + n)— the two sets
2. Optimal — Use First Row and Column as Markers (O(1) Space)
Intuition
Instead of separate sets, use the first row and first column of the matrix itself to record which rows and columns should be zeroed. Before doing so, note separately whether the first row itself originally contained a zero (and similarly for the first column), since we will be using those cells as markers.
- Check if row 0 has any zero (store in
first_row_zero). - Check if col 0 has any zero (store in
first_col_zero). - For cells
(i, j)withi >= 1andj >= 1: ifmatrix[i][j] == 0, markmatrix[i][0] = 0andmatrix[0][j] = 0. - Use those markers to zero out the interior cells.
- Zero out the first row and first column based on the booleans from steps 1-2.
Algorithm
first_row_zero = any(matrix[0][j] == 0 for j in range(n)).first_col_zero = any(matrix[i][0] == 0 for i in range(m)).- For
iin1..m-1andjin1..n-1: ifmatrix[i][j] == 0, setmatrix[i][0] = 0andmatrix[0][j] = 0. - For
iin1..m-1andjin1..n-1: ifmatrix[i][0] == 0ormatrix[0][j] == 0, setmatrix[i][j] = 0. - If
first_row_zero: setmatrix[0][j] = 0for allj. - If
first_col_zero: setmatrix[i][0] = 0for alli.
Solution
def set_zeroes(matrix):
m, n = len(matrix), len(matrix[0])
# Record whether the first row / column originally had zeros
first_row_zero = any(matrix[0][j] == 0 for j in range(n))
first_col_zero = any(matrix[i][0] == 0 for i in range(m))
# Use first row and column as markers for the rest of the matrix
for i in range(1, m):
for j in range(1, n):
if matrix[i][j] == 0:
matrix[i][0] = 0
matrix[0][j] = 0
# Zero out interior cells based on markers
for i in range(1, m):
for j in range(1, n):
if matrix[i][0] == 0 or matrix[0][j] == 0:
matrix[i][j] = 0
# Handle the first row and column themselves
if first_row_zero:
for j in range(n):
matrix[0][j] = 0
if first_col_zero:
for i in range(m):
matrix[i][0] = 0
matrix = [[1,1,1],[1,0,1],[1,1,1]]
set_zeroes(matrix)
print(matrix) # [[1,0,1],[0,0,0],[1,0,1]]
matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]
set_zeroes(matrix)
print(matrix) # [[0,0,0,0],[0,4,5,0],[0,3,1,0]]
matrix = [[1,0,3]]
set_zeroes(matrix)
print(matrix) # [[0,0,0]]
Complexity
- Time:
O(m * n)— three passes over the matrix - Space:
O(1)— only two boolean variables beyond the matrix itself
Common Pitfalls
Zeroing cells as you scan, not after. If you zero out a row or column immediately when you find a zero, later cells in the same scan will see your newly created zeros as “original” zeros, causing a cascade that zeros the entire matrix. Always separate the marking phase from the zeroing phase.
Forgetting to handle the first row and column separately. The O(1) trick repurposes matrix[i][0] and matrix[0][j] as markers, but this overwrites the original values. Always check the first row and column before marking, and restore them last.
Checking matrix[0][0] for both the first row and column. The cell matrix[0][0] is the intersection of the first row marker and the first column marker. Use two separate booleans (first_row_zero, first_col_zero) rather than trying to use matrix[0][0] for both, to avoid ambiguity.
Happy Number
Difficulty: Easy Source: NeetCode
Problem
Write an algorithm to determine if a number
nis happy.A happy number is a number defined by the following process:
- Starting with any positive integer, replace the number by the sum of the squares of its digits.
- Repeat the process until the number equals 1 (where it will stay), or it loops endlessly in a cycle which does not include 1.
- Numbers for which this process ends in 1 are happy.
Return
trueifnis a happy number, andfalseif not.Example 1: Input:
n = 19Output:trueExample 2: Input:
n = 2Output:falseConstraints:
1 <= n <= 2^31 - 1
Prerequisites
Before attempting this problem, you should be comfortable with:
- Digit extraction — using
% 10and// 10to pull individual digits from an integer - Cycle detection — recognising that a non-happy number will enter an infinite loop and knowing how to detect it
- Floyd’s cycle detection — the classic slow/fast pointer trick applied to a sequence
1. Hash Set (Detect Cycle)
Intuition
Keep applying the “sum of digit squares” operation and record each number we visit in a set. If we reach 1, the number is happy. If we reach a number we have seen before, we are in a cycle and the number is not happy. It is a mathematical fact that all non-happy numbers eventually cycle through the number 4, but the set approach works for any cycle.
Algorithm
- Initialize
seen = set(). - While
n != 1andn not in seen:- Add
ntoseen. - Replace
nwith the sum of squares of its digits.
- Add
- Return
n == 1.
Solution
def is_happy_set(n):
def digit_square_sum(x):
total = 0
while x > 0:
digit = x % 10
total += digit * digit
x //= 10
return total
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = digit_square_sum(n)
return n == 1
print(is_happy_set(19)) # True (19→82→68→100→1)
print(is_happy_set(2)) # False (enters cycle)
print(is_happy_set(1)) # True
print(is_happy_set(7)) # True
Complexity
- Time:
O(log n)per iteration, bounded number of iterations before cycle - Space:
O(log n)— set of visited numbers (bounded since the values stay small after the first step)
2. Floyd’s Cycle Detection (Slow / Fast Pointers)
Intuition
Treat the sequence of numbers produced by the digit-square-sum operation as a linked list. If there is a cycle, a fast pointer moving two steps at a time will eventually lap a slow pointer moving one step at a time. If the sequence is happy, both pointers will eventually reach 1. This approach uses O(1) extra space — no hash set needed.
Algorithm
- Initialize
slow = nandfast = digit_square_sum(n). - While
fast != 1andslow != fast:- Move
slowone step:slow = digit_square_sum(slow). - Move
fasttwo steps:fast = digit_square_sum(digit_square_sum(fast)).
- Move
- Return
fast == 1.
Solution
def is_happy(n):
def digit_square_sum(x):
total = 0
while x > 0:
digit = x % 10
total += digit * digit
x //= 10
return total
slow = n
fast = digit_square_sum(n)
# Fast pointer runs two steps, slow runs one; they meet if there is a cycle
while fast != 1 and slow != fast:
slow = digit_square_sum(slow)
fast = digit_square_sum(digit_square_sum(fast))
return fast == 1
print(is_happy(19)) # True
print(is_happy(2)) # False
print(is_happy(1)) # True
print(is_happy(7)) # True
print(is_happy(4)) # False
Complexity
- Time:
O(log n)— bounded iterations; the digit-square-sum reduces large numbers quickly - Space:
O(1)— only two pointers
Common Pitfalls
Infinite loop without cycle detection. Simply looping until n == 1 without tracking visited numbers or using two pointers will loop forever for unhappy numbers.
Extracting digits via string conversion. sum(int(d)**2 for d in str(n)) works correctly in Python and is concise, but the arithmetic approach (% 10 and // 10) is more portable and avoids string allocation.
Starting fast at n instead of one step ahead. If both slow and fast start at n, the loop condition slow != fast is already false before the first iteration. Initialize fast = digit_square_sum(n) (one step ahead) so the pointers start at different positions.
Plus One
Difficulty: Easy Source: NeetCode
Problem
You are given a large integer represented as an integer array
digits, where eachdigits[i]is the i-th digit of the integer. The digits are ordered from most significant to least significant in left-to-right order. The large integer does not contain any leading zeros.Increment the large integer by one and return the resulting array of digits.
Example 1: Input:
digits = [1,2,3]Output:[1,2,4]Example 2: Input:
digits = [4,3,2,1]Output:[4,3,2,2]Example 3: Input:
digits = [9]Output:[1,0]Constraints:
1 <= digits.length <= 1000 <= digits[i] <= 9digitsdoes not contain any leading zeros.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Array traversal from the right — processing the least significant digit first when simulating arithmetic
- Carry propagation — understanding how addition carries ripple left through digits
1. Brute Force (Convert to Integer)
Intuition
Convert the digit array to a Python integer, add one, then convert back to a digit array. Python supports arbitrarily large integers natively, so this always works. It is not the intended simulation approach, but it proves the answer.
Algorithm
- Convert
digitsto an integernusingint(''.join(map(str, digits))). - Compute
n + 1. - Return
[int(d) for d in str(n + 1)].
Solution
def plus_one_brute(digits):
n = int(''.join(map(str, digits)))
return [int(d) for d in str(n + 1)]
print(plus_one_brute([1,2,3])) # [1,2,4]
print(plus_one_brute([9])) # [1,0]
print(plus_one_brute([1,2,9])) # [1,3,0]
print(plus_one_brute([9,9,9])) # [1,0,0,0]
Complexity
- Time:
O(n) - Space:
O(n)— string conversions
2. Carry Simulation (Right to Left)
Intuition
Start from the rightmost digit and try to add one. If the digit is 0–8, just increment it and we are done — no carry. If the digit is 9, it becomes 0 and we carry the 1 to the next digit to the left. Repeat until the carry stops or we run out of digits. If all digits were 9 (e.g., [9,9,9]), they all become 0 and we need to prepend a 1 to get [1,0,0,0].
Algorithm
- Iterate from
i = len(digits) - 1down to 0:- If
digits[i] < 9: increment it and returndigitsimmediately (no carry propagates). - Otherwise: set
digits[i] = 0and continue left (carry).
- If
- If the loop finishes (all nines), prepend 1: return
[1] + digits.
Solution
def plus_one(digits):
for i in range(len(digits) - 1, -1, -1):
if digits[i] < 9:
digits[i] += 1
return digits
digits[i] = 0 # was 9, becomes 0, carry propagates left
# All digits were 9 — we need an extra digit at the front
return [1] + digits
print(plus_one([1,2,3])) # [1,2,4]
print(plus_one([4,3,2,1])) # [4,3,2,2]
print(plus_one([9])) # [1,0]
print(plus_one([1,2,9])) # [1,3,0]
print(plus_one([9,9,9])) # [1,0,0,0]
print(plus_one([0])) # [1]
Complexity
- Time:
O(n)— at most one full pass through all digits - Space:
O(1)in the normal case;O(n)when a new leading digit is prepended
Common Pitfalls
Modifying digits while using index-based iteration incorrectly. Be careful if you use a while loop and manually advance the index — it is easy to skip a digit. The for i in range(...) approach in reverse is cleaner.
Not handling the all-nines case. If you return after the loop without prepending a 1, arrays like [9,9,9] silently return [0,0,0] instead of [1,0,0,0]. Always have the return [1] + digits fallback after the loop.
Doing unnecessary extra work. There is no need to compute carry as a separate variable and then loop again. As soon as you see a digit less than 9, increment it and return — the rest of the array is unchanged.
Roman to Integer
Difficulty: Easy Source: NeetCode
Problem
Roman numerals are represented by seven different symbols:
I,V,X,L,C,D, andM.
Symbol Value I 1 V 5 X 10 L 50 C 100 D 500 M 1000 Roman numerals are usually written largest to smallest from left to right. However, when a smaller value appears before a larger value, it is subtracted. For example,
IV= 4 andIX= 9.Given a roman numeral string
s, convert it to an integer.Example 1: Input:
s = "III"Output:3Example 2: Input:
s = "LVIII"Output:58Example 3: Input:
s = "MCMXCIV"Output:1994Constraints:
1 <= s.length <= 15scontains only the characters('I', 'V', 'X', 'L', 'C', 'D', 'M')- It is guaranteed that
sis a valid roman numeral in the range[1, 3999]
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash maps — storing symbol-to-value mappings for O(1) lookup
- String traversal — iterating through characters with index access
- Subtraction rule — recognising that a smaller value before a larger one means subtract, not add
1. Brute Force (Replace Substrings)
Intuition
Replace all two-character subtractive combinations (like IV, IX, XL, etc.) with their integer equivalents before processing the remaining single characters. This sidesteps the look-ahead logic entirely.
Algorithm
- Define a map of two-character special cases and their values.
- For each special case, replace occurrences in
swith a placeholder or just compute its contribution. - Then process remaining single characters.
Solution
def roman_to_int_brute(s):
special = {"IV": 4, "IX": 9, "XL": 40, "XC": 90, "CD": 400, "CM": 900}
single = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
result = 0
i = 0
while i < len(s):
if i + 1 < len(s) and s[i:i+2] in special:
result += special[s[i:i+2]]
i += 2
else:
result += single[s[i]]
i += 1
return result
print(roman_to_int_brute("III")) # 3
print(roman_to_int_brute("LVIII")) # 58
print(roman_to_int_brute("MCMXCIV")) # 1994
print(roman_to_int_brute("IV")) # 4
print(roman_to_int_brute("IX")) # 9
Complexity
- Time:
O(n) - Space:
O(1)— constant-size maps
2. Single-Pass with Look-Ahead (Subtract Rule)
Intuition
Walk through the string once. For each character, look at the next character. If the current symbol’s value is less than the next one’s value, it is a subtractive case — subtract the current value. Otherwise, add it. This cleanly handles all six subtractive pairs (IV, IX, XL, XC, CD, CM) without a separate lookup table for them.
Algorithm
- Build
val = {'I':1, 'V':5, 'X':10, 'L':50, 'C':100, 'D':500, 'M':1000}. - Initialize
result = 0. - For each index
iin0..len(s)-1:- If
i + 1 < len(s)andval[s[i]] < val[s[i+1]]: subtractval[s[i]]. - Else: add
val[s[i]].
- If
- Return
result.
Solution
def roman_to_int(s):
val = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
result = 0
for i in range(len(s)):
# If a smaller value appears before a larger one, subtract it
if i + 1 < len(s) and val[s[i]] < val[s[i + 1]]:
result -= val[s[i]]
else:
result += val[s[i]]
return result
print(roman_to_int("III")) # 3
print(roman_to_int("LVIII")) # 58
print(roman_to_int("MCMXCIV")) # 1994
print(roman_to_int("IV")) # 4
print(roman_to_int("XLII")) # 42
print(roman_to_int("M")) # 1000
Complexity
- Time:
O(n)— one pass through the string - Space:
O(1)— fixed-size map of 7 entries
Common Pitfalls
Not handling the last character separately. The last character never has a “next” character, so the subtraction rule never applies to it — it is always added. The condition i + 1 < len(s) handles this correctly; without it you get an index-out-of-bounds error.
Subtracting then adding instead of just subtracting. Some implementations add every character’s value first, then subtract twice for subtractive pairs. That works too (e.g., for IV: add I=1, add V=5, subtract 2*I=2 → result 4), but the look-ahead approach is simpler and less error-prone.
Forgetting that XC is 90, not 10 + 100 = 110. Whenever a smaller numeral precedes a larger one, it means subtract. Always apply the subtraction rule; never assume left-to-right addition for every pair.
Pow(x, n)
Difficulty: Medium Source: NeetCode
Problem
Implement
pow(x, n), which calculatesxraised to the powern(i.e.,x^n).Example 1: Input:
x = 2.00000,n = 10Output:1024.00000Example 2: Input:
x = 2.10000,n = 3Output:9.26100Example 3: Input:
x = 2.00000,n = -2Output:0.25000Constraints:
-100.0 < x < 100.0-2^31 <= n <= 2^31 - 1nis an integer- Either
xis not zero orn > 0-10^4 <= x^n <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion — breaking a problem into a smaller subproblem of the same type
- Bit manipulation — checking whether n is odd using
n & 1(optional but common) - Negative exponents —
x^(-n) = 1 / x^n
1. Brute Force (Linear Multiplication)
Intuition
Multiply x by itself n times. Handle negative n by inverting x first. This is O(n) which is far too slow for n up to 2^31, but it is the most literal implementation of “raise to a power”.
Algorithm
- If
n < 0, setx = 1/xandn = -n. - Initialize
result = 1.0. - Multiply
result *= xexactlyntimes. - Return
result.
Solution
def my_pow_brute(x, n):
if n < 0:
x = 1 / x
n = -n
result = 1.0
for _ in range(n):
result *= x
return result
print(my_pow_brute(2.0, 10)) # 1024.0
print(my_pow_brute(2.1, 3)) # ~9.261
print(my_pow_brute(2.0, -2)) # 0.25
print(my_pow_brute(2.0, 0)) # 1.0
# Note: will be very slow for large n — only for demonstration
Complexity
- Time:
O(n)— too slow for n up to 2^31 - Space:
O(1)
2. Fast Exponentiation (Binary Exponentiation)
Intuition
The key observation: x^n = (x^(n/2))^2 when n is even, and x^n = x * (x^(n/2))^2 when n is odd. Each step halves the exponent, so we need only O(log n) multiplications. For negative n, compute x^|n| then take the reciprocal.
Example: 2^10 = (2^5)^2 = (2 * (2^2)^2)^2 = ... — the exponent halves at each level.
Iterative approach: At each step, if the current exponent is odd, multiply the running result by the current base. Then square the base and halve the exponent. Repeat until the exponent is 0.
Algorithm
- If
n < 0: setx = 1/x,n = -n. - Initialize
result = 1.0. - While
n > 0:- If
nis odd (n % 2 == 1): multiplyresult *= x. - Square
x:x *= x. - Halve
n:n //= 2.
- If
- Return
result.
Solution
def my_pow(x, n):
if n < 0:
x = 1 / x
n = -n
result = 1.0
while n > 0:
if n % 2 == 1: # odd exponent — pick up this factor
result *= x
x *= x # square the base
n //= 2 # halve the exponent
return result
print(my_pow(2.0, 10)) # 1024.0
print(my_pow(2.1, 3)) # ~9.261
print(my_pow(2.0, -2)) # 0.25
print(my_pow(2.0, 0)) # 1.0
print(my_pow(1.0, 2147483647)) # 1.0 (handles large n)
print(my_pow(2.0, 1)) # 2.0
# Recursive version — same idea, cleaner to read
def my_pow_recursive(x, n):
if n == 0:
return 1.0
if n < 0:
return my_pow_recursive(1 / x, -n)
half = my_pow_recursive(x, n // 2)
if n % 2 == 0:
return half * half
else:
return half * half * x
print(my_pow_recursive(2.0, 10)) # 1024.0
print(my_pow_recursive(2.0, -2)) # 0.25
Complexity
- Time:
O(log n)— the exponent halves each iteration - Space:
O(1)iterative /O(log n)recursive (call stack depth)
Common Pitfalls
Overflow when negating n = -2^31. In many languages, -(-2^31) overflows back to -2^31. In Python integers are arbitrary-precision, so this is not an issue — but be aware of it in interviews involving C/Java.
Not handling n = 0 separately. Any number (including 0.0) raised to the power 0 should return 1. The iterative loop handles this naturally (it never executes), but the recursive version needs an explicit base case.
Squaring x one extra time after n reaches 1. In the iterative version, after the last odd-exponent step, the loop continues to square x and halve n to 0. The squared x is never used (the loop exits), so there is no bug — but it is a common source of confusion when tracing through the algorithm.
Multiply Strings
Difficulty: Medium Source: NeetCode
Problem
Given two non-negative integers
num1andnum2represented as strings, return the product ofnum1andnum2, also represented as a string.Note: You must not convert the inputs to integers directly.
Example 1: Input:
num1 = "2",num2 = "3"Output:"6"Example 2: Input:
num1 = "123",num2 = "456"Output:"56088"Constraints:
1 <= num1.length, num2.length <= 200num1andnum2consist of digits only- Both
num1andnum2do not contain any leading zeros, except the number0itself
Prerequisites
Before attempting this problem, you should be comfortable with:
- String to digit conversion — using
ord(c) - ord('0')to get the numeric value of a character - Grade-school multiplication — the algorithm learned in elementary school for multiplying two multi-digit numbers
- Array indexing — mapping digit positions to their contribution in the product array
1. Brute Force (Convert via String Arithmetic)
Intuition
Treat each digit of num2 as a multiplier, multiply the entire num1 by that single digit, shift (append zeros), and sum all the partial products. Each single-digit multiplication and the final summation are done with custom string arithmetic to avoid using Python’s built-in integer conversion.
This is essentially what you do by hand, but we can simplify further with the product array approach in solution 2.
Solution
def multiply_brute(num1, num2):
# Helper: multiply string number by single digit d, return string
def multiply_by_digit(s, d):
if d == 0:
return "0"
carry = 0
result = []
for ch in reversed(s):
val = (ord(ch) - ord('0')) * d + carry
result.append(str(val % 10))
carry = val // 10
if carry:
result.append(str(carry))
return ''.join(reversed(result))
# Helper: add two string numbers
def add_strings(a, b):
i, j = len(a) - 1, len(b) - 1
carry = 0
result = []
while i >= 0 or j >= 0 or carry:
da = ord(a[i]) - ord('0') if i >= 0 else 0
db = ord(b[j]) - ord('0') if j >= 0 else 0
s = da + db + carry
result.append(str(s % 10))
carry = s // 10
i -= 1
j -= 1
return ''.join(reversed(result)) if result else "0"
total = "0"
for i, ch in enumerate(reversed(num2)):
d = ord(ch) - ord('0')
partial = multiply_by_digit(num1, d) + "0" * i
total = add_strings(total, partial)
return total
print(multiply_brute("2", "3")) # 6
print(multiply_brute("123", "456")) # 56088
print(multiply_brute("0", "999")) # 0
Complexity
- Time:
O(m * n)— for each digit of num2, we multiply through all of num1 and add - Space:
O(m + n)— partial products
2. Product Array (Grade-School, Direct)
Intuition
When multiplying digit num1[i] by digit num2[j], their product contributes to positions i + j and i + j + 1 in the result (where indices are counted from the right). This lets us accumulate all partial products directly into a single result array of length len(num1) + len(num2), handling carries at the end. No string-to-string addition is needed — just fill the array and carry propagate once.
Algorithm
- Create
pos = [0] * (len(num1) + len(num2)). - For each
ifromlen(num1)-1down to 0:- For each
jfromlen(num2)-1down to 0:mul = (num1[i] digit) * (num2[j] digit).p1 = i + j,p2 = i + j + 1.total = mul + pos[p2].pos[p2] = total % 10(ones digit).pos[p1] += total // 10(carry to next position).
- For each
- Strip leading zeros and join to string.
Solution
def multiply(num1, num2):
m, n = len(num1), len(num2)
pos = [0] * (m + n)
for i in range(m - 1, -1, -1):
for j in range(n - 1, -1, -1):
mul = (ord(num1[i]) - ord('0')) * (ord(num2[j]) - ord('0'))
p1, p2 = i + j, i + j + 1
total = mul + pos[p2]
pos[p2] = total % 10
pos[p1] += total // 10
# Convert to string, skip leading zeros
result = ''.join(map(str, pos)).lstrip('0')
return result if result else "0"
print(multiply("2", "3")) # 6
print(multiply("123", "456")) # 56088
print(multiply("0", "999")) # 0
print(multiply("99", "99")) # 9801
print(multiply("9", "9")) # 81
Complexity
- Time:
O(m * n)— two nested loops over each digit pair - Space:
O(m + n)— the position array
Common Pitfalls
Placing partial products at the wrong indices. When num1[i] and num2[j] are measured from the right (position 0 at the right end), their product goes into positions i + j + 1 (ones) and i + j (carry). If you count from the left (as when indexing into a length-m array with index i), the mapping is p2 = i + j + 1 and p1 = i + j with the array sized to m + n.
Not stripping leading zeros. The product array is always m + n entries long, but most products are shorter. Use lstrip('0') and fall back to "0" for the edge case where the product is zero.
Accumulating carries incorrectly. When writing pos[p1] += total // 10, you are accumulating into pos[p1], which may already have a value from a previous iteration. This is fine — the carry will be resolved in a later outer-loop iteration. Do NOT carry-propagate through the entire array after every digit pair; let the natural left-to-right accumulation handle it.
Detect Squares
Difficulty: Medium Source: NeetCode
Problem
You are given a stream of points on the X-Y plane. Design an algorithm that:
- Adds new points from the stream into a data structure. Duplicate points are allowed and should be treated as different points.
- Given a query point, counts the number of ways to choose three additional points from the data structure such that the four points form an axis-aligned square with positive area.
Implement the
DetectSquaresclass:
DetectSquares()Initializes the object with an empty data structure.void add(int[] point)Adds a new pointpoint = [x, y]to the data structure.int count(int[] point)Counts the number of ways to form axis-aligned squares withpointas one corner.Example 1: Input:
add([3,10]),add([11,2]),add([3,2]), thencount([11,10])Output:1Example 2: Input:
add([3,10]),add([11,2]),add([3,2]),add([11,10]), thencount([11,10])Output:1(the square using the original points; the query point itself does not auto-contribute)Constraints:
point.length == 20 <= x, y <= 1000- At most
3000calls will be made toaddandcount.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash maps — counting occurrences of points efficiently
- Geometry — understanding axis-aligned squares: all four corners share the same two x-coordinates and the same two y-coordinates, and the side length equals the difference
- Enumeration — fixing two points and deriving the remaining two
1. Brute Force (Enumerate All Triples)
Intuition
For a given query point, enumerate every pair of stored points and check if they form a valid axis-aligned square with the query point and the implied fourth corner. This is O(n²) per query — slow but correct.
Algorithm
- Store all added points in a list
pts. - For
count(qx, qy): for every pair(p1, p2)frompts:- Check if
p1, p2, (qx, qy), and the implied fourth corner form an axis-aligned square. - If yes, check if the fourth corner also exists in
pts.
- Check if
- Return the count (divided by 2 to avoid double-counting pairs in symmetric roles).
Solution
from collections import defaultdict
class DetectSquaresBrute:
def __init__(self):
self.pts = []
self.pt_count = defaultdict(int)
def add(self, point):
self.pts.append(point)
self.pt_count[tuple(point)] += 1
def count(self, point):
qx, qy = point
result = 0
seen_diagonals = set()
for x1, y1 in self.pts:
# (qx, qy) and (x1, y1) must be diagonal corners
# For axis-aligned square: |qx-x1| == |qy-y1| > 0
if abs(qx - x1) != abs(qy - y1) or x1 == qx:
continue
key = (min(qx,x1), min(qy,y1), max(qx,x1), max(qy,y1))
if key in seen_diagonals:
continue
seen_diagonals.add(key)
# The other two corners
c3 = (qx, y1)
c4 = (x1, qy)
result += self.pt_count[c3] * self.pt_count[c4]
return result
ds = DetectSquaresBrute()
ds.add([3, 10])
ds.add([11, 2])
ds.add([3, 2])
print(ds.count([11, 10])) # 1
ds2 = DetectSquaresBrute()
ds2.add([3, 10])
ds2.add([11, 2])
ds2.add([3, 2])
ds2.add([11, 10])
print(ds2.count([3, 2])) # 1
Complexity
- Time per count:
O(n)after deduplication with seen_diagonals - Space:
O(n)
2. Optimal — Enumerate Diagonal Corners via Point Count Map
Intuition
For a query point (qx, qy), an axis-aligned square is fully determined by choosing any other point (x, y) as the diagonally opposite corner — but only when |qx - x| == |qy - y| > 0 (equal side lengths, non-degenerate). Once we know the diagonal, the other two corners are (qx, y) and (x, qy). The number of squares using that diagonal is count(qx, y) * count(x, qy).
We enumerate all candidate diagonal points by iterating over distinct x-coordinates and pairing them with the query’s y-coordinate to find the matching candidate points. This runs in O(n) per query where n is the number of distinct points.
Algorithm
- Maintain
pt_count: dict[(x,y) -> count]for all added points. - Maintain
x_points: dict[x -> set of y values]for quick lookup. - For
count(qx, qy):- Initialize
result = 0. - For each
xwith stored points (excludingx == qx— that would give zero-width):- For each
ystored at that x:- If
abs(qx - x) != abs(qy - y)ory == qy: skip (not a valid diagonal). - Otherwise:
result += pt_count[(x, y)] * pt_count[(qx, y)] * pt_count[(x, qy)].
- If
- For each
- Return
result.
- Initialize
Solution
from collections import defaultdict
class DetectSquares:
def __init__(self):
self.pt_count = defaultdict(int) # (x, y) -> count
self.x_to_ys = defaultdict(set) # x -> set of y values present
def add(self, point):
x, y = point
self.pt_count[(x, y)] += 1
self.x_to_ys[x].add(y)
def count(self, point):
qx, qy = point
result = 0
# Enumerate all potential diagonal corners (x, y)
for x in self.x_to_ys:
if x == qx:
continue # same column → zero-width, not a square
for y in self.x_to_ys[x]:
# The diagonal is (qx, qy) — (x, y); must have equal side lengths
if abs(qx - x) != abs(qy - y):
continue
# The other two corners: (qx, y) and (x, qy)
result += (
self.pt_count[(x, y)] *
self.pt_count[(qx, y)] *
self.pt_count[(x, qy)]
)
return result
# Test 1
ds = DetectSquares()
ds.add([3, 10])
ds.add([11, 2])
ds.add([3, 2])
print(ds.count([11, 10])) # 1
# Test 2 — no squares yet
ds2 = DetectSquares()
ds2.add([3, 10])
print(ds2.count([11, 10])) # 0
# Test 3 — duplicate points
ds3 = DetectSquares()
ds3.add([0, 0])
ds3.add([0, 2])
ds3.add([2, 0])
ds3.add([2, 0]) # duplicate
print(ds3.count([2, 2])) # 2 (two ways using the two [2,0] points)
Complexity
- Time per
add:O(1) - Time per
count:O(P)where P is the total number of distinct points stored - Space:
O(P)— point count map and x-to-y sets
Common Pitfalls
Not using multiplicities when counting. If the same point was added multiple times, each copy can independently form a square. Multiply the counts of all three stored corners: count(x,y) * count(qx,y) * count(x,qy).
Forgetting to exclude zero-area squares. If x == qx, all four corners would be on the same vertical line — not a square. Skip this case. Similarly, the condition abs(qx-x) != abs(qy-y) filters out non-square rectangles.
Using the query point’s own count. The query point in count(point) is the one we are testing — it is not a stored point in the data structure (even if the same coordinate was added). Do not multiply by pt_count[(qx, qy)]; that would incorrectly use the query point as both a query and a stored point.
Sliding Window
This section groups problems solved by expanding and shrinking a contiguous range efficiently.
Sliding Window Problems
Practice problems for fixed-size and variable-size window techniques on arrays and strings.
Contains Duplicate II
Difficulty: Easy Source: NeetCode
Problem
Given an integer array
numsand an integerk, returntrueif there are two distinct indicesiandjin the array such thatnums[i] == nums[j]andabs(i - j) <= k.Example 1: Input: nums = [1,2,3,1], k = 3 Output: true
Example 2: Input: nums = [1,0,1,1], k = 1 Output: true
Example 3: Input: nums = [1,2,3,1,2,3], k = 2 Output: false
Constraints:
- 1 <= nums.length <= 10^5
- -10^9 <= nums[i] <= 10^9
- 0 <= k <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash Sets — a data structure that stores unique elements and allows O(1) average-time lookups
- Sliding Window — a technique where you maintain a window of elements and slide it across the array
1. Brute Force
Intuition
For every element, look back up to k positions and check if any earlier element has the same value. If we find a match within k distance, we can immediately return true. It’s straightforward but does redundant work because we’re re-examining the same pairs repeatedly.
Algorithm
- For each index
i, iterate over the previouskindices (or from the start, whichever is closer). - If
nums[i] == nums[j]for anyjin that range, returntrue. - If no such pair is found after scanning the whole array, return
false.
Solution
def containsNearbyDuplicate_brute(nums, k):
n = len(nums)
for i in range(n):
for j in range(max(0, i - k), i):
if nums[i] == nums[j]:
return True
return False
# Test cases
print(containsNearbyDuplicate_brute([1, 2, 3, 1], 3)) # True
print(containsNearbyDuplicate_brute([1, 0, 1, 1], 1)) # True
print(containsNearbyDuplicate_brute([1, 2, 3, 1, 2, 3], 2)) # False
Complexity
- Time:
O(n * k)— for each of n elements we look back up to k positions - Space:
O(1)— no extra data structures
2. Sliding Window with Hash Set
Intuition
Instead of checking backwards every time, maintain a sliding window of the last k elements in a hash set. When you move to a new element, check if it’s already in the set (meaning an identical value exists within distance k). If the window grows beyond size k, evict the oldest element. This way each element is added and removed at most once.
Algorithm
- Initialize an empty set
window. - For each index
i:- If
nums[i]is already inwindow, returntrue. - Add
nums[i]towindow. - If the window size exceeds
k, removenums[i - k]from the set to keep it at most sizek.
- If
- Return
falseif no duplicate was found.
graph LR
A["[1, 2, 3, 1], k=3"] --> B["i=0: set={1}"]
B --> C["i=1: set={1,2}"]
C --> D["i=2: set={1,2,3}"]
D --> E["i=3: 1 in set? YES → true"]
Solution
def containsNearbyDuplicate(nums, k):
window = set()
for i, num in enumerate(nums):
if num in window:
return True
window.add(num)
# Evict the element that's now more than k steps behind
if len(window) > k:
window.remove(nums[i - k])
return False
# Test cases
print(containsNearbyDuplicate([1, 2, 3, 1], 3)) # True
print(containsNearbyDuplicate([1, 0, 1, 1], 1)) # True
print(containsNearbyDuplicate([1, 2, 3, 1, 2, 3], 2)) # False
Complexity
- Time:
O(n)— each element is added and removed from the set at most once - Space:
O(k)— the set holds at most k+1 elements at any time
Common Pitfalls
Off-by-one on window eviction. The window should hold at most k elements before checking the new one. Evict nums[i - k] after adding nums[i] — if you evict too early or too late, you’ll either miss valid duplicates or allow pairs that are more than k apart.
k = 0 edge case. When k is 0, no two distinct indices can satisfy abs(i - j) <= 0, so the answer is always false. The algorithm handles this naturally because the window evicts its only element immediately after adding it.
Longest Substring Without Repeating Characters
Difficulty: Medium Source: NeetCode
Problem
Given a string
s, find the length of the longest substring without repeating characters.Example 1: Input: s = “abcabcbb” Output: 3
Example 2: Input: s = “bbbbb” Output: 1
Example 3: Input: s = “pwwkew” Output: 3
Constraints:
- 0 <= s.length <= 5 * 10^4
sconsists of English letters, digits, symbols and spaces
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sliding Window — dynamically resizing a window by moving left and right pointers
- Hash Sets — O(1) membership checks to detect duplicate characters
1. Brute Force
Intuition
Generate every possible substring, check whether it has all unique characters, and track the longest one that passes the check. We pay O(n) for each uniqueness check on top of the O(n²) enumeration, making this quite slow.
Algorithm
- For each start index
i, for each end indexj >= i, extract the substrings[i:j+1]. - Check if all characters in that substring are unique (convert to a set and compare lengths).
- Track the maximum length found.
Solution
def lengthOfLongestSubstring_brute(s):
max_len = 0
n = len(s)
for i in range(n):
for j in range(i, n):
substring = s[i:j + 1]
if len(set(substring)) == len(substring): # all unique
max_len = max(max_len, len(substring))
return max_len
# Test cases
print(lengthOfLongestSubstring_brute("abcabcbb")) # 3
print(lengthOfLongestSubstring_brute("bbbbb")) # 1
print(lengthOfLongestSubstring_brute("pwwkew")) # 3
Complexity
- Time:
O(n³)— O(n²) substrings, each checked in O(n) - Space:
O(n)— storing the set for each substring check
2. Sliding Window with Hash Set
Intuition
Instead of restarting from scratch for each substring, keep a window [left, right] that always contains unique characters. Expand the window by moving right. When a duplicate is detected (the new character is already in the set), shrink the window from the left until the duplicate is gone. The window is always valid, so we can update the answer at every step without extra checking.
Algorithm
- Initialize
left = 0, an empty setchar_set, andmax_len = 0. - For each
rightin rangelen(s):- While
s[right]is already inchar_set, removes[left]and incrementleft. - Add
s[right]tochar_set. - Update
max_len = max(max_len, right - left + 1).
- While
- Return
max_len.
graph LR
A["s='abcabcbb'"] --> B["right=a,b,c: window=abc, len=3"]
B --> C["right=a: duplicate! shrink left until 'a' removed"]
C --> D["window=bca, len=3"]
D --> E["right=b: duplicate! shrink → window=cab, len=3"]
E --> F["... max stays 3"]
Solution
def lengthOfLongestSubstring(s):
char_set = set()
left = 0
max_len = 0
for right in range(len(s)):
# Shrink from the left until the window has no duplicates
while s[right] in char_set:
char_set.remove(s[left])
left += 1
char_set.add(s[right])
max_len = max(max_len, right - left + 1)
return max_len
# Test cases
print(lengthOfLongestSubstring("abcabcbb")) # 3
print(lengthOfLongestSubstring("bbbbb")) # 1
print(lengthOfLongestSubstring("pwwkew")) # 3
Complexity
- Time:
O(n)— each character is added and removed from the set at most once - Space:
O(min(n, m))— where m is the size of the character set (e.g., 128 for ASCII)
Common Pitfalls
Inner while loop vs if. Use while not if when shrinking the window. A single character removal may not be enough — you need to keep removing from the left until the duplicate character is fully gone from the window.
Empty string input. If s is empty, max_len stays 0 and the loop never executes. That’s the correct answer, so no special handling needed.
Unicode / non-ASCII characters. The hash set approach handles any character type, but the space complexity bound changes. For plain ASCII the set is bounded by 128; for arbitrary Unicode, it could be larger.
Longest Repeating Character Replacement
Difficulty: Medium Source: NeetCode
Problem
You are given a string
sand an integerk. You can choose any character of the string and change it to any other uppercase English character. You can perform this operation at mostktimes. Return the length of the longest substring containing the same letter you can get after performing the above operations.Example 1: Input: s = “ABAB”, k = 2 Output: 4
Example 2: Input: s = “AABABBA”, k = 1 Output: 4
Constraints:
- 1 <= s.length <= 10^5
- s consists of only uppercase English letters
- 0 <= k <= s.length
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sliding Window — variable-size window with expand/shrink logic
- Frequency Counting — using a dictionary or array to count character occurrences in the current window
1. Brute Force
Intuition
Check every possible substring. For each one, count the most frequent character. The number of replacements needed is window_size - max_freq. If that’s at most k, it’s a valid window and we track its length.
Algorithm
- For each start
iand endj, count character frequencies ins[i:j+1]. - Check if
(j - i + 1) - max(freq.values()) <= k. - Track the maximum valid window length.
Solution
def characterReplacement_brute(s, k):
max_len = 0
n = len(s)
for i in range(n):
freq = {}
for j in range(i, n):
freq[s[j]] = freq.get(s[j], 0) + 1
window_size = j - i + 1
if window_size - max(freq.values()) <= k:
max_len = max(max_len, window_size)
return max_len
# Test cases
print(characterReplacement_brute("ABAB", 2)) # 4
print(characterReplacement_brute("AABABBA", 1)) # 4
print(characterReplacement_brute("AAAA", 0)) # 4
Complexity
- Time:
O(n²)— two nested loops; frequency update is O(1) incremental - Space:
O(1)— at most 26 characters in the frequency map
2. Sliding Window
Intuition
The key insight is: a window is valid if window_size - count_of_most_frequent_char <= k. In other words, the number of characters we’d need to replace (everything that isn’t the dominant character) must be within our budget k. We expand the window greedily to the right and shrink from the left only when the window becomes invalid. We track max_freq — the highest character count seen in any window so far. Note that max_freq never decreases (we only ever grow the answer), which is what makes this O(n).
Algorithm
- Initialize
left = 0,freq = {}(character counts in window),max_freq = 0,max_len = 0. - For each
right:- Add
s[right]tofreqand updatemax_freq. - While
(right - left + 1) - max_freq > k, shrink: decrementfreq[s[left]], moveleftright. - Update
max_len = max(max_len, right - left + 1).
- Add
- Return
max_len.
graph TD
A["s='AABABBA', k=1"] --> B["Expand right, track max_freq"]
B --> C["Window invalid when size - max_freq > k"]
C --> D["Shrink left by 1 to restore validity"]
D --> E["Track max window size seen"]
Solution
def characterReplacement(s, k):
freq = {}
left = 0
max_freq = 0
max_len = 0
for right in range(len(s)):
freq[s[right]] = freq.get(s[right], 0) + 1
# max_freq: highest frequency of any single char in current window
max_freq = max(max_freq, freq[s[right]])
# Characters to replace = window_size - max_freq
# If replacements needed exceed k, shrink the window
window_size = right - left + 1
if window_size - max_freq > k:
freq[s[left]] -= 1
left += 1
max_len = max(max_len, right - left + 1)
return max_len
# Test cases
print(characterReplacement("ABAB", 2)) # 4
print(characterReplacement("AABABBA", 1)) # 4
print(characterReplacement("AAAA", 0)) # 4
Complexity
- Time:
O(n)— each character is visited by right once; left only moves forward - Space:
O(1)— frequency map has at most 26 entries
Common Pitfalls
max_freq never decreases — and that’s intentional. When we shrink the window, we don’t recompute max_freq. This is fine because we’re only interested in windows that are at least as large as the best we’ve seen. A smaller max_freq would just mean we’d shrink more, which can’t give us a longer answer anyway.
Confusing window_size - max_freq with the replacement count. This formula counts how many non-dominant characters are in the window — the minimum replacements needed to make the whole window uniform. Make sure you’re subtracting the right thing.
Forgetting to shrink by exactly one. When the window is invalid, you only move left by 1 (not a full while loop). Because right moves by 1 each iteration, the window size stays the same or grows — we never need to shrink by more than 1 per step.
Permutation in String
Difficulty: Medium Source: NeetCode
Problem
Given two strings
s1ands2, returntrueifs2contains a permutation ofs1, orfalseotherwise. In other words, returntrueif one ofs1’s permutations is a substring ofs2.Example 1: Input: s1 = “ab”, s2 = “eidbaooo” Output: true
Example 2: Input: s1 = “ab”, s2 = “eidboaoo” Output: false
Constraints:
- 1 <= s1.length, s2.length <= 10^4
- s1 and s2 consist of lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Fixed Sliding Window — a window of constant size that slides across a string
- Frequency Maps — comparing character count dictionaries to check for anagrams
1. Brute Force
Intuition
For every substring of s2 that has the same length as s1, check if the two are anagrams by comparing their sorted versions (or frequency maps). Checking each window takes O(m) time where m = len(s1), making this approach slow overall.
Algorithm
- Compute the length
m = len(s1). - For each starting index
iins2where a window of sizemfits:- Extract
s2[i:i+m]and check if it’s an anagram ofs1(compare sorted strings or Counter objects).
- Extract
- Return
trueif any window matches, otherwisefalse.
Solution
def checkInclusion_brute(s1, s2):
m = len(s1)
target = sorted(s1)
for i in range(len(s2) - m + 1):
if sorted(s2[i:i + m]) == target:
return True
return False
# Test cases
print(checkInclusion_brute("ab", "eidbaooo")) # True
print(checkInclusion_brute("ab", "eidboaoo")) # False
print(checkInclusion_brute("aab", "lecaabee")) # True
Complexity
- Time:
O(n * m log m)— for each of n windows, sorting takes O(m log m) - Space:
O(m)— storing sorted target and current window
2. Fixed Sliding Window with Frequency Arrays
Intuition
A permutation of s1 is just any arrangement of the same characters with the same counts. So instead of sorting, compare frequency counts. Use a fixed-size window of length len(s1) that slides across s2. When the window slides by one position, update the frequency counts by adding the new right character and removing the old left character. Compare counts at each position. Comparing two 26-element arrays is O(26) = O(1), making the whole thing linear.
Algorithm
- Build frequency arrays
need(for s1) andhave(for the first window of s2). - If they match immediately, return
true. - Slide the window across
s2:- Add the new right character to
have. - Remove the old left character from
have. - If
have == need, returntrue.
- Add the new right character to
- Return
false.
graph LR
A["s1='ab', s2='eidbaooo'"] --> B["need: {a:1, b:1}"]
B --> C["Window 'ei': no match"]
C --> D["Window 'id': no match"]
D --> E["Window 'db': no match"]
E --> F["Window 'ba': {a:1,b:1} == need → TRUE"]
Solution
def checkInclusion(s1, s2):
if len(s1) > len(s2):
return False
m = len(s1)
# Frequency arrays for 26 lowercase letters
need = [0] * 26
have = [0] * 26
for ch in s1:
need[ord(ch) - ord('a')] += 1
# Initialize the first window
for ch in s2[:m]:
have[ord(ch) - ord('a')] += 1
if have == need:
return True
# Slide the window
for i in range(m, len(s2)):
# Add new right character
have[ord(s2[i]) - ord('a')] += 1
# Remove old left character
have[ord(s2[i - m]) - ord('a')] -= 1
if have == need:
return True
return False
# Test cases
print(checkInclusion("ab", "eidbaooo")) # True
print(checkInclusion("ab", "eidboaoo")) # False
print(checkInclusion("aab", "lecaabee")) # True
Complexity
- Time:
O(26 * n)=O(n)— sliding the window is O(n), each comparison is O(26) - Space:
O(1)— two fixed-size arrays of length 26
Common Pitfalls
Using a Counter/dict comparison. This works correctly but dict comparison in Python compares all keys and values, which is fine. However, the array approach makes the O(1) comparison per step more explicit.
len(s1) > len(s2). If s1 is longer than s2, no permutation can fit. Guard against this early, otherwise the slice s2[:m] might behave unexpectedly.
Window boundary. The initial window covers indices [0, m-1]. When sliding, i goes from m to len(s2)-1, and the character leaving the window is at i - m. Make sure the removal index is i - m, not i - m - 1.
Minimum Size Subarray Sum
Difficulty: Medium Source: NeetCode
Problem
Given an array of positive integers
numsand a positive integertarget, return the minimal length of a subarray whose sum is greater than or equal totarget. If there is no such subarray, return0instead.Example 1: Input: target = 7, nums = [2,3,1,2,4,3] Output: 2
Example 2: Input: target = 4, nums = [1,4,4] Output: 1
Example 3: Input: target = 11, nums = [1,1,1,1,1,1,1,1] Output: 0
Constraints:
- 1 <= target <= 10^9
- 1 <= nums.length <= 10^5
- 1 <= nums[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Variable Sliding Window — a window that expands and contracts based on a condition
- Greedy shrinking — once the current window satisfies the constraint, try to make it as small as possible
1. Brute Force
Intuition
Try every possible subarray: for each starting index, keep extending the end index until the sum meets or exceeds the target. Track the shortest such subarray. Since all numbers are positive, the sum only grows as we extend, so we can stop extending early once the condition is met.
Algorithm
- For each start index
i, accumulate a running sum by adding elements one by one. - As soon as the sum
>= target, record the window length and break the inner loop. - Return the minimum length found, or 0 if none was found.
Solution
def minSubArrayLen_brute(target, nums):
n = len(nums)
min_len = float('inf')
for i in range(n):
current_sum = 0
for j in range(i, n):
current_sum += nums[j]
if current_sum >= target:
min_len = min(min_len, j - i + 1)
break # Can't shrink further from the left in this pass
return min_len if min_len != float('inf') else 0
# Test cases
print(minSubArrayLen_brute(7, [2, 3, 1, 2, 4, 3])) # 2
print(minSubArrayLen_brute(4, [1, 4, 4])) # 1
print(minSubArrayLen_brute(11, [1, 1, 1, 1, 1, 1, 1, 1])) # 0
Complexity
- Time:
O(n²)— two nested loops in the worst case - Space:
O(1)— only a few variables
2. Variable Sliding Window
Intuition
Since all numbers are positive, the window sum only increases as we expand right and only decreases as we shrink left. This monotonic property is what makes the sliding window work. Expand the right pointer to grow the sum. Once the sum hits the target, greedily shrink from the left to find the minimum window that still satisfies the condition. Then move right again.
Algorithm
- Initialize
left = 0,current_sum = 0,min_len = infinity. - For each
rightinrange(len(nums)):- Add
nums[right]tocurrent_sum. - While
current_sum >= target:- Update
min_len = min(min_len, right - left + 1). - Subtract
nums[left]fromcurrent_sumand incrementleft.
- Update
- Add
- Return
min_lenif it was updated, otherwise0.
graph LR
A["target=7, nums=[2,3,1,2,4,3]"] --> B["Expand: sum=2,5,6,8 ≥ 7 → len=4"]
B --> C["Shrink left: sum=6 < 7"]
C --> D["Expand: sum=10 ≥ 7 → len=3"]
D --> E["Shrink: sum=7 ≥ 7 → len=2 ✓"]
E --> F["Shrink: sum=3 < 7"]
F --> G["Expand: sum=6 < 7 → done, return 2"]
Solution
def minSubArrayLen(target, nums):
left = 0
current_sum = 0
min_len = float('inf')
for right in range(len(nums)):
current_sum += nums[right]
# Shrink the window as long as it satisfies the condition
while current_sum >= target:
min_len = min(min_len, right - left + 1)
current_sum -= nums[left]
left += 1
return min_len if min_len != float('inf') else 0
# Test cases
print(minSubArrayLen(7, [2, 3, 1, 2, 4, 3])) # 2
print(minSubArrayLen(4, [1, 4, 4])) # 1
print(minSubArrayLen(11, [1, 1, 1, 1, 1, 1, 1, 1])) # 0
Complexity
- Time:
O(n)— each element is added once and removed at most once - Space:
O(1)— just a few pointers and a running sum
Common Pitfalls
This only works because all numbers are positive. If nums could contain zeros or negatives, shrinking the window wouldn’t necessarily decrease the sum, and the sliding window approach would break down.
Updating min_len inside the while loop, not after. The update must happen before you shrink the window — you want to record the length while the window still satisfies the condition, then try to make it smaller.
Returning 0 vs infinity. Initialize min_len to float('inf') so you can detect the case where no valid subarray was found. Only convert to 0 at the return statement.
Minimum Window Substring
Difficulty: Hard Source: NeetCode
Problem
Given two strings
sandtof lengthsmandnrespectively, return the minimum window substring ofssuch that every character int(including duplicates) is included in the window. If there is no such substring, return the empty string"". The test cases will be generated such that the answer is unique.Example 1: Input: s = “ADOBECODEBANC”, t = “ABC” Output: “BANC”
Example 2: Input: s = “a”, t = “a” Output: “a”
Example 3: Input: s = “a”, t = “aa” Output: “”
Constraints:
- m == s.length, n == t.length
- 1 <= m, n <= 10^5
- s and t consist of uppercase and lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Variable Sliding Window — expand right to find a valid window, shrink left to minimize it
- Frequency Maps — tracking character counts for both the target and the current window
- need/have counters — a clever trick to avoid comparing full dictionaries on each step
1. Brute Force
Intuition
Generate every possible substring of s and check if it contains all characters of t (with correct multiplicity). Keep the shortest valid one. This is simple to code but extremely slow for large inputs.
Algorithm
- For each pair
(i, j), extract substrings[i:j+1]. - For each character in
t, confirm its count in the substring is at least as large. - Track the minimum length valid substring and return it.
Solution
from collections import Counter
def minWindow_brute(s, t):
if not t or not s:
return ""
need = Counter(t)
best = ""
for i in range(len(s)):
for j in range(i, len(s)):
window = s[i:j + 1]
window_count = Counter(window)
# Check if window satisfies all requirements
if all(window_count[ch] >= need[ch] for ch in need):
if best == "" or len(window) < len(best):
best = window
return best
# Test cases
print(minWindow_brute("ADOBECODEBANC", "ABC")) # "BANC"
print(minWindow_brute("a", "a")) # "a"
print(minWindow_brute("a", "aa")) # ""
Complexity
- Time:
O(n² * m)— O(n²) substrings, each validated in O(m) - Space:
O(n + m)— Counter objects
2. Variable Sliding Window with need/have
Intuition
Use two pointers. Expand right to collect characters until the window satisfies all of t’s requirements. Then greedily shrink left to find the smallest valid window. When shrinking makes the window invalid, expand right again. The trick to avoid full dictionary comparison on every step: track need (how many distinct chars still required) and have (how many we’ve satisfied). When have == need, the window is valid.
Algorithm
- Build
count_t(frequency of each char in t). Setneed = len(count_t)(distinct chars needed),have = 0. - Use
window = {}for the current window’s char counts,left = 0, and track best window(length, left, right). - For each
right:- Add
s[right]towindow. Ifwindow[s[right]] == count_t[s[right]], incrementhave. - While
have == need(window is valid):- Update best if current window is smaller.
- Remove
s[left]fromwindow; if it drops below requirement, decrementhave. - Increment
left.
- Add
- Return the best substring.
graph TD
A["Expand right until have == need"] --> B["Valid window found"]
B --> C["Record if it's the smallest so far"]
C --> D["Shrink left to try and find a smaller valid window"]
D --> E{"Still have == need?"}
E -->|Yes| C
E -->|No| A
Solution
from collections import defaultdict
def minWindow(s, t):
if not t or not s:
return ""
count_t = {}
for ch in t:
count_t[ch] = count_t.get(ch, 0) + 1
need = len(count_t) # number of distinct chars in t we must satisfy
have = 0 # number of distinct chars currently satisfied in window
window = defaultdict(int)
best_len = float('inf')
best_left = 0
left = 0
for right in range(len(s)):
ch = s[right]
window[ch] += 1
# Check if this char's requirement is now fully met
if ch in count_t and window[ch] == count_t[ch]:
have += 1
# Shrink the window as long as it remains valid
while have == need:
# Update best window
if right - left + 1 < best_len:
best_len = right - left + 1
best_left = left
# Remove the leftmost character
left_ch = s[left]
window[left_ch] -= 1
if left_ch in count_t and window[left_ch] < count_t[left_ch]:
have -= 1
left += 1
return s[best_left:best_left + best_len] if best_len != float('inf') else ""
# Test cases
print(minWindow("ADOBECODEBANC", "ABC")) # "BANC"
print(minWindow("a", "a")) # "a"
print(minWindow("a", "aa")) # ""
Complexity
- Time:
O(n)— right moves across s once; left also moves across s at most once - Space:
O(m + n)— count_t and window dictionaries
Common Pitfalls
Tracking have correctly. Increment have only when window[ch] hits exactly count_t[ch] — not every time you add a character. Similarly, decrement have only when dropping below the requirement, not on every removal.
Characters in s but not in t. Characters not in count_t go into window but never affect have. That’s fine — they’re tracked in case we need to remove them, but they don’t change validity.
Updating best inside the while loop, before shrinking. Record the best window before you remove s[left]. Moving left first would shrink the window and give you the wrong substring boundaries.
Returning the right substring. Track best_left and best_len as indices, then slice at the end. Storing the actual string at every update is wasteful and tricky to get right mid-shrink.
Sliding Window Maximum
Difficulty: Hard Source: NeetCode
Problem
You are given an array of integers
nums, and there is a sliding window of sizekwhich is moving from the very left of the array to the very right. You can only see theknumbers in the window. Each time the sliding window moves right by one position, return the maximum value in each window position.Example 1: Input: nums = [1,3,-1,-3,5,3,6,7], k = 3 Output: [3,3,5,5,6,7]
Example 2: Input: nums = [1], k = 1 Output: [1]
Constraints:
- 1 <= nums.length <= 10^5
- -10^4 <= nums[i] <= 10^4
- 1 <= k <= nums.length
Prerequisites
Before attempting this problem, you should be comfortable with:
- Deque (double-ended queue) — a data structure supporting O(1) append and pop from both ends
- Monotonic Deque — a deque where elements are kept in a specific order (here: decreasing) to efficiently answer range queries
1. Brute Force
Intuition
For each window position, scan all k elements in the window and find the maximum. Simple and correct, but recalculates from scratch for every window position — a lot of redundant work.
Algorithm
- For each starting index
ifrom0ton - k:- Compute
max(nums[i:i+k]). - Append to result.
- Compute
- Return result.
Solution
def maxSlidingWindow_brute(nums, k):
result = []
n = len(nums)
for i in range(n - k + 1):
result.append(max(nums[i:i + k]))
return result
# Test cases
print(maxSlidingWindow_brute([1, 3, -1, -3, 5, 3, 6, 7], 3)) # [3, 3, 5, 5, 6, 7]
print(maxSlidingWindow_brute([1], 1)) # [1]
print(maxSlidingWindow_brute([9, 8, 7, 6], 2)) # [9, 8, 7]
Complexity
- Time:
O(n * k)— n window positions, each requiring O(k) to find max - Space:
O(1)extra (excluding output)
2. Monotonic Deque
Intuition
Maintain a deque of indices where the corresponding values are in decreasing order. The front of the deque is always the index of the maximum element in the current window. When we slide right:
- Add the new element: pop from the back any indices whose values are smaller than the new element — they can never be the maximum while the new element is in the window.
- Remove stale elements: if the front index is outside the current window (index <= i - k), pop it from the front.
- Record the max:
nums[deque[0]]is always the window max.
The deque stays in decreasing order of values, so the max is always at the front.
Algorithm
- Initialize an empty deque
dq(stores indices) and result list. - For each index
i:- While
dqis not empty andnums[dq[-1]] <= nums[i], pop from the back. - Append
ito the back ofdq. - Pop from the front if
dq[0] <= i - k(out of window). - Once
i >= k - 1(first full window), appendnums[dq[0]]to result.
- While
- Return result.
graph TD
A["New element arrives at right"] --> B["Pop back: remove all smaller elements\n(they can't be max while new element exists)"]
B --> C["Push new index to back"]
C --> D["Pop front if index is out of window bounds"]
D --> E["nums[deque front] = window maximum"]
Solution
from collections import deque
def maxSlidingWindow(nums, k):
dq = deque() # stores indices; values are in decreasing order
result = []
for i in range(len(nums)):
# Remove elements from the back that are smaller than current
# (they're useless — current is bigger and will outlast them in the window)
while dq and nums[dq[-1]] <= nums[i]:
dq.pop()
dq.append(i)
# Remove the front element if it's slid out of the window
if dq[0] <= i - k:
dq.popleft()
# Start recording results once the first full window is formed
if i >= k - 1:
result.append(nums[dq[0]])
return result
# Test cases
print(maxSlidingWindow([1, 3, -1, -3, 5, 3, 6, 7], 3)) # [3, 3, 5, 5, 6, 7]
print(maxSlidingWindow([1], 1)) # [1]
print(maxSlidingWindow([9, 8, 7, 6], 2)) # [9, 8, 7]
Complexity
- Time:
O(n)— each index is added to the deque once and removed at most once - Space:
O(k)— the deque holds at most k indices at any time
Common Pitfalls
Storing indices, not values, in the deque. You need the index to check whether a front element has slid out of the window. If you store values, you can’t do this check. Always store indices and look up values with nums[dq[...]].
Pop condition uses <= not <. Use nums[dq[-1]] <= nums[i] when cleaning the back. If you use strict <, you keep duplicate maxima in the deque. Both work for correctness but <= keeps the deque smaller.
Window boundary check: dq[0] <= i - k. The window for position i is [i-k+1, i]. An index j is out of bounds when j < i - k + 1, i.e., j <= i - k. Make sure the boundary math is right; off-by-one here gives wrong answers on the first or last window.
Result collection starts at i = k - 1. Only start appending to result once the first complete window exists (after processing at least k elements).
Stack
This section groups stack-based problems involving LIFO processing, monotonic stacks, and simulation.
Stack Problems
Practice problems for stack simulation, parsing, monotonic stacks, and encoded traversal state.
Baseball Game
Difficulty: Easy Source: NeetCode
Problem
You are keeping score for a baseball game with strange rules. The game consists of several rounds, where the scores of past rounds may affect future rounds’ scores.
At the beginning of the game, you start with an empty record. You are given a list of strings
ops, whereops[i]is thei-th operation you must apply to the record and is one of the following:
- An integer
x— Record a new score ofx."+"— Record a new score that is the sum of the previous two scores."D"— Record a new score that is double the previous score."C"— Invalidate the previous score, removing it from the record.Return the sum of all the scores on the record after applying all the operations.
Example 1: Input: ops = [“5”,“2”,“C”,“D”,“+”] Output: 30
Example 2: Input: ops = [“5”,“-2”,“4”,“C”,“D”,“9”,“+”,“+”] Output: 27
Constraints:
1 <= ops.length <= 1000ops[i]is"C","D","+", or a string representing an integer in the range[-3 * 10^4, 3 * 10^4].- For operation
"+", there will always be at least two previous scores on the record.- For operations
"C"and"D", there will always be at least one previous score on the record.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — A last-in, first-out (LIFO) data structure, perfect for tracking the most recent items.
1. Stack Simulation
Intuition
This problem is basically asking you to simulate a calculator with a memory of past results. A stack is a natural fit here because every operation either looks at, modifies, or removes the most recent scores — and that is exactly what a stack is great at. Walk through each operation one at a time and keep the stack updated.
Algorithm
- Initialize an empty stack to hold valid scores.
- For each operation in
ops:- If it is an integer string, convert it and push onto the stack.
- If it is
"C", pop the top element (invalidate last score). - If it is
"D", push double the top element. - If it is
"+", push the sum of the top two elements (do not remove them).
- Return the sum of all elements remaining in the stack.
Solution
def calPoints(ops):
stack = []
for op in ops:
if op == "C":
stack.pop()
elif op == "D":
stack.append(stack[-1] * 2)
elif op == "+":
stack.append(stack[-1] + stack[-2])
else:
stack.append(int(op))
return sum(stack)
# Test cases
print(calPoints(["5", "2", "C", "D", "+"])) # Expected: 30
print(calPoints(["5", "-2", "4", "C", "D", "9", "+", "+"])) # Expected: 27
print(calPoints(["1"])) # Expected: 1
Complexity
- Time:
O(n)— one pass through the operations list. - Space:
O(n)— the stack can hold at mostnscores.
Common Pitfalls
Forgetting that “+” does not remove the two scores it reads. After computing the new score, both original scores stay on the stack. Only "C" removes an element.
Trying to parse all tokens as integers first. The string "C", "D", and "+" are not integers, so you need to check for them before calling int(). The easiest way is to use if/elif chains with the operator checks first.
Valid Parentheses
Difficulty: Easy Source: NeetCode
Problem
Given a string
scontaining just the characters'(',')','{','}','['and']', determine if the input string is valid.An input string is valid if:
- Open brackets must be closed by the same type of brackets.
- Open brackets must be closed in the correct order.
- Every close bracket has a corresponding open bracket of the same type.
Example 1: Input: s = “()” Output: true
Example 2: Input: s = “()[]{}” Output: true
Example 3: Input: s = “(]” Output: false
Example 4: Input: s = “([)]” Output: false
Example 5: Input: s = “{[]}” Output: true
Constraints:
1 <= s.length <= 10^4sconsists of parentheses only'()[]{}'.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — A LIFO structure that lets you match the most recently opened bracket with the current closing bracket.
- Hash map — Used to store bracket pair mappings for clean lookups.
1. Brute Force
Intuition
The simplest idea is to repeatedly scan the string and remove valid adjacent pairs like (), [], or {} until no more pairs can be removed. If the string ends up empty, it was valid. This mimics how a human might manually verify brackets, but it is slow because you need multiple passes.
Algorithm
- Repeatedly replace
(),[], and{}with empty string"". - Stop when no replacement was made in a full pass.
- If the string is empty, return
True; otherwiseFalse.
Solution
def isValid_brute(s):
while "()" in s or "[]" in s or "{}" in s:
s = s.replace("()", "")
s = s.replace("[]", "")
s = s.replace("{}", "")
return s == ""
# Test cases
print(isValid_brute("()")) # Expected: True
print(isValid_brute("()[]{}")) # Expected: True
print(isValid_brute("(]")) # Expected: False
print(isValid_brute("([)]")) # Expected: False
print(isValid_brute("{[]}")) # Expected: True
Complexity
- Time:
O(n²)— eachreplacecall isO(n)and we may do up toO(n)passes. - Space:
O(n)— Python creates new strings each time.
2. Stack
Intuition
Instead of scanning the string over and over, use a stack to track unmatched open brackets. When you see an opening bracket, push it. When you see a closing bracket, the only valid match is the most recently opened one — which is exactly what a stack gives you. If the stack is empty at the end, everything matched up perfectly.
Algorithm
- Create a mapping from each closing bracket to its corresponding open bracket.
- Initialize an empty stack.
- For each character in
s:- If it is an opening bracket (
(,[,{), push it onto the stack. - If it is a closing bracket, check if the stack is non-empty and the top of the stack matches. If not, return
False.
- If it is an opening bracket (
- After processing all characters, return
Trueonly if the stack is empty (no unmatched opens remain).
Solution
def isValid(s):
stack = []
close_to_open = {
")": "(",
"]": "[",
"}": "{"
}
for char in s:
if char in close_to_open:
# It's a closing bracket
if stack and stack[-1] == close_to_open[char]:
stack.pop()
else:
return False
else:
# It's an opening bracket
stack.append(char)
return len(stack) == 0
# Test cases
print(isValid("()")) # Expected: True
print(isValid("()[]{}")) # Expected: True
print(isValid("(]")) # Expected: False
print(isValid("([)]")) # Expected: False
print(isValid("{[]}")) # Expected: True
print(isValid("(")) # Expected: False (unmatched open)
print(isValid(")")) # Expected: False (nothing on stack to match)
Complexity
- Time:
O(n)— single pass through the string. - Space:
O(n)— the stack holds at mostn/2open brackets.
Common Pitfalls
Forgetting to check if the stack is empty before peeking. If you see ] but the stack is empty, there is no matching open bracket. Always guard stack[-1] with if stack.
Returning True without checking the stack is empty. A string like "(((" never hits a closing bracket, so no early False is triggered. The final return len(stack) == 0 catches this.
Using a set instead of a map for bracket matching. A set can tell you if something is a closing bracket, but you need a map to know which open bracket it should match with.
Implement Stack Using Queues
Difficulty: Easy Source: NeetCode
Problem
Implement a last-in-first-out (LIFO) stack using only two queues. The implemented stack should support all the functions of a normal stack (
push,top,pop, andempty).Implement the
MyStackclass:
MyStack()Initializes the stack object.void push(int x)Pushes elementxto the top of the stack.int pop()Removes the element on the top of the stack and returns it.int top()Returns the element on the top of the stack.boolean empty()Returnstrueif the stack is empty,falseotherwise.Example 1: Input: [“MyStack”,“push”,“push”,“top”,“pop”,“empty”], [[], [1], [2], [], [], []] Output: [null,null,null,2,2,false]
Constraints:
1 <= x <= 9- At most
100calls will be made topush,pop,top, andempty.- All calls to
popandtopare valid.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Queue — A first-in, first-out (FIFO) data structure;
dequefromcollectionsis the standard Python choice. - Stack — The LIFO structure you are trying to simulate.
1. Two Queues (Push Expensive)
Intuition
A queue is FIFO — it hands things back in the order they arrived. A stack is LIFO — it hands back the most recently added item. To fake a stack with queues, the trick is to make sure the element you just pushed ends up at the front of the queue when you need it. One way: every time you push, move all the old elements from queue 1 into queue 2, enqueue the new element into queue 1, then pour everything from queue 2 back into queue 1. Now the newest element is at the front.
Algorithm
push(x):
- Enqueue
xintoq2. - Move all elements from
q1intoq2one by one. - Swap
q1andq2(the new element is now at the front ofq1).
pop(): Dequeue from q1.
top(): Peek at the front of q1.
empty(): Check if q1 is empty.
Solution
from collections import deque
class MyStack:
def __init__(self):
self.q1 = deque()
self.q2 = deque()
def push(self, x: int) -> None:
self.q2.append(x)
while self.q1:
self.q2.append(self.q1.popleft())
self.q1, self.q2 = self.q2, self.q1
def pop(self) -> int:
return self.q1.popleft()
def top(self) -> int:
return self.q1[0]
def empty(self) -> bool:
return len(self.q1) == 0
# Test cases
stack = MyStack()
stack.push(1)
stack.push(2)
print(stack.top()) # Expected: 2
print(stack.pop()) # Expected: 2
print(stack.empty()) # Expected: False
print(stack.pop()) # Expected: 1
print(stack.empty()) # Expected: True
Complexity
- Time:
O(n)push,O(1)pop/top/empty —nis the current number of elements. - Space:
O(n)— storing all elements.
2. One Queue (Push Expensive)
Intuition
You can actually do this with a single queue. After enqueueing the new element, rotate the queue by dequeueing and re-enqueueing every element that was there before the new one. This moves the new element to the front. Imagine the queue as a circle — you are spinning it until the newest item is at the head.
Algorithm
push(x):
- Append
xto the back of the queue. - Rotate the queue
len(q) - 1times: dequeue from the front and enqueue at the back.- Now
xis at the front.
- Now
pop(): Dequeue from the front.
top(): Peek at the front.
empty(): Check if queue is empty.
Solution
from collections import deque
class MyStack:
def __init__(self):
self.q = deque()
def push(self, x: int) -> None:
self.q.append(x)
# Rotate so x is at the front
for _ in range(len(self.q) - 1):
self.q.append(self.q.popleft())
def pop(self) -> int:
return self.q.popleft()
def top(self) -> int:
return self.q[0]
def empty(self) -> bool:
return len(self.q) == 0
# Test cases
stack = MyStack()
stack.push(1)
stack.push(2)
stack.push(3)
print(stack.top()) # Expected: 3
print(stack.pop()) # Expected: 3
print(stack.top()) # Expected: 2
print(stack.empty()) # Expected: False
Complexity
- Time:
O(n)push,O(1)pop/top/empty. - Space:
O(n).
Common Pitfalls
Rotating the wrong number of times. After appending x, the queue has n elements. You need to rotate n - 1 times to bring x to the front, not n times (which would leave it right back at the back).
Confusing which end is the front. With Python’s deque, popleft() removes from the front and append() adds to the back — matching standard queue behavior. Keep this consistent throughout.
Implement Queue Using Stacks
Difficulty: Easy Source: NeetCode
Problem
Implement a first-in-first-out (FIFO) queue using only two stacks. The implemented queue should support all the functions of a normal queue (
push,pop,peek, andempty).Implement the
MyQueueclass:
MyQueue()Initializes the queue object.void push(int x)Pushes elementxto the back of the queue.int pop()Removes the element from the front of the queue and returns it.int peek()Returns the element at the front of the queue without removing it.boolean empty()Returnstrueif the queue is empty,falseotherwise.Note: You must use only standard stack operations — which means only
push to top,peek/pop from top,size, andis emptyare valid.Example 1: Input: [“MyQueue”,“push”,“push”,“peek”,“pop”,“empty”], [[], [1], [2], [], [], []] Output: [null,null,null,1,1,false]
Constraints:
1 <= x <= 9- At most
100calls will be made topush,pop,peek, andempty.- All calls to
popandpeekare valid.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — A LIFO data structure where push and pop happen at the same end.
- Amortized analysis — Understanding that an operation can be expensive occasionally but cheap on average across many calls.
1. Two Stacks (Inbox / Outbox)
Intuition
The key insight is that if you pour a stack into another stack, the order reverses. A queue needs FIFO — so if you pour your “inbox” stack into an “outbox” stack, the oldest element ends up on top of the outbox, ready to be returned first. The trick is to only pour when the outbox is empty, so you never mix up the ordering. This gives you O(1) amortized for every operation because each element crosses from inbox to outbox exactly once.
Algorithm
push(x):
- Push
xontoinbox.
pop():
- If
outboxis empty, pour all ofinboxintooutbox(reverse order preserved). - Pop and return the top of
outbox.
peek():
- If
outboxis empty, pour all ofinboxintooutbox. - Peek at the top of
outbox.
empty():
- Return
Trueif bothinboxandoutboxare empty.
Solution
class MyQueue:
def __init__(self):
self.inbox = [] # new elements go here
self.outbox = [] # elements ready to be dequeued
def push(self, x: int) -> None:
self.inbox.append(x)
def _pour(self) -> None:
"""Move inbox into outbox only when outbox is empty."""
if not self.outbox:
while self.inbox:
self.outbox.append(self.inbox.pop())
def pop(self) -> int:
self._pour()
return self.outbox.pop()
def peek(self) -> int:
self._pour()
return self.outbox[-1]
def empty(self) -> bool:
return not self.inbox and not self.outbox
# Test cases
q = MyQueue()
q.push(1)
q.push(2)
q.push(3)
print(q.peek()) # Expected: 1 (first in)
print(q.pop()) # Expected: 1
print(q.pop()) # Expected: 2
q.push(4)
print(q.pop()) # Expected: 3
print(q.pop()) # Expected: 4
print(q.empty()) # Expected: True
Complexity
- Time:
O(1)amortized for all operations — each element is moved from inbox to outbox exactly once over its lifetime. - Space:
O(n)— all elements live in one of the two stacks.
Common Pitfalls
Pouring inbox into outbox even when outbox still has elements. Only pour when outbox is empty. If you pour prematurely, you will scramble the order of elements that are already queued up in the outbox.
Checking empty incorrectly. The queue is empty only when both stacks are empty. If inbox has elements but outbox is empty, the queue is not empty — you just have not poured yet.
Using inbox[-1] to peek. The top of the inbox is the most recently added element, which is the last in the queue. You want the front of the queue, which lives on top of the outbox after pouring.
Min Stack
Difficulty: Medium Source: NeetCode
Problem
Design a stack that supports push, pop, top, and retrieving the minimum element in constant time.
Implement the
MinStackclass:
MinStack()Initializes the stack object.void push(int val)Pushes the elementvalonto the stack.void pop()Removes the element on the top of the stack.int top()Gets the top element of the stack.int getMin()Retrieves the minimum element in the stack.You must implement a solution with
O(1)time complexity for each function.Example 1: Input: [“MinStack”,“push”,“push”,“push”,“getMin”,“pop”,“top”,“getMin”], [[], [-2], [0], [-3], [], [], [], []] Output: [null,null,null,null,-3,null,0,-2]
Constraints:
-2^31 <= val <= 2^31 - 1- Methods
pop,top, andgetMinoperations will always be called on non-empty stacks.- At most
3 * 10^4calls will be made topush,pop,top, andgetMin.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — The core data structure being extended here.
- Auxiliary data structures — The idea of keeping a second structure in sync with the first to answer extra queries efficiently.
1. Auxiliary Min Stack
Intuition
The challenge is that when you pop an element, the minimum might change — and you need to know the new minimum instantly without scanning. The solution is to maintain a second “min stack” that tracks the current minimum at every level. When you push a value, also push the minimum-so-far onto the min stack. When you pop, pop from both stacks. Now getMin() is just a peek at the top of the min stack, which always reflects the minimum of the current stack contents.
Algorithm
push(val):
- Push
valonto the main stack. - Compute the new minimum:
min(val, min_stack[-1])if min_stack is non-empty, elseval. - Push that minimum onto the min stack.
pop():
- Pop from both the main stack and the min stack simultaneously.
top():
- Return the top of the main stack.
getMin():
- Return the top of the min stack.
Solution
class MinStack:
def __init__(self):
self.stack = []
self.min_stack = [] # tracks the current min at each level
def push(self, val: int) -> None:
self.stack.append(val)
current_min = val if not self.min_stack else min(val, self.min_stack[-1])
self.min_stack.append(current_min)
def pop(self) -> None:
self.stack.pop()
self.min_stack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.min_stack[-1]
# Test cases
ms = MinStack()
ms.push(-2)
ms.push(0)
ms.push(-3)
print(ms.getMin()) # Expected: -3
ms.pop()
print(ms.top()) # Expected: 0
print(ms.getMin()) # Expected: -2
# Additional test
ms2 = MinStack()
ms2.push(5)
ms2.push(3)
ms2.push(7)
print(ms2.getMin()) # Expected: 3
ms2.pop()
print(ms2.getMin()) # Expected: 3
ms2.pop()
print(ms2.getMin()) # Expected: 5
Complexity
- Time:
O(1)for all operations — every operation is a constant number of push/pop/peek steps. - Space:
O(n)— the min stack mirrors the main stack, so total space is2n.
Common Pitfalls
Only updating the min stack when a new minimum is found. If you only push to the min stack when the value is smaller, the two stacks fall out of sync and pops become incorrect. Always push to both stacks on every push.
Using a single min_val variable instead of a stack. A single variable breaks as soon as you pop the current minimum — you have no way to recover the previous minimum without scanning the whole stack. That is exactly why you need a stack.
Forgetting to pop from the min stack on pop(). The two stacks must always have the same length. If you forget to pop from min_stack, getMin() will return stale values.
Evaluate Reverse Polish Notation
Difficulty: Medium Source: NeetCode
Problem
You are given an array of strings
tokensthat represents an arithmetic expression in Reverse Polish Notation (RPN).Evaluate the expression. Return an integer that represents the value of the expression.
Note:
- The valid operators are
'+','-','*', and'/'.- Each operand may be an integer or another expression.
- Division between two integers always truncates toward zero.
- There will not be any division by zero.
- The input represents a valid arithmetic expression in RPN.
Example 1: Input: tokens = [“2”,“1”,“+”,“3”,“*”] Output: 9 Explanation: ((2 + 1) * 3) = 9
Example 2: Input: tokens = [“4”,“13”,“5”,“/”,“+”] Output: 6 Explanation: (4 + (13 / 5)) = 6
Constraints:
1 <= tokens.length <= 10^4tokens[i]is either an operator"+","-","*","/", or an integer in the range[-200, 200].
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — Used to hold operands until their operator arrives.
- Reverse Polish Notation — Postfix notation where operators follow their operands (e.g.,
3 4 +means3 + 4).
1. Stack Evaluation
Intuition
In RPN, whenever you see an operator, the two most recent numbers are its operands. A stack makes this natural: push every number, and when you hit an operator, pop two numbers, apply the operator, and push the result back. By the time you reach the end, the stack has exactly one element — the answer.
Algorithm
- Initialize an empty stack.
- For each token:
- If it is a number (not one of the four operators), convert it to
intand push it. - If it is an operator, pop
bthena(order matters for-and/), computea op b, and push the result.
- If it is a number (not one of the four operators), convert it to
- Return the single remaining element in the stack.
Solution
def evalRPN(tokens):
stack = []
operators = {"+", "-", "*", "/"}
for token in tokens:
if token in operators:
b = stack.pop()
a = stack.pop()
if token == "+":
stack.append(a + b)
elif token == "-":
stack.append(a - b)
elif token == "*":
stack.append(a * b)
elif token == "/":
# int() truncates toward zero — correct for negative numbers.
# Python's // floors toward negative infinity, which is wrong here.
# e.g., -7 // 2 = -4 in Python, but RPN requires -3.
stack.append(int(a / b))
else:
stack.append(int(token))
return stack[0]
# Test cases
print(evalRPN(["2", "1", "+", "3", "*"])) # Expected: 9
print(evalRPN(["4", "13", "5", "/", "+"])) # Expected: 6
print(evalRPN(["10", "6", "9", "3", "+", "-11", "*", "/", "*", "17", "+", "5", "+"]))
# Expected: 22
# Division truncation edge case
print(evalRPN(["-7", "2", "/"])) # Expected: -3 (not -4)
print(evalRPN(["6", "-132", "/"])) # Expected: 0
Complexity
- Time:
O(n)— single pass through the tokens array. - Space:
O(n)— the stack holds at most(n+1)/2numbers at any point (half the tokens are operators in a fully parenthesized expression).
Common Pitfalls
Using // for division instead of int(). Python’s floor division (//) rounds toward negative infinity, but the problem requires truncation toward zero. For positive numbers they agree, but for negatives they differ: -7 // 2 is -4 in Python, whereas the expected answer is -3. Always use int(a / b).
Popping operands in the wrong order. The stack returns the most recently pushed item first. When you evaluate a - b, b was pushed last, so it comes off first. Pop b = stack.pop(), then a = stack.pop(), and compute a - b. Getting this backwards causes wrong answers for - and /.
Trying to distinguish numbers from operators by checking digits. Tokens can be negative (e.g., "-3"), so token[0].isdigit() fails. Checking membership in an operator set is cleaner and handles all cases.
Asteroid Collision
Difficulty: Medium Source: NeetCode
Problem
We are given an array
asteroidsof integers representing asteroids in a row. The absolute value represents size and the sign represents direction (positive = right, negative = left).Each asteroid moves at the same speed. Find out the state of the asteroids after all collisions. If two asteroids meet, the smaller one explodes. If they are the same size, both explode. Two asteroids moving in the same direction never meet.
Example 1: Input: asteroids = [5, 10, -5] Output: [5, 10] Explanation: The 10 and -5 collide resulting in 10. The 5 and 10 never collide.
Example 2: Input: asteroids = [8, -8] Output: [] Explanation: Both asteroids explode.
Example 3: Input: asteroids = [10, 2, -5] Output: [10] Explanation: The 2 and -5 collide resulting in -5. The -5 and 10 collide resulting in 10.
Example 4: Input: asteroids = [-2, -1, 1, 2] Output: [-2, -1, 1, 2] Explanation: The -2 and -1 are moving left, 1 and 2 are moving right. No collision occurs.
Constraints:
2 <= asteroids.length <= 10^4-1000 <= asteroids[i] <= 1000asteroids[i] != 0
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — Used to track surviving asteroids as you process each new one.
- Simulation — Carefully working through the cases of what can happen during a collision.
1. Stack Simulation
Intuition
Think about when a collision actually happens: a right-moving asteroid on the stack meets a left-moving incoming asteroid. Left-moving asteroids only collide with right-moving ones that came before them. Use a stack to represent surviving asteroids processed so far. When a new asteroid comes in:
- If it is positive (moving right), it cannot collide with anything already on the stack — push it.
- If it is negative (moving left), it might collide with the top of the stack if the top is positive. Keep resolving collisions until the incoming asteroid either explodes or survives.
Algorithm
- Initialize an empty stack.
- For each asteroid
a:- Set
alive = True. - While
aliveand the stack has a positive asteroid on top (potential collision):- If
|a| > stack[-1]: the top explodes — pop the stack and continue the loop. - If
|a| == stack[-1]: mutual destruction — pop the stack, setalive = False. - If
|a| < stack[-1]: incoming asteroid explodes — setalive = False.
- If
- If
alive, pushaonto the stack.
- Set
- Return the stack as a list.
Solution
def asteroidCollision(asteroids):
stack = []
for a in asteroids:
alive = True
# Only collide when: top of stack is positive AND incoming is negative
while alive and stack and stack[-1] > 0 and a < 0:
if stack[-1] < abs(a):
# Top asteroid is smaller — it explodes
stack.pop()
elif stack[-1] == abs(a):
# Same size — both explode
stack.pop()
alive = False
else:
# Incoming asteroid is smaller — it explodes
alive = False
if alive:
stack.append(a)
return stack
# Test cases
print(asteroidCollision([5, 10, -5])) # Expected: [5, 10]
print(asteroidCollision([8, -8])) # Expected: []
print(asteroidCollision([10, 2, -5])) # Expected: [10]
print(asteroidCollision([-2, -1, 1, 2])) # Expected: [-2, -1, 1, 2]
print(asteroidCollision([-1, -2, -3])) # Expected: [-1, -2, -3] (all going left)
print(asteroidCollision([1, -1, 1, -1])) # Expected: []
Complexity
- Time:
O(n)— each asteroid is pushed and popped at most once. - Space:
O(n)— the stack holds at mostnasteroids.
Common Pitfalls
Colliding two left-moving or two right-moving asteroids. Two asteroids going the same direction never collide — they are moving in parallel. Only trigger a collision when the stack top is positive (right-moving) and the incoming asteroid is negative (left-moving).
Stopping the while loop too early. After one asteroid on the stack explodes, the incoming asteroid might still collide with the next one on the stack. Keep looping until either the stack is empty, the top is not a right-mover, or the incoming asteroid is gone.
Forgetting the alive flag. Once the incoming asteroid explodes, you must not push it. The alive flag is the cleanest way to track this across the inner while loop.
Daily Temperatures
Difficulty: Medium Source: NeetCode
Problem
Given an array of integers
temperaturesrepresenting the daily temperatures, return an arrayanswersuch thatanswer[i]is the number of days you have to wait after thei-th day to get a warmer temperature. If there is no future day for which this is possible, keepanswer[i] == 0instead.Example 1: Input: temperatures = [73,74,75,71,69,72,76,73] Output: [1,1,4,2,1,1,0,0]
Example 2: Input: temperatures = [30,40,50,60] Output: [1,1,1,0]
Example 3: Input: temperatures = [30,60,90] Output: [1,1,0]
Constraints:
1 <= temperatures.length <= 10^530 <= temperatures[i] <= 100
Prerequisites
Before attempting this problem, you should be comfortable with:
- Monotonic stack — A stack that is kept in a sorted order (increasing or decreasing) by popping elements when the invariant is broken.
1. Brute Force
Intuition
For each day, scan every day after it until you find a warmer one. Simple, but you check many pairs multiple times.
Algorithm
- Initialize
answerarray of zeros with lengthn. - For each index
i, scanj = i+1, i+2, ...untiltemperatures[j] > temperatures[i]. - Record
answer[i] = j - i.
Solution
def dailyTemperatures_brute(temperatures):
n = len(temperatures)
answer = [0] * n
for i in range(n):
for j in range(i + 1, n):
if temperatures[j] > temperatures[i]:
answer[i] = j - i
break
return answer
# Test cases
print(dailyTemperatures_brute([73,74,75,71,69,72,76,73])) # Expected: [1,1,4,2,1,1,0,0]
print(dailyTemperatures_brute([30,40,50,60])) # Expected: [1,1,1,0]
Complexity
- Time:
O(n²)— for each element, we may scan all elements to its right. - Space:
O(1)extra (not counting the output array).
2. Monotonic Decreasing Stack
Intuition
Instead of looking forward for each element, think about it in reverse: when a warmer day arrives, it can resolve all the pending colder days at once. Maintain a stack of indices whose “next warmer day” has not been found yet. Keep the stack in decreasing order of temperature — when a new temperature is higher than the top, that top has found its answer. Pop it, record the distance, and keep going until the stack top is warmer than the current day or the stack is empty.
Algorithm
- Initialize
answer = [0] * nand an emptystackof indices. - For each index
i:- While
stackis non-empty andtemperatures[i] > temperatures[stack[-1]]:- Pop index
jfrom the stack. - Set
answer[j] = i - j.
- Pop index
- Push
ionto the stack.
- While
- Any indices remaining in the stack get
answer[j] = 0(already initialized). - Return
answer.
Solution
def dailyTemperatures(temperatures):
n = len(temperatures)
answer = [0] * n
stack = [] # stores indices; temperatures at those indices are non-increasing
for i in range(n):
# Current temperature is warmer than what's on the stack — resolve them
while stack and temperatures[i] > temperatures[stack[-1]]:
j = stack.pop()
answer[j] = i - j
stack.append(i)
return answer
# Test cases
print(dailyTemperatures([73,74,75,71,69,72,76,73])) # Expected: [1,1,4,2,1,1,0,0]
print(dailyTemperatures([30,40,50,60])) # Expected: [1,1,1,0]
print(dailyTemperatures([30,60,90])) # Expected: [1,1,0]
print(dailyTemperatures([90,80,70,60])) # Expected: [0,0,0,0]
Complexity
- Time:
O(n)— each index is pushed once and popped at most once. - Space:
O(n)— the stack can hold all indices in the worst case (e.g., strictly decreasing temperatures).
Common Pitfalls
Storing temperatures in the stack instead of indices. You need the index to compute the distance (i - j). Always store indices, then look up the temperature via temperatures[stack[-1]].
Using a strictly increasing stack instead of non-decreasing. The condition for popping is strictly greater-than (>). If temperatures are equal, the current day is not “warmer” — do not pop.
Forgetting that remaining stack elements get answer 0. They are already 0 from initialization, so no extra work is needed — but remember why: those days never found a warmer future day.
Online Stock Span
Difficulty: Medium Source: NeetCode
Problem
Design an algorithm that collects daily price quotes for some asset and returns the span of that asset’s price for the current day.
The span of the asset’s price today is defined as the maximum number of consecutive days (starting from today and going backwards) for which the stock price was less than or equal to today’s price.
Implement the
StockSpannerclass:
StockSpanner()Initializes the object of the class.int next(int price)Returns the span of the givenpricefor the current day.Example 1: Input: [“StockSpanner”,“next”,“next”,“next”,“next”,“next”,“next”,“next”], [[], [100], [80], [60], [70], [60], [75], [85]] Output: [null,1,1,1,2,1,4,6]
Constraints:
1 <= price <= 10^5- At most
10^4calls will be made tonext.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Monotonic stack — A stack maintained in sorted order to efficiently answer “how far back does the current value dominate?” queries.
- Span aggregation — Collapsing previously computed spans to avoid redundant work.
1. Stack of (price, span) Pairs
Intuition
Naively, for each new price you would scan backwards until you find a higher price — but that is O(n) per call. The smarter approach stores (price, span) pairs on the stack instead of raw prices. When you see a new price, pop every stack entry whose price is less than or equal to the current price and accumulate their spans. Since those popped entries are already “dominated” by the current price, you can collapse them into a single entry. This means each entry is pushed and popped exactly once, giving O(1) amortized per call.
Algorithm
next(price):
- Start with
span = 1(counts today). - While the stack is non-empty and
stack[-1][0] <= price:- Pop
(prev_price, prev_span)from the stack. - Add
prev_spantospan.
- Pop
- Push
(price, span)onto the stack. - Return
span.
Solution
class StockSpanner:
def __init__(self):
# Stack of (price, span) pairs
# Prices are strictly decreasing from bottom to top
self.stack = []
def next(self, price: int) -> int:
span = 1
# Absorb all consecutive days where the price was <= current price
while self.stack and self.stack[-1][0] <= price:
_, prev_span = self.stack.pop()
span += prev_span
self.stack.append((price, span))
return span
# Test cases
spanner = StockSpanner()
print(spanner.next(100)) # Expected: 1
print(spanner.next(80)) # Expected: 1
print(spanner.next(60)) # Expected: 1
print(spanner.next(70)) # Expected: 2
print(spanner.next(60)) # Expected: 1
print(spanner.next(75)) # Expected: 4
print(spanner.next(85)) # Expected: 6
# Additional test: monotonically increasing prices
spanner2 = StockSpanner()
print(spanner2.next(10)) # Expected: 1
print(spanner2.next(20)) # Expected: 2
print(spanner2.next(30)) # Expected: 3
Complexity
- Time:
O(1)amortized pernext()call — each price is pushed once and popped at most once across all calls. - Space:
O(n)— the stack holds at mostnentries (one pernext()call, in the worst case of strictly decreasing prices).
Common Pitfalls
Storing only prices without spans. If you only store prices, when you pop a dominated entry you lose the information about how many days it spanned. You would have to re-scan those days, defeating the purpose. Always store (price, span) together.
Using < instead of <= when popping. The span counts days where the price was “less than or equal to” today’s price. If the top has the same price as today, it should be absorbed into the span.
Thinking this is O(n) per call. It looks like the inner while loop could run many times, but remember: each entry is pushed exactly once and popped at most once. Amortized over all calls, that is O(1) per call.
Car Fleet
Difficulty: Medium Source: NeetCode
Problem
There are
ncars at given miles away from the starting mile 0, traveling to reach the miletarget.You are given two integer arrays
positionandspeed, both of lengthn, whereposition[i]is the starting mile of thei-th car andspeed[i]is the speed in miles per hour of thei-th car.A car cannot pass another car, but it can catch up to it and then travel at the slower car’s speed.
Two or more cars traveling at the same position and same speed form a single car fleet.
If a car catches up to a car fleet at the moment the fleet reaches the target, it does not join the fleet.
Return the number of car fleets that will arrive at the destination.
Example 1: Input: target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3] Output: 3
Example 2: Input: target = 10, position = [3], speed = [3] Output: 1
Example 3: Input: target = 100, position = [0,2,4], speed = [4,2,1] Output: 1
Constraints:
n == position.length == speed.length1 <= n <= 10^50 < target <= 10^60 <= position[i] < target- All values of
positionare unique.1 <= speed[i] <= 10^6
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — Used here to track fleet formation as you scan cars from front to back.
- Sorting — The cars must be processed in order of starting position.
1. Sort + Stack
Intuition
Cars closer to the target have the right of way — if a car behind is faster, it catches up and joins the fleet of the car ahead, adopting its (slower) speed. The key insight: compute how long each car takes to reach the target (time = (target - position) / speed). Then process cars from closest to the target to farthest. If a car behind takes more time than the car ahead, it can never catch up — it is a new fleet. If it takes less or equal time, it merges with the fleet ahead.
A stack of fleet arrival times makes this clean: push a car’s time only if it is strictly greater than the current top (meaning it forms a new fleet). The number of entries in the stack at the end is the answer.
Algorithm
- Pair up
(position[i], speed[i])and sort by position in descending order (closest to target first). - Initialize an empty stack.
- For each car, compute
time = (target - pos) / speed. - If the stack is empty or
time > stack[-1], pushtime(new fleet). - Otherwise, this car catches up to the fleet ahead — skip it (do not push).
- Return
len(stack).
Solution
def carFleet(target, position, speed):
# Pair and sort by position descending (car closest to target first)
pairs = sorted(zip(position, speed), reverse=True)
stack = []
for pos, spd in pairs:
time = (target - pos) / spd
# Only a new fleet if this car takes longer than the current front fleet
if not stack or time > stack[-1]:
stack.append(time)
# If time <= stack[-1], the car catches up and joins the fleet ahead
return len(stack)
# Test cases
print(carFleet(12, [10, 8, 0, 5, 3], [2, 4, 1, 1, 3])) # Expected: 3
print(carFleet(10, [3], [3])) # Expected: 1
print(carFleet(100, [0, 2, 4], [4, 2, 1])) # Expected: 1
# Edge: two cars, faster behind catches up
print(carFleet(10, [0, 4], [2, 1])) # Expected: 1
# Edge: two cars, faster behind does NOT catch up
print(carFleet(10, [4, 0], [1, 2])) # Expected: 2
Complexity
- Time:
O(n log n)— dominated by sorting. - Space:
O(n)— for the sorted pairs and stack.
Common Pitfalls
Sorting in ascending order instead of descending. You need to process the car closest to the target first. Sort by position descending.
Using integer division. Time calculations need floating point — (target - pos) / speed should be regular division, not //. A car at position 9 with speed 2 heading to target 10 takes 0.5 hours, which integer division would round to 0.
Thinking you need to actually simulate the fleet merging. You never need to update speeds or positions. The time-to-target comparison is enough: if a car behind takes less time, it physically catches up and joins, no matter where they are.
Simplify Path
Difficulty: Medium Source: NeetCode
Problem
You are given an absolute path for a Unix-style file system, which always begins with a slash
'/'. Your task is to transform this absolute path into its simplified canonical path.The rules of a Unix-style file system are as follows:
- A single period
'.'represents the current directory.- A double period
'..'represents the parent directory. Move to the parent of the current directory.- Any other valid sequence of characters represents a directory or file name.
- Multiple consecutive slashes
'//'are treated as a single slash'/'.The simplified path must:
- Start with a single slash
'/'.- Directories within the path are separated by exactly one slash
'/'.- Not end with a slash
'/', unless it is the root directory.- Not have any single or double periods used to denote current or parent directories.
Example 1: Input: path = “/home/” Output: “/home”
Example 2: Input: path = “/../” Output: “/”
Example 3: Input: path = “/home//foo/” Output: “/home/foo”
Example 4: Input: path = “/a/./b/../../c/” Output: “/c”
Constraints:
1 <= path.length <= 3000pathconsists of English letters, digits, period'.', slash'/'or'_'.pathis a valid absolute Unix path.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — Used to track the current directory hierarchy as you process path components.
- String splitting — Splitting on
'/'tokenizes the path into components.
1. Stack + Split
Intuition
Splitting the path on '/' gives you a list of components. Empty strings result from consecutive slashes — you can ignore those. Single dots mean “stay here” — also ignore. Double dots mean “go up one level” — pop from the stack. Everything else is a real directory name — push it. Once processed, join the stack contents with '/' and prepend the root slash.
Algorithm
- Split
pathon'/'to get components. - Initialize an empty stack.
- For each component:
- Skip if it is empty or
".". - Pop from the stack if it is
".."(only if the stack is non-empty). - Otherwise push the component.
- Skip if it is empty or
- Return
"/" + "/".join(stack).
Solution
def simplifyPath(path):
stack = []
for component in path.split("/"):
if component == "" or component == ".":
# Empty from double slash, or current directory — skip
continue
elif component == "..":
# Go up one level — pop if possible
if stack:
stack.pop()
else:
# Valid directory or file name
stack.append(component)
return "/" + "/".join(stack)
# Test cases
print(simplifyPath("/home/")) # Expected: "/home"
print(simplifyPath("/../")) # Expected: "/"
print(simplifyPath("/home//foo/")) # Expected: "/home/foo"
print(simplifyPath("/a/./b/../../c/")) # Expected: "/c"
print(simplifyPath("/")) # Expected: "/"
print(simplifyPath("/a/b/c/../../d")) # Expected: "/a/d"
print(simplifyPath("/.../a/../b")) # Expected: "/.../b" ('...' is a valid dir name)
Complexity
- Time:
O(n)— splitting and joining are both linear in the path length. - Space:
O(n)— the stack holds at mostn/2components.
Common Pitfalls
Treating "..." (three dots) as parent directory. Only ".." (exactly two dots) means “go up.” Any other sequence of dots — like "..." — is a valid directory name and should be pushed.
Not handling ".." at the root. Going “up” from the root should do nothing, not cause an error. The if stack: guard before popping handles this gracefully.
Forgetting to handle multiple consecutive slashes. path.split("/") naturally produces empty strings between consecutive slashes. Skipping empty components handles this without any special-case logic.
Adding a trailing slash. The join produces no trailing slash because "/".join([]) is "" and "/".join(["a", "b"]) is "a/b". The only slash comes from the explicit "/" you prepend.
Decode String
Difficulty: Medium Source: NeetCode
Problem
Given an encoded string, return its decoded string.
The encoding rule is:
k[encoded_string], where theencoded_stringinside the square brackets is being repeated exactlyktimes. Note thatkis guaranteed to be a positive integer.You may assume that the input string is always valid; there are no extra white spaces, square brackets are well-formed, etc. Furthermore, you may assume that the original data does not contain any digits and that all the digits are only for those repeat numbers,
k.Example 1: Input: s = “3[a]2[bc]” Output: “aaabcbc”
Example 2: Input: s = “3[a2[c]]” Output: “accaccacc”
Example 3: Input: s = “2[abc]3[cd]ef” Output: “abcabccdcdcdef”
Constraints:
1 <= s.length <= 30sconsists of lowercase English letters, digits, and square brackets'[]'.- It is guaranteed that
sis valid (parentheses are properly closed,k > 0).- There are no extra white spaces.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Stack — Used to save the “context” (current string and repeat count) when entering a nested bracket group.
- String building — Understanding how to accumulate characters and expand repeated substrings.
1. Stack-Based Decoding
Intuition
The tricky part is nesting — 3[a2[c]] means you need to decode the inner 2[c] first before repeating the outer. A stack naturally handles nesting: when you see [, save your current state (the string built so far and the repeat count) on the stack and start fresh. When you see ], pop the saved state, expand the current string by the saved count, and append it to what you had before.
Algorithm
- Initialize
current_str = "",current_num = 0, and an emptystack. - For each character
c:- If
cis a digit:current_num = current_num * 10 + int(c)(handles multi-digit numbers). - If
c == '[': push(current_str, current_num)onto the stack, reset both to""and0. - If
c == ']': pop(prev_str, num), setcurrent_str = prev_str + num * current_str. - Otherwise (a letter): append
ctocurrent_str.
- If
- Return
current_str.
Solution
def decodeString(s):
stack = [] # stores (string_so_far, repeat_count) tuples
current_str = ""
current_num = 0
for c in s:
if c.isdigit():
# Build up the number (could be multi-digit like "12[...]")
current_num = current_num * 10 + int(c)
elif c == "[":
# Save current context and start a new group
stack.append((current_str, current_num))
current_str = ""
current_num = 0
elif c == "]":
# Expand: pop context, repeat current string, attach to previous
prev_str, num = stack.pop()
current_str = prev_str + num * current_str
else:
# Regular letter
current_str += c
return current_str
# Test cases
print(decodeString("3[a]2[bc]")) # Expected: "aaabcbc"
print(decodeString("3[a2[c]]")) # Expected: "accaccacc"
print(decodeString("2[abc]3[cd]ef")) # Expected: "abcabccdcdcdef"
print(decodeString("abc")) # Expected: "abc" (no encoding)
print(decodeString("10[a]")) # Expected: "aaaaaaaaaa" (multi-digit k)
print(decodeString("2[3[a]b]")) # Expected: "aaabaaab" (nested)
Complexity
- Time:
O(n * k)wherenis the length of the output — each character in the final decoded string is written at most once per nesting level, but the repeated string construction can make it proportional to the output size. - Space:
O(n)— the stack depth is bounded by the nesting depth, andcurrent_strcan grow to the full output size.
Common Pitfalls
Forgetting multi-digit numbers. A number like 12 appears as two separate digit characters. Build it up with current_num = current_num * 10 + int(c) rather than overwriting.
Resetting state on [ but forgetting to reset current_num too. After pushing onto the stack, both current_str and current_num need to be reset to empty string and zero for the new group.
Using str * 0 accidentally. The problem guarantees k >= 1, but if current_num were somehow 0 (e.g., you forgot to parse a digit), the inner string would be silently dropped. Make sure every [ is preceded by a valid digit sequence.
Maximum Frequency Stack
Difficulty: Hard Source: NeetCode
Problem
Design a stack-like data structure to push elements to the stack and pop the most frequent element from the stack.
Implement the
FreqStackclass:
FreqStack()Constructs an empty frequency stack.void push(int val)Pushes an integervalonto the top of the stack.int pop()Removes and returns the most frequent element in the stack. If there is a tie for the most frequent element, the element closest to the top of the stack (most recently pushed) is removed and returned.Example 1: Input: [“FreqStack”,“push”,“push”,“push”,“push”,“push”,“push”,“pop”,“pop”,“pop”,“pop”], [[], [5], [7], [5], [7], [4], [5], [], [], [], []] Output: [null,null,null,null,null,null,null,5,7,5,4]
Explanation:
- After pushes: [5,7,5,7,4,5]
- pop() → 5 (frequency 3, the highest)
- pop() → 7 (now 5 has freq 2, 7 has freq 2; 7 was pushed more recently at freq 2)
- pop() → 5 (5 has freq 2 again since no pushes, 7 has freq 1; 5 was pushed more recently at freq 2)
- pop() → 4
Constraints:
0 <= val <= 10^9- At most
2 * 10^4calls will be made topushandpop.- It is guaranteed that there will be at least one element in the stack before calling
pop.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Hash map — Two maps are needed: one for element frequencies, one mapping frequency levels to their stacks.
- Stack — Each frequency level maintains its own stack to handle tie-breaking by recency.
1. Frequency Map + Group Map
Intuition
The challenge is handling two competing requirements: pop the most frequent element, and if there is a tie, pop the most recently pushed one. The key insight is to maintain a separate stack for each frequency level. When you push an element, increment its frequency and append it to the stack for that frequency. When you pop, look at the highest frequency level and pop from its stack. If that stack becomes empty, decrement max_freq.
Think of it like shelves in a library: frequency level 1 is the bottom shelf, level 2 is above it, etc. Elements move up shelves as they get pushed more. To pop, take from the top of the highest non-empty shelf.
Algorithm
push(val):
- Increment
freq[val](default 0). - Update
max_freq = max(max_freq, freq[val]). - Append
valtogroup[freq[val]](the stack for that frequency).
pop():
- Pop
valfromgroup[max_freq]. - Decrement
freq[val]. - If
group[max_freq]is now empty, decrementmax_freq. - Return
val.
Solution
from collections import defaultdict
class FreqStack:
def __init__(self):
self.freq = defaultdict(int) # val -> current frequency
self.group = defaultdict(list) # frequency -> stack of vals at that freq
self.max_freq = 0
def push(self, val: int) -> None:
self.freq[val] += 1
f = self.freq[val]
self.max_freq = max(self.max_freq, f)
self.group[f].append(val)
def pop(self) -> int:
val = self.group[self.max_freq].pop()
self.freq[val] -= 1
# If no more elements at max_freq, the new max is one lower
if not self.group[self.max_freq]:
self.max_freq -= 1
return val
# Test cases
fs = FreqStack()
fs.push(5)
fs.push(7)
fs.push(5)
fs.push(7)
fs.push(4)
fs.push(5)
print(fs.pop()) # Expected: 5 (freq 3)
print(fs.pop()) # Expected: 7 (freq 2, but 7 was pushed more recently than 5 at freq 2)
print(fs.pop()) # Expected: 5 (freq 2)
print(fs.pop()) # Expected: 4 (freq 1, but 4 was pushed more recently at freq 1)
# Additional test: all same elements
fs2 = FreqStack()
fs2.push(1)
fs2.push(1)
fs2.push(1)
print(fs2.pop()) # Expected: 1
print(fs2.pop()) # Expected: 1
print(fs2.pop()) # Expected: 1
Complexity
- Time:
O(1)for bothpushandpop— all operations are hash map lookups and stack push/pop. - Space:
O(n)— the frequency map and group map together holdntotal entries across all frequency levels.
Common Pitfalls
Trying to maintain a single sorted structure. A heap or sorted container would give O(log n) — the group map approach achieves O(1) by exploiting the structure of frequency increments (they only go up by 1 at a time).
Decrementing max_freq too eagerly. Only decrement max_freq when group[max_freq] is actually empty. If there are still other elements at that frequency level, max_freq should stay.
Confusing the semantics of group. group[f] is a stack (last-in-first-out) of elements whose frequency was f at the moment they were pushed. An element appears at multiple frequency levels — at level 1 when first pushed, level 2 on the second push, etc. Popping removes it from its highest level.
Largest Rectangle in Histogram
Difficulty: Hard Source: NeetCode
Problem
Given an array of integers
heightsrepresenting the histogram’s bar height where the width of each bar is1, return the area of the largest rectangle in the histogram.Example 1: Input: heights = [2,1,5,6,2,3] Output: 10 Explanation: The rectangle has height 5 and spans bars at indices 2 and 3.
Example 2: Input: heights = [2,4] Output: 4
Constraints:
1 <= heights.length <= 10^50 <= heights[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Monotonic stack — A stack maintained in sorted order to efficiently find the nearest smaller element to the left and right.
- Rectangle area reasoning — Understanding that a rectangle’s height is limited by the shortest bar it spans.
1. Brute Force
Intuition
For every pair of bars (i, j), the largest rectangle that spans from i to j has a height equal to the minimum bar height in that range. Try every possible left and right boundary and compute the area. Simple but slow.
Algorithm
- For each starting bar
i, track the running minimum height as you extend right. - For each ending bar
j >= i, computearea = min_height * (j - i + 1). - Track the global maximum.
Solution
def largestRectangleArea_brute(heights):
n = len(heights)
max_area = 0
for i in range(n):
min_height = heights[i]
for j in range(i, n):
min_height = min(min_height, heights[j])
area = min_height * (j - i + 1)
max_area = max(max_area, area)
return max_area
# Test cases
print(largestRectangleArea_brute([2, 1, 5, 6, 2, 3])) # Expected: 10
print(largestRectangleArea_brute([2, 4])) # Expected: 4
print(largestRectangleArea_brute([1])) # Expected: 1
Complexity
- Time:
O(n²)— two nested loops overnbars. - Space:
O(1)extra.
2. Monotonic Increasing Stack
Intuition
For each bar, the largest rectangle with that bar as the shortest (limiting) bar extends as far left and right as possible — until it hits a bar shorter than itself. The right boundary is the first bar to the right that is shorter. The left boundary is the first bar to the left that is shorter.
A monotonic increasing stack gives you the left boundary for free: when you push a bar, everything below it in the stack is shorter (so it extends left past all of them). When a bar is popped because a shorter bar arrived, the shorter bar is the right boundary, and the new stack top is the left boundary.
The trick for the “extended left” part: instead of storing just the index, store (index, height) where index is the leftmost position the bar could extend to. When you pop a bar and the incoming bar is shorter, set the incoming bar’s start index to the popped bar’s start index (it can extend as far left as the popped bar could).
Algorithm
- Initialize
max_area = 0and an emptystackof(start_index, height). - For each bar at index
iwith heighth:- Set
start = i. - While
stackis non-empty andstack[-1][1] > h(current bar is shorter):- Pop
(idx, popped_h). - Compute
area = popped_h * (i - idx). - Update
max_area. - Set
start = idx(the current bar can extend as far left as the popped bar).
- Pop
- Push
(start, h).
- Set
- After the loop, process remaining bars in the stack:
- For each
(idx, h)in stack:area = h * (n - idx).
- For each
- Return
max_area.
Solution
def largestRectangleArea(heights):
n = len(heights)
max_area = 0
# Stack stores (start_index, height)
# Heights in stack are non-decreasing (monotonic increasing)
stack = []
for i, h in enumerate(heights):
start = i
# Pop all bars taller than the current bar
while stack and stack[-1][1] > h:
idx, popped_h = stack.pop()
# Rectangle: height=popped_h, width=i-idx
max_area = max(max_area, popped_h * (i - idx))
# Current bar can extend as far left as this popped bar
start = idx
stack.append((start, h))
# Remaining bars extend all the way to the right end
for idx, h in stack:
max_area = max(max_area, h * (n - idx))
return max_area
# Test cases
print(largestRectangleArea([2, 1, 5, 6, 2, 3])) # Expected: 10
print(largestRectangleArea([2, 4])) # Expected: 4
print(largestRectangleArea([1])) # Expected: 1
print(largestRectangleArea([6, 2, 5, 4, 5, 1, 6])) # Expected: 12
print(largestRectangleArea([1, 1, 1, 1])) # Expected: 4
print(largestRectangleArea([5, 4, 3, 2, 1])) # Expected: 9
Complexity
- Time:
O(n)— each bar is pushed once and popped at most once. - Space:
O(n)— the stack holds at mostnbars.
Common Pitfalls
Forgetting to process bars remaining in the stack after the loop. Bars still in the stack have not found a shorter right boundary — they extend all the way to the rightmost bar. Their area is height * (n - start_index).
Computing width incorrectly. When popping bar at idx due to the bar at i, the rectangle spans from idx to i - 1, so width is i - idx (not i - idx + 1, because the current shorter bar at i is not included).
Using >= instead of > when popping. You should only pop when the stack top is strictly taller than the current bar. If they are equal height, the current bar extends the same rectangle leftward, and the start update handles this correctly without needing an extra pop.
Storing only heights instead of (start, height) pairs. Without the start index, you cannot know how far left a bar’s rectangle extends after its left neighbors have been popped.
Two Pointers
This section groups problems solved by coordinating two moving indices across arrays or strings.
Two Pointer Problems
Practice problems for inward scans, partitioning, and sorted-array pointer movement.
Reverse String
Difficulty: Easy Source: NeetCode
Problem
Write a function that reverses an array of characters in-place. You must do this by modifying the input array with
O(1)extra memory.Example 1: Input:
s = ['h','e','l','l','o']Output:['o','l','l','e','h']Example 2: Input:
s = ['H','a','n','n','a','h']Output:['h','a','n','n','a','H']Constraints:
1 <= s.length <= 10^5s[i]is a printable ASCII character
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — index-based access and in-place modification
- Two Pointers — using two indices that move toward each other from opposite ends
1. Brute Force
Intuition
Create a reversed copy of the array and write it back into the original. Python’s slicing makes this a one-liner, but it uses O(n) extra space — which violates the problem’s constraint. It’s still useful as a baseline to understand what we’re trying to achieve.
Algorithm
- Create a reversed copy of
susing slicing:s[::-1]. - Write each element of the reversed copy back into
s.
Solution
def reverseString(s):
reversed_copy = s[::-1]
for i in range(len(s)):
s[i] = reversed_copy[i]
s = ['h', 'e', 'l', 'l', 'o']
reverseString(s)
print(s) # ['o', 'l', 'l', 'e', 'h']
s = ['H', 'a', 'n', 'n', 'a', 'h']
reverseString(s)
print(s) # ['h', 'a', 'n', 'n', 'a', 'H']
s = ['a']
reverseString(s)
print(s) # ['a']
Complexity
- Time:
O(n) - Space:
O(n)
2. Two Pointers
Intuition
Place one pointer at the start and another at the end of the array. Swap the characters they point to, then move both pointers inward. Keep going until the pointers meet in the middle. Each swap fixes two characters at once, so we only need n/2 swaps total — and we never need any extra storage.
Algorithm
- Initialize
left = 0andright = len(s) - 1. - While
left < right:- Swap
s[left]ands[right]. - Increment
left, decrementright.
- Swap
Solution
def reverseString(s):
left, right = 0, len(s) - 1
while left < right:
s[left], s[right] = s[right], s[left]
left += 1
right -= 1
s = ['h', 'e', 'l', 'l', 'o']
reverseString(s)
print(s) # ['o', 'l', 'l', 'e', 'h']
s = ['H', 'a', 'n', 'n', 'a', 'h']
reverseString(s)
print(s) # ['h', 'a', 'n', 'n', 'a', 'H']
s = ['a', 'b']
reverseString(s)
print(s) # ['b', 'a']
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Using s = s[::-1] and thinking it modifies in-place. Reassigning s inside the function only rebinds the local variable — the original list the caller passed in is unchanged. You must mutate the list itself, either with index assignments or slice assignment s[:] = s[::-1].
Stopping too early with left <= right. When the array has an odd number of elements, left == right at the middle element. Swapping an element with itself is harmless, but using < is cleaner and equally correct.
Valid Palindrome
Difficulty: Easy Source: NeetCode
Problem
A phrase is a palindrome if, after converting all uppercase letters into lowercase letters and removing all non-alphanumeric characters, it reads the same forward and backward.
Given a string
s, returntrueif it is a palindrome, orfalseotherwise.Example 1: Input:
s = "A man, a plan, a canal: Panama"Output:trueExample 2: Input:
s = "race a car"Output:falseExample 3: Input:
s = " "Output:trueConstraints:
1 <= s.length <= 2 * 10^5sconsists only of printable ASCII characters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Strings — iterating characters,
isalnum(),lower() - Two Pointers — comparing from both ends simultaneously
1. Brute Force
Intuition
Strip out everything that isn’t a letter or digit, lowercase what remains, then compare the cleaned string to its reverse. If they match, it’s a palindrome. This is easy to read and reason about — the only downside is using O(n) extra space for the cleaned string.
Algorithm
- Build
cleanedby iteratingsand keeping only characters wherec.isalnum()isTrue, lowercased. - Return
cleaned == cleaned[::-1].
Solution
def isPalindrome(s):
cleaned = [c.lower() for c in s if c.isalnum()]
return cleaned == cleaned[::-1]
print(isPalindrome("A man, a plan, a canal: Panama")) # True
print(isPalindrome("race a car")) # False
print(isPalindrome(" ")) # True
Complexity
- Time:
O(n) - Space:
O(n)
2. Two Pointers
Intuition
Instead of building a cleaned copy, we work directly on the original string. Put one pointer at the far left and one at the far right. Whenever a pointer lands on a non-alphanumeric character, skip it by moving the pointer inward. Once both pointers are on valid characters, compare them (case-insensitively). If they ever differ, it’s not a palindrome. If the pointers cross without a mismatch, it is.
Algorithm
- Initialize
left = 0,right = len(s) - 1. - While
left < right:- Advance
leftwhileleft < rightands[left]is not alphanumeric. - Retreat
rightwhileleft < rightands[right]is not alphanumeric. - If
s[left].lower() != s[right].lower(), returnFalse. - Move both pointers inward:
left += 1,right -= 1.
- Advance
- Return
True.
Solution
def isPalindrome(s):
left, right = 0, len(s) - 1
while left < right:
while left < right and not s[left].isalnum():
left += 1
while left < right and not s[right].isalnum():
right -= 1
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
print(isPalindrome("A man, a plan, a canal: Panama")) # True
print(isPalindrome("race a car")) # False
print(isPalindrome(" ")) # True
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Forgetting to lowercase before comparing. 'A' and 'a' are different characters, so always call .lower() (or .upper()) on both sides before the comparison.
Off-by-one in the inner skip loops. The inner while loops must also check left < right, otherwise a pointer can overshoot and the final comparison reads out-of-bounds indices (or compares the same character to itself on an all-non-alphanumeric string).
Treating an all-whitespace string as non-palindrome. After stripping non-alphanumeric characters, an empty sequence is trivially a palindrome — the loop never executes and you return True, which is correct.
Valid Palindrome II
Difficulty: Easy Source: NeetCode
Problem
Given a string
s, returntrueif the string can be a palindrome after deleting at most one character from it.Example 1: Input:
s = "aba"Output:trueExample 2: Input:
s = "abca"Output:true(delete'c')Example 3: Input:
s = "abc"Output:falseConstraints:
1 <= s.length <= 10^5sconsists only of lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Valid Palindrome (LeetCode 125) — checking if a string is a palindrome with two pointers
- Two Pointers — moving inward from both ends and branching on a mismatch
1. Brute Force
Intuition
Try deleting every character one at a time and check if the resulting string is a palindrome. The first deletion that produces a palindrome means we return True. If no single deletion works, return False. Simple to understand but slow — we’re doing n palindrome checks each costing O(n).
Algorithm
- Define a helper
is_palindrome(t)that returns whether stringtreads the same forward and backward. - If
sis already a palindrome, returnTrue. - For each index
ifrom0tolen(s) - 1:- Build
t = s[:i] + s[i+1:](delete character ati). - If
is_palindrome(t), returnTrue.
- Build
- Return
False.
Solution
def validPalindrome(s):
def is_palindrome(t):
return t == t[::-1]
if is_palindrome(s):
return True
for i in range(len(s)):
if is_palindrome(s[:i] + s[i + 1:]):
return True
return False
print(validPalindrome("aba")) # True
print(validPalindrome("abca")) # True
print(validPalindrome("abc")) # False
Complexity
- Time:
O(n²) - Space:
O(n)
2. Two Pointers with One Skip
Intuition
Walk inward from both ends with two pointers, exactly like a normal palindrome check. The moment we hit a mismatch at positions left and right, we know we must skip one of those two characters. We check both options: does s[left+1 .. right] form a palindrome, or does s[left .. right-1]? If either does, we can fix the string with one deletion. We only ever need to branch once — if neither half is a palindrome, no single deletion can save us.
Algorithm
- Define a helper
is_palindrome(t, l, r)that checks ift[l..r]is a palindrome using two pointers. - Initialize
left = 0,right = len(s) - 1. - While
left < right:- If
s[left] == s[right]: move both pointers inward. - Else: return
is_palindrome(s, left + 1, right) or is_palindrome(s, left, right - 1).
- If
- Return
True(no mismatch found — already a palindrome).
Solution
def validPalindrome(s):
def is_palindrome(t, l, r):
while l < r:
if t[l] != t[r]:
return False
l += 1
r -= 1
return True
left, right = 0, len(s) - 1
while left < right:
if s[left] != s[right]:
return is_palindrome(s, left + 1, right) or is_palindrome(s, left, right - 1)
left += 1
right -= 1
return True
print(validPalindrome("aba")) # True
print(validPalindrome("abca")) # True
print(validPalindrome("abc")) # False
print(validPalindrome("deeee")) # True
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Skipping more than one character. The problem allows at most one deletion. Once you hit a mismatch, you must commit to one of the two choices and verify the remainder is a palindrome — you can’t keep skipping inside the helper.
Checking the wrong substrings on mismatch. When s[left] != s[right], the two candidate substrings are s[left+1 .. right] (skip left character) and s[left .. right-1] (skip right character). A common mistake is to check s[left+1 .. right-1], which skips both — that’s two deletions, not one.
Returning early on an already-valid palindrome. If the two-pointer walk completes without a mismatch, the string is already a palindrome, so return True without calling the helper.
Merge Strings Alternately
Difficulty: Easy Source: NeetCode
Problem
You are given two strings
word1andword2. Merge the strings by adding letters in alternating order, starting withword1. If a string is longer than the other, append the additional letters onto the end of the merged string.Return the merged string.
Example 1: Input:
word1 = "abc",word2 = "pqr"Output:"apbqcr"Example 2: Input:
word1 = "ab",word2 = "pqrs"Output:"apbqrs"Example 3: Input:
word1 = "abcd",word2 = "pq"Output:"apbqcd"Constraints:
1 <= word1.length, word2.length <= 100word1andword2consist of lowercase English letters
Prerequisites
Before attempting this problem, you should be comfortable with:
- Strings — building strings from characters, string concatenation
- Two Pointers — advancing two indices through separate sequences in tandem
1. Brute Force (zip + remainder)
Intuition
Zip the two strings together so we process one character from each at a time. After the zip exhausts the shorter string, concatenate whatever is left from the longer string. This is idiomatic Python and perfectly readable, though some interviewers may want a more explicit pointer approach.
Algorithm
- Initialize
result = []. - Zip
word1andword2— for each pair(c1, c2), appendc1thenc2toresult. - Find which string is longer and append its remaining characters.
- Return
"".join(result).
Solution
def mergeAlternately(word1, word2):
result = []
for c1, c2 in zip(word1, word2):
result.append(c1)
result.append(c2)
# Append the leftover from whichever string is longer
m, n = len(word1), len(word2)
if m > n:
result.append(word1[n:])
else:
result.append(word2[m:])
return "".join(result)
print(mergeAlternately("abc", "pqr")) # apbqcr
print(mergeAlternately("ab", "pqrs")) # apbqrs
print(mergeAlternately("abcd", "pq")) # apbqcd
Complexity
- Time:
O(m + n) - Space:
O(m + n)
2. Two Pointers
Intuition
Use a single pointer i that advances through both strings simultaneously. On each step, if i is within word1, take word1[i]; if i is within word2, take word2[i]. Keep going until i has passed the end of both strings. This makes the alternating logic and the remainder handling fall out naturally from a single loop.
Algorithm
- Initialize
i = 0,result = []. - While
i < len(word1)ori < len(word2):- If
i < len(word1), appendword1[i]. - If
i < len(word2), appendword2[i]. - Increment
i.
- If
- Return
"".join(result).
Solution
def mergeAlternately(word1, word2):
i = 0
result = []
while i < len(word1) or i < len(word2):
if i < len(word1):
result.append(word1[i])
if i < len(word2):
result.append(word2[i])
i += 1
return "".join(result)
print(mergeAlternately("abc", "pqr")) # apbqcr
print(mergeAlternately("ab", "pqrs")) # apbqrs
print(mergeAlternately("abcd", "pq")) # apbqcd
Complexity
- Time:
O(m + n) - Space:
O(m + n)
Common Pitfalls
Using + to concatenate inside a loop. Strings are immutable in Python, so result += c creates a new string every iteration — that’s O(n²) time overall. Always collect characters into a list and join at the end.
Forgetting the remainder. zip stops at the shorter string. If you only use zip without handling the leftover, you’ll silently drop characters from the longer string.
Checking i < max(len(word1), len(word2)) instead of or. Using or in the while condition and guarding each append separately is the safest pattern — it avoids index-out-of-range errors without needing to compute the max length up front.
Merge Sorted Array
Difficulty: Easy Source: NeetCode
Problem
You are given two integer arrays
nums1andnums2, sorted in non-decreasing order, and two integersmandn, representing the number of elements innums1andnums2respectively.Merge
nums2intonums1as one sorted array in-place.nums1has lengthm + n; the lastnpositions are zeroed out as placeholders.Example 1: Input:
nums1 = [1,2,3,0,0,0],m = 3,nums2 = [2,5,6],n = 3Output:[1,2,2,3,5,6]Example 2: Input:
nums1 = [1],m = 1,nums2 = [],n = 0Output:[1]Constraints:
nums1.length == m + nnums2.length == n0 <= m, n <= 200-10^9 <= nums1[i], nums2[j] <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sorted Arrays — merging two sorted sequences (merge step from merge sort)
- Two Pointers — using multiple indices to avoid overwriting data you still need
1. Brute Force
Intuition
Copy all elements of nums2 into the trailing placeholder slots of nums1, then sort nums1. We’re throwing away the fact that both arrays are already sorted, but it gets the job done in a single sort call. The sort costs O((m+n) log(m+n)), which is fine for small inputs.
Algorithm
- Copy
nums2intonums1[m:]. - Sort
nums1in place.
Solution
def merge(nums1, m, nums2, n):
for i in range(n):
nums1[m + i] = nums2[i]
nums1.sort()
nums1 = [1, 2, 3, 0, 0, 0]
merge(nums1, 3, [2, 5, 6], 3)
print(nums1) # [1, 2, 2, 3, 5, 6]
nums1 = [1]
merge(nums1, 1, [], 0)
print(nums1) # [1]
nums1 = [0]
merge(nums1, 0, [1], 1)
print(nums1) # [1]
Complexity
- Time:
O((m + n) log(m + n)) - Space:
O(1)(in-place sort)
2. Three Pointers from the End
Intuition
If we merge from the front, we risk overwriting elements in nums1 that we still need. So instead, fill from the back. The largest unplaced element among both arrays belongs at the very end of nums1. Compare the last real elements of nums1 and nums2, write the bigger one to the current tail position, and move that pointer backward. Repeat until we exhaust one or both arrays. If nums2 still has elements left when nums1 runs out, copy them in — if nums1 runs out first, its remaining elements are already in place.
Algorithm
- Initialize
p1 = m - 1,p2 = n - 1,tail = m + n - 1. - While
p1 >= 0andp2 >= 0:- If
nums1[p1] >= nums2[p2]: writenums1[p1]tonums1[tail], decrementp1. - Else: write
nums2[p2]tonums1[tail], decrementp2. - Decrement
tail.
- If
- While
p2 >= 0: writenums2[p2]tonums1[tail], decrement bothp2andtail. - (If
p1 >= 0, those elements are already in the correct positions — no action needed.)
Solution
def merge(nums1, m, nums2, n):
p1 = m - 1
p2 = n - 1
tail = m + n - 1
while p1 >= 0 and p2 >= 0:
if nums1[p1] >= nums2[p2]:
nums1[tail] = nums1[p1]
p1 -= 1
else:
nums1[tail] = nums2[p2]
p2 -= 1
tail -= 1
# Any remaining elements in nums2 still need to be placed
while p2 >= 0:
nums1[tail] = nums2[p2]
p2 -= 1
tail -= 1
nums1 = [1, 2, 3, 0, 0, 0]
merge(nums1, 3, [2, 5, 6], 3)
print(nums1) # [1, 2, 2, 3, 5, 6]
nums1 = [1]
merge(nums1, 1, [], 0)
print(nums1) # [1]
nums1 = [0]
merge(nums1, 0, [1], 1)
print(nums1) # [1]
Complexity
- Time:
O(m + n) - Space:
O(1)
Common Pitfalls
Merging from the front and overwriting unread elements. If you start writing at index 0, you’ll clobber nums1 values that haven’t been compared yet. Always fill from the tail when merging in-place into nums1.
Forgetting the leftover nums2 elements. After the main loop, nums2 may still have elements smaller than everything remaining in nums1. The second while p2 >= 0 loop handles this. You don’t need a symmetric loop for p1 because those elements are already sitting in the right positions.
Starting tail at the wrong index. tail should start at m + n - 1, the last valid index of nums1. A common mistake is starting at m - 1 (the last real element), which leaves no room for nums2.
Remove Duplicates From Sorted Array
Difficulty: Easy Source: NeetCode
Problem
Given an integer array
numssorted in non-decreasing order, remove the duplicates in-place so that each unique element appears only once. The relative order of the elements should be kept the same. Returnk— the number of unique elements innums.The first
kelements ofnumsmust hold the unique elements. The remaining elements and the size of the array do not matter.Example 1: Input:
nums = [1,1,2]Output:k = 2,nums = [1,2,_]Example 2: Input:
nums = [0,0,1,1,1,2,2,3,3,4]Output:k = 5,nums = [0,1,2,3,4,_,_,_,_,_]Constraints:
1 <= nums.length <= 3 * 10^4-100 <= nums[i] <= 100numsis sorted in non-decreasing order
Prerequisites
Before attempting this problem, you should be comfortable with:
- Sorted Arrays — recognizing that equal adjacent elements are duplicates
- Two Pointers — slow/fast pointer pattern for in-place array compaction
1. Brute Force
Intuition
Collect all unique elements into a new array, then write them back into nums. Easy to reason about, but uses O(n) extra space. Since the array is sorted, uniqueness is trivial — just skip any element that equals the previous one.
Algorithm
- Build
uniqueby iteratingnumsand appending a value only when it differs from the previous value. - Write
uniqueback into the firstlen(unique)positions ofnums. - Return
len(unique).
Solution
def removeDuplicates(nums):
unique = [nums[0]]
for i in range(1, len(nums)):
if nums[i] != nums[i - 1]:
unique.append(nums[i])
for i, val in enumerate(unique):
nums[i] = val
return len(unique)
nums = [1, 1, 2]
k = removeDuplicates(nums)
print(k, nums[:k]) # 2 [1, 2]
nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
k = removeDuplicates(nums)
print(k, nums[:k]) # 5 [0, 1, 2, 3, 4]
Complexity
- Time:
O(n) - Space:
O(n)
2. Slow / Fast Two Pointers
Intuition
Use a slow pointer k as the write index (where the next unique element should go) and a fast pointer i that scans every element. Whenever the fast pointer finds a value different from the element just written at k - 1, we’ve found a new unique value — write it to nums[k] and advance k. Elements equal to nums[k - 1] are just skipped. The array is already sorted, so duplicates always sit adjacent to each other.
Algorithm
- Initialize
k = 1(the first element is always unique, so the write position starts at index 1). - For
ifrom1tolen(nums) - 1:- If
nums[i] != nums[k - 1]: writenums[i]tonums[k], incrementk.
- If
- Return
k.
Solution
def removeDuplicates(nums):
k = 1 # nums[0] is always kept; start writing at index 1
for i in range(1, len(nums)):
if nums[i] != nums[k - 1]:
nums[k] = nums[i]
k += 1
return k
nums = [1, 1, 2]
k = removeDuplicates(nums)
print(k, nums[:k]) # 2 [1, 2]
nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
k = removeDuplicates(nums)
print(k, nums[:k]) # 5 [0, 1, 2, 3, 4]
nums = [1, 1, 1]
k = removeDuplicates(nums)
print(k, nums[:k]) # 1 [1]
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Comparing nums[i] to nums[i-1] instead of nums[k-1]. Once you start overwriting elements, nums[i-1] might have already been changed. Always compare the fast pointer against what’s actually at the write head nums[k-1].
Initializing k = 0 and starting the loop at 0. The first element is always unique — there’s nothing to compare it against. Starting with k = 1 (the element at index 0 is already placed) and i = 1 avoids an unnecessary edge-case check.
Returning k vs. reading nums[:k]. k is a count, not an index. nums[:k] gives the correct slice — nums[:k-1] would miss the last unique element.
Two Sum II - Input Array Is Sorted
Difficulty: Medium Source: NeetCode
Problem
Given a 1-indexed array of integers
numbersthat is already sorted in non-decreasing order, find two numbers such that they add up to a specifictargetnumber. Return the indices of the two numbers as an integer array[index1, index2]where1 <= index1 < index2 <= numbers.length.There is exactly one solution, and you may not use the same element twice.
Example 1: Input:
numbers = [2,7,11,15],target = 9Output:[1,2]Example 2: Input:
numbers = [2,3,4],target = 6Output:[1,3]Example 3: Input:
numbers = [-1,0],target = -1Output:[1,2]Constraints:
2 <= numbers.length <= 3 * 10^4-1000 <= numbers[i] <= 1000numbersis sorted in non-decreasing order- Exactly one solution exists
Prerequisites
Before attempting this problem, you should be comfortable with:
- Two Sum (LeetCode 1) — the unsorted variant solved with a hash map
- Sorted Arrays — leveraging sorted order to make directional decisions
- Two Pointers — binary search intuition applied to a pair search
1. Brute Force
Intuition
Try every pair of indices and check whether their values sum to target. Since the problem guarantees exactly one answer, we’ll always find it — we’re just doing a lot of unnecessary work checking pairs that can’t possibly work.
Algorithm
- For
ifrom0tolen(numbers) - 2:- For
jfromi + 1tolen(numbers) - 1:- If
numbers[i] + numbers[j] == target: return[i + 1, j + 1].
- If
- For
Solution
def twoSum(numbers, target):
for i in range(len(numbers)):
for j in range(i + 1, len(numbers)):
if numbers[i] + numbers[j] == target:
return [i + 1, j + 1]
return []
print(twoSum([2, 7, 11, 15], 9)) # [1, 2]
print(twoSum([2, 3, 4], 6)) # [1, 3]
print(twoSum([-1, 0], -1)) # [1, 2]
Complexity
- Time:
O(n²) - Space:
O(1)
2. Two Pointers
Intuition
Because the array is sorted, we can make smart moves instead of checking everything. Start with left at the smallest element and right at the largest. If their sum equals target, we’re done. If the sum is too small, we need a bigger number — move left rightward to get a larger value. If the sum is too large, we need a smaller number — move right leftward. Each step eliminates one candidate, so we find the answer in a single pass.
Algorithm
- Initialize
left = 0,right = len(numbers) - 1. - While
left < right:- Compute
total = numbers[left] + numbers[right]. - If
total == target: return[left + 1, right + 1]. - If
total < target: incrementleft. - If
total > target: decrementright.
- Compute
Solution
def twoSum(numbers, target):
left, right = 0, len(numbers) - 1
while left < right:
total = numbers[left] + numbers[right]
if total == target:
return [left + 1, right + 1]
elif total < target:
left += 1
else:
right -= 1
return []
print(twoSum([2, 7, 11, 15], 9)) # [1, 2]
print(twoSum([2, 3, 4], 6)) # [1, 3]
print(twoSum([-1, 0], -1)) # [1, 2]
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Forgetting the 1-indexed output. The array is 0-indexed in Python but the problem wants 1-indexed answers. Add 1 to both left and right before returning.
Using a hash map (Two Sum style) when O(1) space is required. The hash map approach from the original Two Sum works here too and runs in O(n), but uses O(n) extra space. The two-pointer approach is better because the sorted order gives us direction for free.
Moving both pointers after a mismatch. Only move the pointer that makes the sum closer to target. Moving both simultaneously can skip the correct pair.
3Sum
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, return all the triplets[nums[i], nums[j], nums[k]]such thati != j,i != k,j != k, andnums[i] + nums[j] + nums[k] == 0.The solution set must not contain duplicate triplets.
Example 1: Input:
nums = [-1,0,1,2,-1,-4]Output:[[-1,-1,2],[-1,0,1]]Example 2: Input:
nums = [0,1,1]Output:[]Example 3: Input:
nums = [0,0,0]Output:[[0,0,0]]Constraints:
3 <= nums.length <= 3000-10^5 <= nums[i] <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Two Sum II — two-pointer search in a sorted array for a specific target
- Sorting — why sorting enables efficient duplicate skipping
- Two Pointers — applying two pointers as a subroutine inside a loop
1. Brute Force
Intuition
Try every combination of three distinct indices. If they sum to zero, add the sorted triplet to a set to deduplicate. Convert the set to a list at the end. It’s correct but painfully slow for large inputs — O(n³) from three nested loops.
Algorithm
- Initialize
resultas a set. - For every trio of indices
i < j < k:- If
nums[i] + nums[j] + nums[k] == 0, addtuple(sorted([nums[i], nums[j], nums[k]]))toresult.
- If
- Return
[list(t) for t in result].
Solution
def threeSum(nums):
result = set()
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
for k in range(j + 1, n):
if nums[i] + nums[j] + nums[k] == 0:
result.add(tuple(sorted([nums[i], nums[j], nums[k]])))
return [list(t) for t in result]
print(threeSum([-1, 0, 1, 2, -1, -4])) # [[-1, -1, 2], [-1, 0, 1]]
print(threeSum([0, 1, 1])) # []
print(threeSum([0, 0, 0])) # [[0, 0, 0]]
Complexity
- Time:
O(n³) - Space:
O(k)where k is the number of unique triplets
2. Sort + Two Pointers
Intuition
Sort the array first. Then fix one element nums[i] with an outer loop and reduce the problem to Two Sum II on the remaining subarray: find two numbers in nums[i+1 .. n-1] that sum to -nums[i]. Use two pointers (left = i + 1, right = n - 1) for that inner search.
The key insight for deduplication: because the array is sorted, duplicates are adjacent. After placing a triplet, skip over any repeated values for both the outer index and the inner pointers — that way each unique triplet is found exactly once.
Algorithm
- Sort
nums. - For
ifrom0ton - 3:- Skip if
nums[i] == nums[i - 1]andi > 0(duplicate outer element). - Set
left = i + 1,right = n - 1,target = -nums[i]. - While
left < right:- If
nums[left] + nums[right] == target: record triplet, advanceleftand retreatright, then skip duplicates on both sides. - If sum < target: increment
left. - If sum > target: decrement
right.
- If
- Skip if
- Return
result.
Solution
def threeSum(nums):
nums.sort()
result = []
n = len(nums)
for i in range(n - 2):
# Skip duplicate values for the fixed element
if i > 0 and nums[i] == nums[i - 1]:
continue
left, right = i + 1, n - 1
target = -nums[i]
while left < right:
total = nums[left] + nums[right]
if total == target:
result.append([nums[i], nums[left], nums[right]])
left += 1
right -= 1
# Skip duplicates for left and right pointers
while left < right and nums[left] == nums[left - 1]:
left += 1
while left < right and nums[right] == nums[right + 1]:
right -= 1
elif total < target:
left += 1
else:
right -= 1
return result
print(threeSum([-1, 0, 1, 2, -1, -4])) # [[-1, -1, 2], [-1, 0, 1]]
print(threeSum([0, 1, 1])) # []
print(threeSum([0, 0, 0])) # [[0, 0, 0]]
Complexity
- Time:
O(n²) - Space:
O(1)extra (output list not counted)
Common Pitfalls
Skipping duplicates in the wrong place. The outer duplicate skip (i > 0 and nums[i] == nums[i-1]) must use i > 0 as a guard, otherwise you’d skip index 0 altogether. The inner duplicate skips happen after recording a triplet, not before.
Early termination optimization. If nums[i] > 0 after sorting, all remaining elements are also positive, so no triplet can sum to zero. Adding if nums[i] > 0: break speeds things up in practice.
Modifying the input. nums.sort() sorts in-place. If the caller cares about the original order, sort a copy. In a typical interview, in-place sorting is fine.
4Sum
Difficulty: Medium Source: NeetCode
Problem
Given an array
numsofnintegers, return an array of all the unique quadruplets[nums[a], nums[b], nums[c], nums[d]]such thata,b,c,dare distinct indices andnums[a] + nums[b] + nums[c] + nums[d] == target.Example 1: Input:
nums = [1,0,-1,0,-2,2],target = 0Output:[[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]Example 2: Input:
nums = [2,2,2,2,2],target = 8Output:[[2,2,2,2]]Constraints:
1 <= nums.length <= 200-10^9 <= nums[i] <= 10^9-10^9 <= target <= 10^9
Prerequisites
Before attempting this problem, you should be comfortable with:
- 3Sum — sort + fix one element + two-pointer inner search
- Two Pointers — extending the 3Sum pattern by adding one more fixed element
- Duplicate Skipping — careful index advancement to avoid repeated quadruplets
1. Brute Force
Intuition
Enumerate every combination of four distinct indices, check if their values sum to target, and collect unique quadruplets. Sorting each found quadruplet and storing them in a set handles deduplication. Works, but four nested loops make it unusably slow for any real input.
Algorithm
- Initialize
resultas a set. - Use four nested loops over indices
a < b < c < d. - If the four values sum to
target, add the sorted tuple toresult. - Convert
resultto a list of lists and return.
Solution
def fourSum(nums, target):
result = set()
n = len(nums)
for a in range(n):
for b in range(a + 1, n):
for c in range(b + 1, n):
for d in range(c + 1, n):
if nums[a] + nums[b] + nums[c] + nums[d] == target:
result.add(tuple(sorted([nums[a], nums[b], nums[c], nums[d]])))
return [list(t) for t in result]
print(fourSum([1, 0, -1, 0, -2, 2], 0)) # [[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]]
print(fourSum([2, 2, 2, 2, 2], 8)) # [[2, 2, 2, 2]]
Complexity
- Time:
O(n⁴) - Space:
O(k)where k is the number of unique quadruplets
2. Sort + Two Fixed Pointers + Two Pointers
Intuition
4Sum is just 3Sum with one more outer loop. Sort the array, then fix the first element with index i and the second with index j. For the remaining two elements, run the familiar two-pointer search on nums[j+1 .. n-1]. The duplicate-skipping logic from 3Sum applies independently to i, j, and both inner pointers.
Algorithm
- Sort
nums. - For
ifrom0ton - 4:- Skip if
i > 0 and nums[i] == nums[i - 1]. - For
jfromi + 1ton - 3:- Skip if
j > i + 1 and nums[j] == nums[j - 1]. - Set
left = j + 1,right = n - 1. - While
left < right:- Compute
total = nums[i] + nums[j] + nums[left] + nums[right]. - If equal to
target: record quadruplet, advanceleft/retreatright, skip duplicates. - If
total < target: incrementleft. - If
total > target: decrementright.
- Compute
- Skip if
- Skip if
- Return
result.
Solution
def fourSum(nums, target):
nums.sort()
result = []
n = len(nums)
for i in range(n - 3):
if i > 0 and nums[i] == nums[i - 1]:
continue
for j in range(i + 1, n - 2):
if j > i + 1 and nums[j] == nums[j - 1]:
continue
left, right = j + 1, n - 1
while left < right:
total = nums[i] + nums[j] + nums[left] + nums[right]
if total == target:
result.append([nums[i], nums[j], nums[left], nums[right]])
left += 1
right -= 1
while left < right and nums[left] == nums[left - 1]:
left += 1
while left < right and nums[right] == nums[right + 1]:
right -= 1
elif total < target:
left += 1
else:
right -= 1
return result
print(fourSum([1, 0, -1, 0, -2, 2], 0)) # [[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]]
print(fourSum([2, 2, 2, 2, 2], 8)) # [[2, 2, 2, 2]]
print(fourSum([0, 0, 0, 0], 0)) # [[0, 0, 0, 0]]
Complexity
- Time:
O(n³) - Space:
O(1)extra (output list not counted)
Common Pitfalls
Inner duplicate skip guard for j. The guard must be j > i + 1 (not j > 0) — otherwise you skip the very first j for each i.
Integer overflow with large values. The problem allows values up to 10^9 and target up to 10^9, so the sum of four numbers can reach 4 * 10^9. Python handles arbitrary-precision integers natively, but in languages like C++ or Java you’d need long to avoid overflow.
Thinking this generalizes to O(n²). 4Sum is inherently O(n³) — we have two fixed outer loops (O(n²)) and a two-pointer inner loop (O(n)). There is no known sub-cubic solution for the general case.
Rotate Array
Difficulty: Medium Source: NeetCode
Problem
Given an integer array
nums, rotate the array to the right byksteps, wherekis non-negative. Do this in-place withO(1)extra space.Example 1: Input:
nums = [1,2,3,4,5,6,7],k = 3Output:[5,6,7,1,2,3,4]Example 2: Input:
nums = [-1,-100,3,99],k = 2Output:[3,99,-1,-100]Constraints:
1 <= nums.length <= 10^5-2^31 <= nums[i] <= 2^31 - 10 <= k <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Arrays — in-place modification and slicing
- Two Pointers — the reverse trick for rotating sequences
- Modular Arithmetic — handling
k >= len(nums)withk % n
1. Brute Force
Intuition
Allocate a new array of the same size. Each element at index i ends up at index (i + k) % n after rotation. Fill the new array using this mapping, then copy it back into nums. Simple and correct, but uses O(n) extra space.
Algorithm
- Compute
n = len(nums)andk = k % n. - Create
rotated = [0] * n. - For each
i, setrotated[(i + k) % n] = nums[i]. - Copy
rotatedback intonums.
Solution
def rotate(nums, k):
n = len(nums)
k = k % n
rotated = [0] * n
for i in range(n):
rotated[(i + k) % n] = nums[i]
for i in range(n):
nums[i] = rotated[i]
nums = [1, 2, 3, 4, 5, 6, 7]
rotate(nums, 3)
print(nums) # [5, 6, 7, 1, 2, 3, 4]
nums = [-1, -100, 3, 99]
rotate(nums, 2)
print(nums) # [3, 99, -1, -100]
Complexity
- Time:
O(n) - Space:
O(n)
2. Three Reverses
Intuition
Here’s the clever trick: rotating right by k is equivalent to:
- Reversing the entire array.
- Reversing the first
kelements. - Reversing the last
n - kelements.
Try it on [1,2,3,4,5,6,7] with k=3:
- Reverse all →
[7,6,5,4,3,2,1] - Reverse first 3 →
[5,6,7,4,3,2,1] - Reverse last 4 →
[5,6,7,1,2,3,4]✓
Each reversal is done with two pointers (swap from both ends), so the whole operation is O(n) time and O(1) space.
Algorithm
- Compute
k = k % nto handlek >= n. - Define
reverse(nums, left, right)— swap elements inward untilleft >= right. - Call
reverse(nums, 0, n - 1)— reverse entire array. - Call
reverse(nums, 0, k - 1)— reverse firstkelements. - Call
reverse(nums, k, n - 1)— reverse lastn - kelements.
Solution
def rotate(nums, k):
n = len(nums)
k = k % n
def reverse(left, right):
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
reverse(0, n - 1) # Reverse the whole array
reverse(0, k - 1) # Reverse the first k elements
reverse(k, n - 1) # Reverse the remaining n-k elements
nums = [1, 2, 3, 4, 5, 6, 7]
rotate(nums, 3)
print(nums) # [5, 6, 7, 1, 2, 3, 4]
nums = [-1, -100, 3, 99]
rotate(nums, 2)
print(nums) # [3, 99, -1, -100]
nums = [1, 2]
rotate(nums, 3)
print(nums) # [2, 1]
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Not reducing k with modulo. If k >= n, the rotation wraps around. k = k % n handles this — and if k becomes 0, no rotation is needed (all three reverses would be no-ops anyway).
Off-by-one in the second and third reversal bounds. The first k elements span indices 0 to k - 1 (inclusive). The remaining elements span k to n - 1. Getting one of these bounds wrong produces a partially-rotated array that looks almost correct.
Using nums[:] = nums[-k:] + nums[:-k] and thinking it’s O(1). Python slice assignment creates new list objects, so this is O(n) space despite looking clean. It’s a valid one-liner for interviews where space complexity isn’t tested, but don’t claim it’s in-place.
Container With Most Water
Difficulty: Medium Source: NeetCode
Problem
You are given an integer array
heightof lengthn. There arenvertical lines drawn such that the two endpoints of thei-th line are(i, 0)and(i, height[i]).Find two lines that together with the x-axis form a container that holds the most water. Return the maximum amount of water a container can store.
Note: You may not slant the container.
Example 1: Input:
height = [1,8,6,2,5,4,8,3,7]Output:49Example 2: Input:
height = [1,1]Output:1Constraints:
n == height.length2 <= n <= 10^50 <= height[i] <= 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Area Calculation — water held =
min(height[left], height[right]) * (right - left) - Two Pointers — greedy reasoning about which pointer to move inward
- Greedy Thinking — why moving the taller side inward can never improve the result
1. Brute Force
Intuition
Try every pair of lines and compute how much water they can hold. The water volume between two lines at indices i and j is min(height[i], height[j]) * (j - i). Track the maximum across all pairs.
Algorithm
- Initialize
max_water = 0. - For each pair
(i, j)withi < j:- Compute
water = min(height[i], height[j]) * (j - i). - Update
max_water = max(max_water, water).
- Compute
- Return
max_water.
Solution
def maxArea(height):
max_water = 0
n = len(height)
for i in range(n):
for j in range(i + 1, n):
water = min(height[i], height[j]) * (j - i)
max_water = max(max_water, water)
return max_water
print(maxArea([1, 8, 6, 2, 5, 4, 8, 3, 7])) # 49
print(maxArea([1, 1])) # 1
print(maxArea([4, 3, 2, 1, 4])) # 16
Complexity
- Time:
O(n²) - Space:
O(1)
2. Two Pointers (Greedy)
Intuition
Start with the widest possible container: left at index 0 and right at the last index. The water is limited by the shorter of the two walls. If we move the taller wall inward, the width decreases but the limiting height stays the same (or gets worse) — we can only possibly improve by moving the shorter wall inward, hoping to find a taller one that compensates for the lost width. So always move the pointer pointing to the shorter line inward.
Algorithm
- Initialize
left = 0,right = n - 1,max_water = 0. - While
left < right:- Compute
water = min(height[left], height[right]) * (right - left). - Update
max_water. - If
height[left] <= height[right]: incrementleft. - Else: decrement
right.
- Compute
- Return
max_water.
Solution
def maxArea(height):
left, right = 0, len(height) - 1
max_water = 0
while left < right:
water = min(height[left], height[right]) * (right - left)
max_water = max(max_water, water)
if height[left] <= height[right]:
left += 1
else:
right -= 1
return max_water
print(maxArea([1, 8, 6, 2, 5, 4, 8, 3, 7])) # 49
print(maxArea([1, 1])) # 1
print(maxArea([4, 3, 2, 1, 4])) # 16
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Moving the taller pointer inward. This is the classic mistake. When you move the taller side, the new width is smaller and the bottleneck height is still bounded by the shorter side — you can’t gain anything. Always move the shorter side.
Using height[left] < height[right] instead of <=. When both heights are equal, it doesn’t matter which pointer you move — either choice is fine. Using <= for the left pointer (or >= for the right) handles ties correctly.
Forgetting to compute water before moving. Compute and potentially update max_water first, then decide which pointer to move. Moving first and computing after skips the current configuration.
Boats to Save People
Difficulty: Medium Source: NeetCode
Problem
You are given an array
peoplewherepeople[i]is the weight of thei-th person, and an infinite number of boats where each boat can carry a maximum weight oflimit. Each boat carries at most two people at the same time, provided the sum of the weight of those people is at mostlimit.Return the minimum number of boats to carry every given person.
Example 1: Input:
people = [1,2],limit = 3Output:1Example 2: Input:
people = [3,2,2,1],limit = 3Output:3Example 3: Input:
people = [3,5,3,4],limit = 5Output:4Constraints:
1 <= people.length <= 5 * 10^41 <= people[i] <= limit <= 3 * 10^4
Prerequisites
Before attempting this problem, you should be comfortable with:
- Greedy Algorithms — pairing the lightest and heaviest to minimize wasted capacity
- Sorting — enabling two-pointer pairing
- Two Pointers — simultaneously processing from both ends of a sorted array
1. Brute Force
Intuition
Sort the people by weight. For each heaviest remaining person, try to find the heaviest other person they can share a boat with (greedy pairing). This avoids the two-pointer elegance but still achieves a correct result. It’s O(n²) because each pairing scan iterates through the remaining people.
Algorithm
- Sort
people. - Use a
usedset to track who has boarded. - For each person (heaviest first, iterating from the right):
- If already used, skip.
- Try to find the heaviest unused person on the left side who fits.
- Either way, assign a boat and mark both as used.
- Return the boat count.
Solution
def numRescueBoats(people, limit):
people.sort()
n = len(people)
used = [False] * n
boats = 0
for i in range(n - 1, -1, -1):
if used[i]:
continue
boats += 1
used[i] = True
# Try to pair with the lightest unused person
for j in range(n):
if not used[j] and people[i] + people[j] <= limit:
used[j] = True
break
return boats
print(numRescueBoats([1, 2], 3)) # 1
print(numRescueBoats([3, 2, 2, 1], 3)) # 3
print(numRescueBoats([3, 5, 3, 4], 5)) # 4
Complexity
- Time:
O(n²) - Space:
O(n)
2. Sort + Two Pointers
Intuition
Sort the array. Now greedily try to pair the lightest person (left pointer) with the heaviest person (right pointer). If they fit together on one boat, great — both board and we move both pointers inward. If they don’t fit, the heaviest person takes a boat alone (no one lighter can fit with them since we already tried the lightest option). Either way, the right pointer moves left. Count one boat per iteration.
Algorithm
- Sort
people. - Initialize
left = 0,right = len(people) - 1,boats = 0. - While
left <= right:- If
people[left] + people[right] <= limit: incrementleft(both board together). - Decrement
right(heaviest person always boards). - Increment
boats.
- If
- Return
boats.
Solution
def numRescueBoats(people, limit):
people.sort()
left, right = 0, len(people) - 1
boats = 0
while left <= right:
if people[left] + people[right] <= limit:
left += 1 # Lightest person shares the boat
right -= 1 # Heaviest person always takes a boat
boats += 1
return boats
print(numRescueBoats([1, 2], 3)) # 1
print(numRescueBoats([3, 2, 2, 1], 3)) # 3
print(numRescueBoats([3, 5, 3, 4], 5)) # 4
print(numRescueBoats([2, 2], 6)) # 1
Complexity
- Time:
O(n log n)— dominated by sorting - Space:
O(1)
Common Pitfalls
Using left < right instead of left <= right. When only one person remains (left == right), they still need a boat. The <= ensures the last person is counted.
Moving right only when a pair is found. The heaviest person always takes a boat regardless of whether they share it — the right pointer decrements unconditionally. Only left advances conditionally.
Trying to pair the heaviest person with the second-heaviest. The greedy insight is to pair the heaviest with the lightest. Pairing two heavy people often wastes capacity and increases the total boat count.
Trapping Rain Water
Difficulty: Hard Source: NeetCode
Problem
Given
nnon-negative integers representing an elevation map where the width of each bar is1, compute how much water it can trap after raining.Example 1: Input:
height = [0,1,0,2,1,0,1,3,2,1,2,1]Output:6Example 2: Input:
height = [4,2,0,3,2,5]Output:9Constraints:
n == height.length1 <= n <= 2 * 10^40 <= height[i] <= 10^5
Prerequisites
Before attempting this problem, you should be comfortable with:
- Prefix/Suffix Arrays — precomputing running maximums from both ends
- Two Pointers — processing from both ends and choosing which side to advance
- Water Level Intuition — water at position
i=min(max_left, max_right) - height[i]
1. Brute Force
Intuition
For every bar, the water above it is determined by the shortest of the tallest walls on its left and its right. Specifically: water[i] = min(max_left[i], max_right[i]) - height[i]. In the brute force version, we compute those two maxima by scanning left and right from every position — leading to O(n²) time.
Algorithm
- Initialize
total = 0. - For each index
ifrom0ton - 1:- Scan left to find
max_left = max(height[0..i]). - Scan right to find
max_right = max(height[i..n-1]). - Add
min(max_left, max_right) - height[i]tototal.
- Scan left to find
- Return
total.
Solution
def trap(height):
n = len(height)
total = 0
for i in range(n):
max_left = max(height[:i + 1])
max_right = max(height[i:])
total += min(max_left, max_right) - height[i]
return total
print(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])) # 6
print(trap([4, 2, 0, 3, 2, 5])) # 9
Complexity
- Time:
O(n²) - Space:
O(1)
2. Prefix/Suffix Max Arrays
Intuition
We’re recomputing the same maxima over and over in the brute force. Precompute them once. Build max_left[i] = maximum height from index 0 to i, and max_right[i] = maximum height from index i to n-1. Then a single pass accumulates the water.
Algorithm
- Build
max_left: scan left to right,max_left[i] = max(max_left[i-1], height[i]). - Build
max_right: scan right to left,max_right[i] = max(max_right[i+1], height[i]). - For each
i, addmin(max_left[i], max_right[i]) - height[i]tototal.
Solution
def trap(height):
n = len(height)
max_left = [0] * n
max_right = [0] * n
max_left[0] = height[0]
for i in range(1, n):
max_left[i] = max(max_left[i - 1], height[i])
max_right[n - 1] = height[n - 1]
for i in range(n - 2, -1, -1):
max_right[i] = max(max_right[i + 1], height[i])
total = 0
for i in range(n):
total += min(max_left[i], max_right[i]) - height[i]
return total
print(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])) # 6
print(trap([4, 2, 0, 3, 2, 5])) # 9
Complexity
- Time:
O(n) - Space:
O(n)
3. Two Pointers (Optimal)
Intuition
Can we avoid the extra arrays? Yes — with two pointers. The key observation: at any position, the water trapped depends on min(max_left, max_right). If the max we’ve seen from the left is smaller than the max we’ve seen from the right, the left side is the bottleneck — we can safely compute water for the left pointer without knowing the exact max_right (because whatever it is, it’s at least as large as max_right so far). Process whichever side has the smaller running maximum, advance that pointer, and keep track of max_left and max_right as you go.
Algorithm
- Initialize
left = 0,right = n - 1,max_left = 0,max_right = 0,total = 0. - While
left <= right:- If
max_left <= max_right:- Update
max_left = max(max_left, height[left]). - Add
max_left - height[left]tototal. - Increment
left.
- Update
- Else:
- Update
max_right = max(max_right, height[right]). - Add
max_right - height[right]tototal. - Decrement
right.
- Update
- If
- Return
total.
Solution
def trap(height):
left, right = 0, len(height) - 1
max_left, max_right = 0, 0
total = 0
while left <= right:
if max_left <= max_right:
max_left = max(max_left, height[left])
total += max_left - height[left]
left += 1
else:
max_right = max(max_right, height[right])
total += max_right - height[right]
right -= 1
return total
print(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])) # 6
print(trap([4, 2, 0, 3, 2, 5])) # 9
print(trap([3, 0, 2, 0, 4])) # 7
Complexity
- Time:
O(n) - Space:
O(1)
Common Pitfalls
Updating max_left/max_right before subtracting. You must update the running max first (max_left = max(max_left, height[left])), then compute the water (max_left - height[left]). If you subtract first, you could get a negative value for the bar that sets a new maximum.
Confusing which pointer to advance. Advance the side with the smaller running max — that’s the bottleneck side whose water level is already determined. The other side’s max can only be higher, so the water formula is safe to apply.
Using left < right instead of left <= right. When left == right, both pointers are on the same bar and need to be processed. Using < skips the middle element and can undercount water in odd-length arrays.
Negative water values. Each bar can only trap water if it’s lower than both surrounding walls. The formula min(max_left, max_right) - height[i] is always non-negative because both maxima include height[i] itself. If you see a negative contribution, you’ve got the max computation wrong.
Programming Languages
Reference material and tutorials for programming languages used throughout this notebook.
Languages
- Python — syntax, control flow, functions, and tools for problem solving
- SQL — querying, DDL/DML, joins, window functions, and aggregations
Python Language Track
This track is intentionally short and practical. It focuses on the subset of Python that helps most when writing DSA solutions.
Chapters
Introduction
Python is a strong language for DSA practice because the syntax stays small while the standard data structures stay powerful.
What matters most for DSA work
- Variables are dynamically typed.
- Lists, sets, dictionaries, and strings are built in.
- Indentation defines code blocks.
- Readability is a feature, not just style.
name = "Python"
year = 1991
is_good_for_dsa = True
print(name)
print(year)
print(is_good_for_dsa)
print(type(name), type(year), type(is_good_for_dsa))
Primitive values you will use often
count = 5 # int
ratio = 2.5 # float
title = "graph" # str
active = False # bool
nothing = None # NoneType
print(count, ratio, title, active, nothing)
Mutability matters
Lists and dictionaries are mutable. Strings and tuples are not.
numbers = [1, 2, 3]
numbers[1] = 99
word = "stack"
updated = word.upper()
print(numbers)
print(word)
print(updated)
A small habit that helps
Name things after meaning, not after syntax.
- Better:
left,right,window_sum,visited - Worse:
a,b,temp2,x1
That habit makes two-pointer, recursion, and graph code much easier to debug.
Control Flow and Loops
Most DSA solutions are built from a small set of control-flow patterns.
Conditions
value = 7
if value % 2 == 0:
print("even")
elif value % 3 == 0:
print("divisible by 3")
else:
print("other")
for loops
Use for when you know the range or want to iterate directly over values.
nums = [4, 8, 15, 16]
for x in nums:
print("value:", x)
for i in range(len(nums)):
print("index:", i, "value:", nums[i])
while loops
Use while when the loop condition changes dynamically, such as with binary search or fast/slow pointers.
left = 0
right = 5
while left < right:
mid = (left + right) // 2
print(left, mid, right)
left += 1
Loop helpers
enumerate and zip keep code cleaner than manual indexing.
letters = ["a", "b", "c"]
scores = [10, 20, 30]
for i, ch in enumerate(letters):
print(i, ch)
for ch, score in zip(letters, scores):
print(ch, score)
Pattern intuition
- Traversal of an array:
for i in range(n) - Two pointers:
while left < right - Repeated processing until empty:
while stack - BFS queue processing:
while queue
Functions, Classes, and Exceptions
You do not need advanced Python to solve most DSA problems, but you do need a few essentials.
Functions
def gcd(a, b):
while b:
a, b = b, a % b
return a
print(gcd(18, 24))
Type hints
Type hints improve readability even when the interpreter does not enforce them.
from typing import List
def prefix_sums(nums: List[int]) -> List[int]:
result = []
running = 0
for x in nums:
running += x
result.append(running)
return result
print(prefix_sums([1, 2, 3, 4]))
Small classes
Classes matter most in DSA when we model nodes and custom structures.
class Node:
def __init__(self, value):
self.value = value
self.next = None
head = Node(10)
head.next = Node(20)
print(head.value, head.next.value)
Exceptions
Raising a clear error is often better than silently returning the wrong result.
def top(stack):
if not stack:
raise IndexError("stack is empty")
return stack[-1]
print(top([5, 7, 9]))
A practical rule
- Use functions for algorithms.
- Use classes for linked lists, trees, graphs, and reusable structures.
- Raise exceptions when an invalid operation should be explicit.
Python Tools for DSA
This is the Python toolkit that shows up again and again in DSA solutions.
Lists
nums = [10, 20, 30]
nums.append(40)
last = nums.pop()
print(nums)
print(last)
print(nums[0], nums[-1])
Dictionaries
counts = {}
for ch in "banana":
counts[ch] = counts.get(ch, 0) + 1
print(counts)
Sets
seen = set()
for x in [3, 1, 4, 1, 5]:
if x in seen:
print("duplicate:", x)
break
seen.add(x)
Tuples
Tuples are useful when you want an immutable pair like (row, col) or (distance, node).
point = (2, 5)
row, col = point
print(row, col)
Useful built-ins
nums = [7, 2, 9, 4]
print("sorted:", sorted(nums))
print("min:", min(nums))
print("max:", max(nums))
print("sum:", sum(nums))
Interview habit
Before writing a custom data structure in Python, ask:
- Can
listsolve this? - Do I need a
setfor membership? - Do I need a
dictfor lookup? - Is a tuple the cleanest key or state representation?
SQL Notes
Browser execution note: Most
SELECTqueries in this section can now run directly in the browser against an in-memory SQLite database with preloaded sample data such ascustomers,orders, andSales.*tables. SQL Server setup scripts and T-SQL-specific syntax are still reference-only. That includes commands and features likeGO,USE,CREATE DATABASE,ALTER DATABASE,DROP DATABASE, and some SQL Server-only functions such asFORMAT().
Table of Content
- Introduction
1. SQL Foundations – Introduction, SELECT, DDL, DML & Filtering Data
Browser execution note: Most query examples in these notes can be edited and run directly in the browser using an in-memory SQLite database loaded with sample tables like
customers,orders,Sales.Customers,Sales.Employees,Sales.Products,Sales.Orders, andSales.OrdersArchive. SQL Server database bootstrap scripts and T-SQL-specific syntax remain reference-only. Examples usingGO,USE,CREATE DATABASE,ALTER DATABASE,DROP DATABASE, or SQL Server-only features may need small syntax changes before they run in the browser.
1. Summary
Core building blocks of SQL:
- What databases and tables are.
- How to define tables (DDL).
- How to manipulate data (DML).
- How to query data with
SELECT. - How to filter, group, sort, and limit results.
Examples are based on tables like customers, orders, and persons from below.
2. Big Picture – Where This Fits in SQL
2.1 Database & SQL Overview
Think of the structure like this:
- Server → hosts one or more databases.
- Database → container for related data (e.g., Sales, HR).
- Schema → subgroup inside a database (e.g.,
Orders,Customers). - Table → grid with columns (fields) and rows (records).
- Column → attribute of data (e.g.,
id,name,birthdate); each has a data type. - Row → one record (e.g., one customer).
- Primary Key → column(s) that uniquely identify each row (e.g.,
id).
SQL is the language we use to talk to this data.
SQL commands are grouped into 3 big families:
-
DQL – Data Query Language
SELECT→ read/query data.
-
DDL – Data Definition Language
CREATE,ALTER,DROP→ define or change table structure.
-
DML – Data Manipulation Language
INSERT,UPDATE,DELETE→ modify data inside tables.
2.2 Logical Query Execution Order (How SQL “Thinks”)
We write a query like this:
SELECT ...
FROM ...
WHERE ...
GROUP BY ...
HAVING ...
ORDER BY ...
TOP / LIMIT ...
But internally, SQL logically processes in this order:
FROM(and joins – later)WHEREGROUP BY- Aggregates (e.g.,
SUM,AVG,COUNT) HAVINGSELECT(columns, expressions, aliases)DISTINCTORDER BYTOP/LIMIT(engine-dependent)
Why this matters:
- You cannot use aggregates in
WHERE(they don’t exist yet). HAVINGfilters groups, afterGROUP BY.- Some aliases from
SELECTcan be used inORDER BYbut not inWHERE.
Mnemonic:
“Fresh Water Gets Athletes Hydrated So Don’t Over Train.”
“Fat Whales Go Around Heavy Ships Diving Over Tides.”
FROM → WHERE → GROUP BY → Aggregates → HAVING → SELECT → DISTINCT → ORDER BY → TOP
3. Key Concepts & Definitions
3.1 SQL Components in a Statement
Example:
-- Retrieve Customers Data
SELECT name, LOWER(country)
FROM customers
WHERE country = 'Italy';
- Comment:
-- Retrieve Customers Data - Clauses:
SELECT,FROM,WHERE - Keywords:
SELECT,FROM,WHERE(reserved words) - Function:
LOWER(country)– built-in function applied to values - Identifiers:
name,country,customers– table/column names - Operator:
= - Value (literal):
'Italy'
3.2 DDL vs DML vs DQL – Quick Table
| Category | Name | Purpose | Examples |
|---|---|---|---|
| DQL | Data Query Language | Read/query data | SELECT |
| DDL | Data Definition Language | Define/change table structure | CREATE, ALTER, DROP |
| DML | Data Manipulation Language | Insert/update/delete table rows | INSERT, UPDATE, DELETE |
3.3 WHERE Operators (Filtering)
- Comparison:
=,<>or!=,>,>=,<,<= - Logical:
AND,OR,NOT - Range:
BETWEEN - Membership:
IN,NOT IN - Pattern:
LIKE(with wildcards%and_)
3.4 Function Toolbox
| Category | Function | Description | Example |
|---|---|---|---|
| String | LOWER() | Convert text to lowercase | LOWER(country) |
| Aggregate | SUM() | Add up numeric values | SUM(score) |
| Aggregate | COUNT() | Count rows / non-NULL values | COUNT(id) |
| Aggregate | AVG() | Average numeric values | AVG(score) |
You can extend this table later as you learn more functions.
4. Syntax Cheat-Sheet
4.1 Core SELECT Patterns
-- Basic SELECT all columns
SELECT *
FROM table_name;
-- Select specific columns
SELECT column1, column2, ...
FROM table_name;
-- SELECT with WHERE filter
SELECT column1, column2, ...
FROM table_name
WHERE condition;
-- ORDER BY for sorting
SELECT column1, column2, ...
FROM table_name
ORDER BY column1 ASC, column2 DESC;
-- GROUP BY with aggregates
SELECT group_column,
AGG_FUNC(other_column) AS alias
FROM table_name
GROUP BY group_column;
-- GROUP BY with HAVING (filter groups)
SELECT group_column,
AGG_FUNC(other_column) AS alias
FROM table_name
GROUP BY group_column
HAVING AGG_FUNC(other_column) condition;
-- DISTINCT values
SELECT DISTINCT column1
FROM table_name;
-- TOP N rows (T-SQL style)
SELECT TOP N *
FROM table_name
ORDER BY column_to_rank DESC;
4.2 DDL – Defining Table Structure
-- CREATE table
CREATE TABLE table_name (
column1 DATA_TYPE [constraints],
column2 DATA_TYPE [constraints],
...
CONSTRAINT constraint_name PRIMARY KEY (column1)
);
-- ALTER table - add column
ALTER TABLE table_name
ADD new_column DATA_TYPE [constraints];
-- ALTER table - drop column
ALTER TABLE table_name
DROP COLUMN column_name;
-- DROP table
DROP TABLE table_name;
4.3 DML – Manipulating Data
-- INSERT values (explicit columns)
INSERT INTO table_name (column1, column2, ...)
VALUES
(value1, value2, ...),
(value1b, value2b, ...); -- multiple rows
-- INSERT using SELECT
INSERT INTO target_table (col1, col2, ...)
SELECT colA, colB, ...
FROM source_table
WHERE ...;
-- UPDATE rows
UPDATE table_name
SET column1 = value1,
column2 = value2
WHERE condition; -- always use WHERE unless you want all rows
-- DELETE rows
DELETE FROM table_name
WHERE condition;
-- Delete all rows (fast)
TRUNCATE TABLE table_name;
4.4 WHERE & Filtering Patterns
-- Comparison
WHERE column = value
WHERE column <> value
WHERE column > value
WHERE column >= value
WHERE column < value
WHERE column <= value
-- Logical
WHERE condition1 AND condition2
WHERE condition1 OR condition2
WHERE NOT condition
-- BETWEEN (inclusive)
WHERE column BETWEEN lower AND upper
-- IN / NOT IN
WHERE column IN (value1, value2, ...)
WHERE column NOT IN (value1, value2, ...)
-- LIKE patterns
WHERE text_column LIKE 'M%' -- starts with M
WHERE text_column LIKE '%n' -- ends with n
WHERE text_column LIKE '%r%' -- contains r
WHERE text_column LIKE '__r%' -- r in 3rd character position
5. Worked Examples (With Annotations)
5.1 SELECT – All Columns vs Few Columns
All customers:
-- Retrieve All Customer Data
SELECT *
FROM customers;
SELECT *→ return every column fromcustomers.- Good for quick exploration, but in real queries you usually list columns explicitly.
Specific columns:
-- Retrieve each customer's name, country, and score.
SELECT
first_name,
country,
score
FROM customers;
- Only returns 3 columns – clearer and often more efficient.
5.2 WHERE – Basic Filtering
Score not equal to 0:
-- Retrieve customers with a score not equal to 0
SELECT *
FROM customers
WHERE score != 0;
!=or<>→ “not equal to”.
Country equals Germany:
-- Retrieve customers from Germany
SELECT *
FROM customers
WHERE country = 'Germany';
Columns + filter combined:
-- Retrieve the name and country of customers from Germany
SELECT
first_name,
country
FROM customers
WHERE country = 'Germany';
5.3 ORDER BY – Sorting
/* Retrieve all customers and
sort the results by the highest score first. */
SELECT *
FROM customers
ORDER BY score DESC;
DESC→ descending (high to low).
/* Retrieve all customers and
sort the results by the country and then by the highest score. */
SELECT *
FROM customers
ORDER BY country ASC, score DESC;
- First sort by
country(A–Z), - Then, within each country, sort by
scorefrom high to low.
5.4 GROUP BY & HAVING – Aggregation
Total score per country:
-- Find the total score for each country
SELECT
country,
SUM(score) AS total_score
FROM customers
GROUP BY country;
SUM(score)→ adds upscorefor each country.GROUP BY country→ one row per country.
Invalid example (on purpose):
SELECT
country,
first_name,
SUM(score) AS total_score
FROM customers
GROUP BY country;
-
This fails because
first_nameis neither:- in
GROUP BY, nor - inside an aggregate like
SUM.
- in
-
SQL doesn’t know which
first_nameto show for each group.
Total score & customer count per country:
SELECT
country,
SUM(score) AS total_score,
COUNT(id) AS total_customers
FROM customers
GROUP BY country;
Using HAVING – Filter groups after aggregation:
/* Find the average score for each country
and return only those countries with an average score greater than 430 */
SELECT
country,
AVG(score) AS avg_score
FROM customers
GROUP BY country
HAVING AVG(score) > 430;
Adds a row-level filter first:
/* considering only customers with a score not equal to 0 */
SELECT
country,
AVG(score) AS avg_score
FROM customers
WHERE score != 0
GROUP BY country
HAVING AVG(score) > 430;
WHERElimits rows before grouping.HAVINGlimits groups after aggregating.
5.5 DISTINCT & TOP
-- Return unique list of all countries
SELECT DISTINCT country
FROM customers;
-- Retrieve the Top 3 Customers with the Highest Scores
SELECT TOP 3 *
FROM customers
ORDER BY score DESC;
- Always use
TOPwithORDER BYif you want the “top N” in a meaningful way.
5.6 DDL – CREATE / ALTER / DROP
/* Create a new table called persons
with columns: id, person_name, birth_date, and phone */
CREATE TABLE persons (
id INT NOT NULL,
person_name VARCHAR(50) NOT NULL,
birth_date DATE,
phone VARCHAR(15) NOT NULL,
CONSTRAINT pk_persons PRIMARY KEY (id)
);
-- Add a new column called email to the persons table
ALTER TABLE persons
ADD email VARCHAR(50) NOT NULL;
-- Remove the column phone from the persons table
ALTER TABLE persons
DROP COLUMN phone;
-- Delete the table persons from the database
DROP TABLE persons;
DROP TABLEremoves structure + data permanently.
5.7 DML – INSERT, UPDATE, DELETE
INSERT – explicit columns:
-- Insert new records into the customers table
INSERT INTO customers (id, first_name, country, score)
VALUES
(6, 'Anna', 'USA', NULL),
(7, 'Sam', NULL, 100);
- Two rows inserted.
NULL→ missing/unknown.
Incorrect type example:
-- Incorrect data type in values
INSERT INTO customers (id, first_name, country, score)
VALUES
('Max', 9, 'Max', NULL);
'Max'whereid INTis expected → error.
No column list (not recommended):
INSERT INTO customers
VALUES
(9, 'Andreas', 'Germany', NULL);
- Values must match all columns in exact table order – fragile when table changes.
Partial columns (others become NULL or default):
INSERT INTO customers (id, first_name)
VALUES
(10, 'Sahra');
INSERT using SELECT (move data):
-- Copy data from the 'customers' table into 'persons'
INSERT INTO persons (id, person_name, birth_date, phone)
SELECT
id,
first_name,
NULL,
'Unknown'
FROM customers;
UPDATE:
-- Change the score of customer with ID 6 to 0
UPDATE customers
SET score = 0
WHERE id = 6;
-- Change the score of customer with ID 10 to 0 and update the country to 'UK'
UPDATE customers
SET score = 0,
country = 'UK'
WHERE id = 10;
-- Update all customers with a NULL score by setting their score to 0
UPDATE customers
SET score = 0
WHERE score IS NULL;
- Always use
IS NULL/IS NOT NULL, not= NULL.
DELETE & TRUNCATE:
-- Delete all customers with an ID greater than 5
DELETE FROM customers
WHERE id > 5;
-- Delete all data from the persons table
DELETE FROM persons;
-- Faster method to delete all rows
TRUNCATE TABLE persons;
DELETEwithoutWHEREdeletes all rows.TRUNCATE TABLEis faster but also removes all rows; structure stays.
5.8 Filtering Examples (Comparison, Logical, BETWEEN, IN, LIKE)
Comparison & logical:
-- Retrieve all customers with a score greater than 500.
SELECT *
FROM customers
WHERE score > 500;
-- from USA and score > 500
SELECT *
FROM customers
WHERE country = 'USA' AND score > 500;
-- from USA or score > 500
SELECT *
FROM customers
WHERE country = 'USA' OR score > 500;
-- score not less than 500 (i.e. >= 500)
SELECT *
FROM customers
WHERE NOT score < 500;
BETWEEN (inclusive):
-- score between 100 and 500 (inclusive)
SELECT *
FROM customers
WHERE score BETWEEN 100 AND 500;
IN:
-- from Germany or USA
SELECT *
FROM customers
WHERE country IN ('Germany', 'USA');
LIKE patterns:
-- first name starts with 'M'
SELECT *
FROM customers
WHERE first_name LIKE 'M%';
-- ends with 'n'
SELECT *
FROM customers
WHERE first_name LIKE '%n';
-- contains 'r'
SELECT *
FROM customers
WHERE first_name LIKE '%r%';
-- 'r' in third position
SELECT *
FROM customers
WHERE first_name LIKE '__r%';
%= any number of characters (including zero)._= exactly one character.
6. Common Mistakes, Tips & Error Patterns
6.1 Typical Mistakes & How to Avoid Them
-
Forgetting WHERE in UPDATE / DELETE
- Risk: change or delete all rows.
- Tip: Always test with
SELECTfirst, then turn it intoUPDATE/DELETE.
-
Mismatched columns and values in INSERT
- Number and order must line up.
- Tip: Always specify the column list explicitly.
-
Wrong data types in INSERT
- Example: string
'Max'into anINTcolumn. - Tip: Check
CREATE TABLEdefinitions.
- Example: string
-
Using
=with NULL- Wrong:
WHERE score = NULL(always false). - Correct:
WHERE score IS NULLorIS NOT NULL.
- Wrong:
-
SELECT with GROUP BY – non-aggregated columns
- Error when you select a column not in
GROUP BYor an aggregate. - Rule: every selected column must be grouped or aggregated.
- Error when you select a column not in
-
Using HAVING for row-level filtering
- Row conditions (e.g.,
score != 0) belong inWHERE, notHAVING.
- Row conditions (e.g.,
-
Misunderstanding BETWEEN boundaries
BETWEEN 100 AND 500includes both 100 and 500.
-
Relying on TOP without ORDER BY
- Without
ORDER BY, the “top 3” could be random-ish.
- Without
6.2 Error-Pattern Cheat Sheet
When you see an error, think of this quick map:
-
“Column … is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause” → You’re selecting a non-aggregated column that’s not in
GROUP BY. ✱ Fix: add it toGROUP BYor wrap it in an aggregate. -
“Cannot insert the value NULL into column ‘…’; column does not allow nulls” → You didn’t provide a value for a
NOT NULLcolumn. ✱ Fix: insert a value or change the column definition. -
Type mismatch errors (e.g., converting varchar to int failed) → Value type doesn’t match the column type. ✱ Fix: correct the literal or adjust the column type.
-
“Syntax error near …” around
WHERE/GROUP BY/HAVING→ Often caused by wrong clause order or missing comma/semicolon. ✱ Fix: check statement order and punctuation.
7. Memory Boosters
7.1 Mnemonics
-
SQL Categories – “Q, D, M”:
- Query → DQL →
SELECT - Define → DDL →
CREATE,ALTER,DROP - Modify → DML →
INSERT,UPDATE,DELETE
- Query → DQL →
-
SELECT Clause Order – “SF WOGHD” → “See Funny WOGH Dogs”:
- SELECT
- FROM
- WHERE
- ORDER BY
- GROUP BY
- HAVING
- (DISTINCT,
TOPare usually next to SELECT)
-
Filtering Operators – “CLRMS” (think “colors”):
- Comparison (
=, >, <, <> …) - Logical (
AND, OR, NOT) - Range (
BETWEEN) - Membership (
IN, NOT IN) - Search (
LIKE)
- Comparison (
-
LIKE Wildcards – “Percent = many, Underscore = one”:
%→ any number of characters._→ exactly one character.
-
Execution Order - Fresh Water Gets Athletes Hydrated So Don’t Over Train.
7.2 Analogies
- Database = folder; table = Excel sheet; row = one line; column = one field/heading.
SELECT= you ask a question; result set = the answer.WHERE= filter funnel: only rows passing the condition flow through.GROUP BY= grouping exam papers by class, then summarizing each group with stats (avg, sum, count).
8. Active Recall, Flashcards & Practice
8.1 Active Recall Questions (Based on This Material Only)
Try answering without looking; then check.
Concepts & Types
- What are the three main categories of SQL commands and which verbs belong to each?
- What is the difference between DDL and DML?
- What is a primary key and why is it important?
SELECT & Filtering
- Write a query to get
first_nameandcountryfromcustomerswherescoreis greater than 500. - How do you sort customers by
countryascending and then byscoredescending? - What’s the difference between
WHEREandHAVING? When do you use each?
Aggregation
- Write a query to calculate the total score and number of customers per country.
- Modify that query to only show countries with total score > 1000. Should that filter be in
WHEREorHAVING?
DDL / DML
- Write a
CREATE TABLEstatement for a simpleproductstable withid,product_name, andprice. - How do you add an
emailcolumn to thepersonstable? - How do you change the
scoreof customer with ID 6 to 0? - How do you delete all customers with
id> 5?
Filtering Operators
- Write a query to get all customers whose score is between 100 and 500 (inclusive).
- Write a query to get all customers from either Germany or USA using
IN. - Write a query to get all customers whose first name starts with
Mand ends witha. - How do you find all customers where
countryis not Germany and score is greater than 500?
8.2 Flashcard Templates (Reusable)
You can create infinite cards with these templates:
Template A – Table Understanding
- Q: “What are the columns and data types of
<table_name>?” - Q: “What is the primary key of
<table_name>and why?”
Template B – Query Variants
For any table X and column Y:
- Q: “Write a query to list all rows in X ordered by Y descending.”
- Q: “Write a query to count rows in X where Y meets some condition.”
- Q: “Write a query to get the top 3 rows in X according to Y.”
Template C – Clause Replacement
Pick one query and ask:
- “What happens if I remove
WHERE?” - “What happens if I move a condition from
WHEREtoHAVINGor vice versa?”
8.3 Practice Checklist (Level 1–3)
Level 1 – Basics
-
Write 5
SELECTqueries oncustomers:- 2 with
WHERE, - 1 with
ORDER BY, - 1 with
DISTINCT, - 1 with
TOP.
- 2 with
-
For each operator (
=,<>,>,<,BETWEEN,IN,LIKE), write one example.
Level 2 – Aggregation
-
Write 3
GROUP BYqueries oncustomers:- Total score per country,
- Average score per country (excluding
score = 0), - Number of customers per country.
-
For each, add a
HAVINGcondition and explain in plain words what it does.
Level 3 – DDL & DML
-
Create your own table (e.g.,
booksormovies) usingCREATE TABLE. -
Insert at least 5 rows (include one intentional mistake, then fix it).
-
Write:
- 1
UPDATEwith aWHEREthat affects 1 row, - 1
DELETEwith aWHEREthat affects a few rows, - 1
TRUNCATE TABLE(on a test table only).
- 1
9. Very Short Recap
9.1 Key Takeaways (Bullet Recap)
- Databases live on a server and store data in tables made of rows and columns.
- SQL commands are grouped into DQL (
SELECT), DDL (CREATE,ALTER,DROP), and DML (INSERT,UPDATE,DELETE). SELECTis built with clauses likeFROM,WHERE,GROUP BY,HAVING,ORDER BY, plus extras likeDISTINCTandTOP.WHEREfilters individual rows;HAVINGfilters groups after aggregation.ORDER BYcontrols sort order; always combineTOPwithORDER BYfor meaningful “top N” queries.GROUP BY+ aggregates (SUM,AVG,COUNT) let you summarize data per group.- DDL defines the structure of your tables; DML changes the data in them.
- Common pitfalls: missing
WHEREinUPDATE/DELETE, mismatched columns/values inINSERT, using=withNULL, violatingGROUP BYrules. - Logical execution order (FROM → WHERE → GROUP BY → Aggregates → HAVING → SELECT → DISTINCT → ORDER BY → TOP) explains many rules and errors.
- Active recall questions + regular practice on a small table like
customerswill make these basics automatic.
Sample Data
Practice SQL Queries for following tables (Go to next page)
SQL-SERVER:
Database name: MyDatabase :
/*
=============================================================
Database Creation and Table Setup Script
=============================================================
Script Purpose:
This script creates a new SQL Server database named 'MyDatabase'.
If the database already exists, it is dropped to ensure a clean setup.
The script then creates three tables: 'customers', 'orders', and 'employees'
with their respective schemas, and populates them with sample data.
WARNING:
Running this script will drop the entire 'MyDatabase' database if it exists,
permanently deleting all data within it. Proceed with caution and ensure you
have proper backups before executing this script.
*/
USE master;
GO
-- Drop and recreate the 'MyDatabase' database
IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'MyDatabase')
BEGIN
ALTER DATABASE MyDatabase SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE MyDatabase;
END;
GO
-- Create the 'MyDatabase' database
CREATE DATABASE MyDatabase;
GO
USE MyDatabase;
GO
-- ======================================================
-- Table: customers
-- ======================================================
DROP TABLE IF EXISTS customers;
GO
CREATE TABLE customers (
id INT NOT NULL,
first_name VARCHAR(50) NOT NULL,
country VARCHAR(50),
score INT,
CONSTRAINT PK_customers PRIMARY KEY (id)
);
GO
-- Insert customers data
INSERT INTO customers (id, first_name, country, score) VALUES
(1, 'Maria', 'Germany', 350),
(2, ' John', 'USA', 900),
(3, 'Georg', 'UK', 750),
(4, 'Martin', 'Germany', 500),
(5, 'Peter', 'USA', 0);
GO
-- ======================================================
-- Table: orders
-- ======================================================
DROP TABLE IF EXISTS orders;
GO
CREATE TABLE orders (
order_id INT NOT NULL,
customer_id INT NOT NULL,
order_date DATE,
sales INT,
CONSTRAINT PK_orders PRIMARY KEY (order_id)
);
GO
-- Insert orders data
INSERT INTO orders (order_id, customer_id, order_date, sales) VALUES
(1001, 1, '2021-01-11', 35),
(1002, 2, '2021-04-05', 15),
(1003, 3, '2021-06-18', 20),
(1004, 6, '2021-08-31', 10);
GO
Database name: SalesDB :
/*
=============================================================
Database Creation and Table Setup Script
=============================================================
Script Purpose:
This script creates a new SQL Server database named 'SalesDB'.
If the database already exists, it is dropped to ensure a clean setup.
The script then creates three tables: 'customers', 'orders', and 'employees'
with their respective schemas, and populates them with sample data.
WARNING:
Running this script will drop the entire 'SalesDB' database if it exists,
permanently deleting all data within it. Proceed with caution and ensure you
have proper backups before executing this script.
*/
USE master;
GO
-- Drop and recreate the 'SalesDB' database
IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'SalesDB')
BEGIN
ALTER DATABASE SalesDB SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE SalesDB;
END;
GO
-- Create the 'SalesDB' database
CREATE DATABASE SalesDB;
GO
USE SalesDB;
GO
-- Check if the schema 'Sales' exists
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = 'Sales')
BEGIN
-- If it does exist, drop the 'Sales' schema
DROP SCHEMA Sales;
END;
GO
-- Create the 'Sales' Schema using dynamic SQL
EXEC sys.sp_executesql N'CREATE SCHEMA Sales;';
GO
-- ======================================================
-- Table: customers
-- ======================================================
CREATE TABLE Sales.Customers (
CustomerID INT NOT NULL,
FirstName VARCHAR(50),
LastName VARCHAR(50),
Country VARCHAR(50),
Score INT,
CONSTRAINT PK_customers PRIMARY KEY (CustomerID)
);
GO
-- Insert data into Customer table
INSERT INTO Sales.Customers
VALUES
(1, 'Jossef', 'Goldberg', 'Germany', 350),
(2, 'Kevin', 'Brown', 'USA', 900),
(3, 'Mary', NULL, 'USA', 750),
(4, 'Mark', 'Schwarz', 'Germany', 500),
(5, 'Anna', 'Adams', 'USA', NULL);
GO
-- ======================================================
-- Table: Employee
-- ======================================================
-- Create Employee table
CREATE TABLE Sales.Employees (
EmployeeID INT NOT NULL,
FirstName VARCHAR(50),
LastName VARCHAR(50),
Department VARCHAR(50),
BirthDate DATE,
Gender CHAR(1),
Salary INT,
ManagerID INT,
CONSTRAINT PK_employees PRIMARY KEY (EmployeeID)
);
GO
-- Insert data into Employee table
INSERT INTO Sales.Employees
VALUES
(1, 'Frank', 'Lee', 'Marketing', '1988-12-05', 'M', 55000, null),
(2, 'Kevin', 'Brown', 'Marketing', '1972-11-25', 'M', 65000, 1),
(3, 'Mary', null, 'Sales', '1986-01-05', 'F', 75000, 1),
(4, 'Michael', 'Ray', 'Sales', '1977-02-10', 'M', 90000, 2),
(5, 'Carol', 'Baker', 'Sales', '1982-02-11', 'F', 55000, 3);
GO
-- ======================================================
-- Table: Products
-- ======================================================
-- Create Products table
CREATE TABLE Sales.Products (
ProductID INT NOT NULL,
Product VARCHAR(50),
Category VARCHAR(50),
Price INT,
CONSTRAINT PK_products PRIMARY KEY (ProductID)
);
GO
-- Insert data into Products table
INSERT INTO Sales.Products (ProductID, Product, Category, Price)
VALUES
(101, 'Bottle', 'Accessories', 10),
(102, 'Tire', 'Accessories', 15),
(103, 'Socks', 'Clothing', 20),
(104, 'Caps', 'Clothing', 25),
(105, 'Gloves', 'Clothing', 30);
GO
-- ======================================================
-- Table: orders
-- ======================================================
-- Create Orders table
CREATE TABLE Sales.Orders (
OrderID INT NOT NULL,
ProductID INT,
CustomerID INT,
SalesPersonID INT,
OrderDate DATE,
ShipDate DATE,
OrderStatus VARCHAR(50),
ShipAddress VARCHAR(255),
BillAddress VARCHAR(255),
Quantity INT,
Sales INT,
CreationTime DATETIME2,
CONSTRAINT PK_orders PRIMARY KEY (OrderID)
);
GO
-- Insert data into Orders table
INSERT INTO Sales.Orders
VALUES
(1, 101, 2, 3, '2025-01-01', '2025-01-05', 'Delivered','9833 Mt. Dias Blv.', '1226 Shoe St.', 1, 10, '2025-01-01T12:34:56'),
(2, 102, 3, 3, '2025-01-05', '2025-01-10', 'Shipped','250 Race Court',NULL, 1, 15, '2025-01-05T23:22:04'),
(3, 101, 1, 5, '2025-01-10', '2025-01-25', 'Delivered','8157 W. Book','8157 W. Book', 2, 20, '2025-01-10T18:24:08'),
(4, 105, 1, 3, '2025-01-20', '2025-01-25', 'Shipped', '5724 Victory Lane', '', 2, 60, '2025-01-20T05:50:33'),
(5, 104, 2, 5, '2025-02-01', '2025-02-05', 'Delivered',NULL, NULL, 1, 25, '2025-02-01T14:02:41'),
(6, 104, 3, 5, '2025-02-05', '2025-02-10', 'Delivered','1792 Belmont Rd.',NULL, 2, 50, '2025-02-06T15:34:57'),
(7, 102, 1, 1, '2025-02-15', '2025-02-27', 'Delivered','136 Balboa Court', '', 2, 30, '2025-02-16T06:22:01'),
(8, 101, 4, 3, '2025-02-18', '2025-02-27', 'Shipped','2947 Vine Lane','4311 Clay Rd', 3, 90, '2025-02-18T10:45:22'),
(9, 101, 2, 3, '2025-03-10', '2025-03-15', 'Shipped','3768 Door Way', '', 2, 20,'2025-03-10T12:59:04'),
(10, 102, 3, 5, '2025-03-15', '2025-03-20', 'Shipped',NULL, NULL, 0, 60,'2025-03-16T23:25:15');
GO
-- ======================================================
-- Table: OrdersArchive
-- ======================================================
-- Create OrdersArchive table
CREATE TABLE Sales.OrdersArchive (
OrderID INT,
ProductID INT,
CustomerID INT,
SalesPersonID INT,
OrderDate DATE,
ShipDate DATE,
OrderStatus VARCHAR(50),
ShipAddress VARCHAR(255),
BillAddress VARCHAR(255),
Quantity INT,
Sales INT,
CreationTime DATETIME2
);
GO
INSERT INTO Sales.OrdersArchive
VALUES
(1, 101,2 , 3, '2024-04-01', '2024-04-05', 'Shipped','123 Main St', '456 Billing St', 1, 10, '2024-04-01T12:34:56'),
(2, 102,3 , 3, '2024-04-05', '2024-04-10', 'Shipped','456 Elm St', '789 Billing St', 1, 15, '2024-04-05T23:22:04'),
(3, 101, 1, 4, '2024-04-10', '2024-04-25', 'Shipped','789 Maple St','789 Maple St', 2, 20, '2024-04-10T18:24:08'),
(4, 105,1 , 3, '2024-04-20', '2024-04-25', 'Shipped', '987 Victory Lane', '', 2, 60, '2024-04-20T05:50:33'),
(4, 105,1 , 3, '2024-04-20', '2024-04-25', 'Delivered', '987 Victory Lane', '', 2, 60, '2024-04-20T14:50:33'),
(5, 104,2 , 5, '2024-05-01', '2024-05-05', 'Shipped','345 Oak St', '678 Pine St', 1, 25, '2024-05-01T14:02:41'),
(6, 104, 3, 5, '2024-05-05', '2024-05-10', 'Delivered','543 Belmont Rd.',NULL, 2, 50, '2024-05-06T15:34:57'),
(6, 104, 3, 5, '2024-05-05', '2024-05-10', 'Delivered','543 Belmont Rd.','3768 Door Way', 2, 50, '2024-05-07T13:22:05'),
(6, 101, 3, 5, '2024-05-05', '2024-05-10', 'Delivered','543 Belmont Rd.','3768 Door Way', 2, 50, '2024-05-12T20:36:55'),
(7, 102,3 , 5, '2024-06-15', '2024-06-20', 'Shipped','111 Main St', '222 Billing St', 0, 60,'2024-06-16T23:25:15');
GO
For PostgreSQL:
For MySQL:
SQL SELECT Query
This guide covers various SELECT query techniques used for retrieving, filtering, sorting, and aggregating data efficiently.
Table of Contents
- Comments in SQL
- SELECT All Columns
- SELECT Specific Columns
- WHERE Clause (Filtering Rows)
- ORDER BY (Sorting Results)
- GROUP BY (Aggregating Data)
- HAVING (Filtering Groups)
- DISTINCT (Unique Values)
- TOP (Limiting Rows)
- Putting It All Together
- “Cool Stuff” – Additional SQL Features
PDF Slides
1. Comments in SQL
Comments help you document your queries so you (and others) understand what they do later.
Single-line and Multi-line Comments
/* ==============================================================================
COMMENTS
=============================================================================== */
-- This is a single-line comment.
/* This
is
a multiple-line
comment
*/
- Use
--for single-line comments (very common). - Use
/* ... */for multi-line comments, useful for big explanations or temporarily disabling blocks of code.
💡 Try it: Add a comment above any query describing what you expect it to return in plain English.
2. SELECT All Columns
Use SELECT * when you want all columns from a table (often okay for quick exploration, but not ideal in production).
/* ==============================================================================
SELECT ALL COLUMNS
=============================================================================== */
-- Retrieve All Customer Data
SELECT *
FROM customers
-- Retrieve All Order Data
SELECT *
FROM orders
SELECT * FROM customers→ returns every column and every row fromcustomers.SELECT * FROM orders→ same idea fororders.
🧠 Question:
When might SELECT * be dangerous or inefficient in a real application?
3. SELECT Specific Columns
Often you only need a few columns. That’s where explicit column lists shine.
/* ==============================================================================
SELECT FEW COLUMNS
=============================================================================== */
-- Retrieve each customer's name, country, and score.
SELECT
first_name,
country,
score
FROM customers
- This returns just what you need:
first_name,country,score. - Clearer, safer, and often faster than
SELECT *.
💡 Try it:
Modify this query to also return the id column.
4. WHERE Clause (Filtering Rows)
WHERE filters rows before they are returned (or grouped, aggregated, etc.).
/* ==============================================================================
WHERE
=============================================================================== */
-- Retrieve customers with a score not equal to 0
SELECT *
FROM customers
WHERE score != 0
-- Retrieve customers from Germany
SELECT *
FROM customers
WHERE country = 'Germany'
-- Retrieve the name and country of customers from Germany
SELECT
first_name,
country
FROM customers
WHERE country = 'Germany'
Key ideas:
score != 0→ keep all rows wherescoreis not zero.country = 'Germany'→ keep rows wherecountryis exactly'Germany'.
🧠 Question:
How would you write a query to get all customers from 'USA' with a score greater than 500?
5. ORDER BY (Sorting Results)
ORDER BY lets you sort results by one or more columns.
/* ==============================================================================
ORDER BY
=============================================================================== */
/* Retrieve all customers and
sort the results by the highest score first. */
SELECT *
FROM customers
ORDER BY score DESC
/* Retrieve all customers and
sort the results by the lowest score first. */
SELECT *
FROM customers
ORDER BY score ASC
/* Retrieve all customers and
sort the results by the country. */
SELECT *
FROM customers
ORDER BY country ASC
/* Retrieve all customers and
sort the results by the country and then by the highest score. */
SELECT *
FROM customers
ORDER BY country ASC, score DESC
/* Retrieve the name, country, and score of customers
whose score is not equal to 0
and sort the results by the highest score first. */
SELECT
first_name,
country,
score
FROM customers
WHERE score != 0
ORDER BY score DESC
ASC= ascending (smallest → largest, A → Z).DESC= descending (largest → smallest, Z → A).- You can sort by multiple columns:
ORDER BY country ASC, score DESC.
💡 Try it:
Change one of the queries to sort by first_name instead of score. What changes in the output?
6. GROUP BY (Aggregating Data)
GROUP BY groups rows that share the same value(s) in one or more columns, so you can apply aggregate functions (like SUM, COUNT, AVG) per group.
/* ==============================================================================
GROUP BY
=============================================================================== */
-- Find the total score for each country
SELECT
country,
SUM(score) AS total_score
FROM customers
GROUP BY country
/* This will not work because 'first_name' is neither part of the GROUP BY
nor wrapped in an aggregate function. SQL doesn't know how to handle this column. */
SELECT
country,
first_name,
SUM(score) AS total_score
FROM customers
GROUP BY country
-- Find the total score and total number of customers for each country
SELECT
country,
SUM(score) AS total_score,
COUNT(id) AS total_customers
FROM customers
GROUP BY country
Important rules:
-
Every selected column must be:
- either in the
GROUP BY, or - inside an aggregate function (
SUM,COUNT,AVG, etc.).
- either in the
-
The “invalid” example demonstrates this common error.
🧠 Question:
Why is it ambiguous to select first_name when grouping only by country?
7. HAVING (Filtering Groups)
HAVING filters groups after aggregation. Think of it as WHERE for grouped data.
/* ==============================================================================
HAVING
=============================================================================== */
/* Find the average score for each country
and return only those countries with an average score greater than 430 */
SELECT
country,
AVG(score) AS avg_score
FROM customers
GROUP BY country
HAVING AVG(score) > 430
/* Find the average score for each country
considering only customers with a score not equal to 0
and return only those countries with an average score greater than 430 */
SELECT
country,
AVG(score) AS avg_score
FROM customers
WHERE score != 0
GROUP BY country
HAVING AVG(score) > 430
WHEREfilters rows before grouping.HAVINGfilters groups after grouping.
💡 Try it: Change the query to only show countries where the average score is less than 300. Which part do you change?
8. DISTINCT (Unique Values)
DISTINCT removes duplicate rows in the result set.
/* ==============================================================================
DISTINCT
=============================================================================== */
-- Return Unique list of all countries
SELECT DISTINCT country
FROM customers
- If multiple customers are from the same country,
DISTINCT countryreturns that country only once.
🧠 Question:
What is the difference between SELECT country FROM customers and SELECT DISTINCT country FROM customers?
9. TOP (Limiting Rows)
TOP (in SQL Server and similar dialects) limits how many rows are returned.
/* ==============================================================================
TOP
=============================================================================== */
-- Retrieve only 3 Customers
SELECT TOP 3 *
FROM customers
-- Retrieve the Top 3 Customers with the Highest Scores
SELECT TOP 3 *
FROM customers
ORDER BY score DESC
-- Retrieve the Lowest 2 Customers based on the score
SELECT TOP 2 *
FROM customers
ORDER BY score ASC
-- Get the Two Most Recent Orders
SELECT TOP 2 *
FROM orders
ORDER BY order_date DESC
- Without
ORDER BY,TOPjust gives you some N rows (not well-defined). - With
ORDER BY, you can say “top N by score” or “latest N by date”.
💡 Try it: Adjust one query to return the Top 5 customers by score instead of 3.
10. Putting It All Together
Here’s a more complex query combining WHERE, GROUP BY, HAVING, and ORDER BY.
/* ==============================================================================
All Together
=============================================================================== */
/* Calculate the average score for each country
considering only customers with a score not equal to 0
and return only those countries with an average score greater than 430
and sort the results by the highest average score first. */
SELECT
country,
AVG(score) AS avg_score
FROM customers
WHERE score != 0
GROUP BY country
HAVING AVG(score) > 430
ORDER BY AVG(score) DESC
Step-by-step (logical execution order):
FROM customersWHERE score != 0→ filter rows.GROUP BY country→ group remaining rows by country.AVG(score)→ compute average for each country.HAVING AVG(score) > 430→ keep only groups with high averages.SELECT country, AVG(score) AS avg_scoreORDER BY AVG(score) DESC→ show highest averages first.
🧠 Question:
What happens if you remove the WHERE score != 0 condition? How might that affect the averages?
11. “Cool Stuff” – Additional SQL Features
Here are a few extra tricks that are often useful in practice.
/* ==============================================================================
COOL STUFF - Additional SQL Features
=============================================================================== */
-- Execute multiple queries at once
SELECT * FROM customers;
SELECT * FROM orders;
- Many SQL tools let you run multiple statements in one go (usually separated by
;).
/* Selecting Static Data */
-- Select a static or constant value without accessing any table
SELECT 123 AS static_number;
SELECT 'Hello' AS static_string;
- You can query constants without referencing any table (useful for testing or checking expressions).
-- Assign a constant value to a column in a query
SELECT
id,
first_name,
'New Customer' AS customer_type
FROM customers;
- Here, every row gets a computed column
customer_typewith the constant value'New Customer'.
💡 Try it:
Change 'New Customer' to 'VIP' and add a WHERE filter for customers with high scores. You’ve just created a categorized list of VIP customers.
################################################
SQL SELECT Practice Workbook
1. Comments in SQL
✅ Exercises
-
Write a single-line comment that says:
Get all German customers.💡 Answer
-- Get all German customers SELECT * FROM customers WHERE country = 'Germany'; -
Write a multi-line comment explaining that the next query will return the top 3 customers by score.
💡 Answer
/* 2) Multi-line comment This query returns the top 3 customers ordered by their score in descending order */ SELECT TOP 3 * FROM customers ORDER BY score DESC; -
You want to temporarily disable a query without deleting it. How would you comment it out?
💡 Answer
/* 3) Comment out the whole query SELECT * FROM customers WHERE score != 0; */
2. SELECT All Columns
✅ Exercises
-
Write a query to retrieve all columns from the
customerstable.💡 Answer
-- 1) All customers SELECT * FROM customers; -
Write a query to retrieve all columns from the
orderstable.💡 Answer
-- 2) All orders SELECT * FROM orders;
3. SELECT Specific Columns
✅ Exercises
-
Write a query that returns only
first_nameandcountryfromcustomers.💡 Answer
SELECT first_name, country FROM customers; -
Write a query that returns
id,first_name, andscorefromcustomers.💡 Answer
SELECT id, first_name, score FROM customers;
4. WHERE Clause (Filtering Rows)
✅ Exercises
-
Retrieve all customers whose
scoreis exactly0.💡 Answer
SELECT * FROM customers WHERE score = 0; -
Retrieve all customers not from
'Germany'.💡 Answer
SELECT * FROM customers WHERE country != 'Germany'; -
Retrieve all customers from
'USA'with ascoregreater than500.💡 Answer
SELECT * FROM customers WHERE country = 'USA' AND score > 500; -
Retrieve only
first_nameandcountryfor customers withscorenot equal to0.💡 Answer
SELECT first_name, country FROM customers WHERE score != 0;
5. ORDER BY (Sorting Results)
✅ Exercises
-
List all customers ordered by
first_namealphabetically (A→Z).💡 Answer
SELECT * FROM customers ORDER BY first_name ASC; -
List all customers sorted by
country(A→Z) and then byscorefrom highest to lowest.💡 Answer
SELECT * FROM customers ORDER BY country ASC, score DESC; -
Show only
first_name,country,scorefor customers wherescore != 0, sorted byscorefrom highest to lowest.💡 Answer
SELECT first_name, country, score FROM customers WHERE score != 0 ORDER BY score DESC; -
Retrieve all customers ordered by
scorefrom lowest to highest.💡 Answer
SELECT * FROM customers ORDER BY score ASC;
6. GROUP BY (Aggregating Data)
✅ Exercises
-
For each
country, calculate the total score of its customers. Returncountryandtotal_score.💡 Answer
-- 1) Total score per country SELECT country, SUM(score) AS total_score FROM customers GROUP BY country; -
For each
country, show both:- the total score
- the number of customers
Return
country,total_score, andtotal_customers.
💡 Answer
-- 2) Total score & count per country SELECT country, SUM(score) AS total_score, COUNT(id) AS total_customers FROM customers GROUP BY country; -
Spot the error: Why does this query fail?
SELECT country, first_name, SUM(score) AS total_score FROM customers GROUP BY country;💡 Answer
-- 3) Explanation: The query fails because 'first_name' is in the SELECT list but not: a) included in GROUP BY, or b) wrapped in an aggregate function. SQL doesn't know which 'first_name' to show for each country group.How would you fix it so it runs?
One possible fix (remove first_name):
💡 Answer
SELECT
country,
SUM(score) AS total_score
FROM customers
GROUP BY country;
Another fix (include first_name in grouping, though that changes meaning):
💡 Answer
SELECT
country,
SUM(score) AS total_score
FROM customers
GROUP BY country, first_name;
7. HAVING (Filtering Groups)
✅ Exercises
-
For each country, compute the average score, and only show countries with an average score greater than 400.
💡 Answer
-- 1) Average score per country > 400 SELECT country, AVG(score) AS avg_score FROM customers GROUP BY country HAVING AVG(score) > 400; -
Same as above, but ignore customers whose
scoreis0when computing the average.💡 Answer
-- 2) Ignore score = 0 rows first SELECT country, AVG(score) AS avg_score FROM customers WHERE score != 0 GROUP BY country HAVING AVG(score) > 400; -
True/False (then justify):
- “You can use
HAVINGinstead ofWHEREto filter individual rows before grouping.”
💡 Answer
-- 3) Answer: False. Explanation: - WHERE filters individual rows BEFORE grouping. - HAVING filters groups AFTER grouping. Using HAVING instead of WHERE for simple row filters is slower and conceptually wrong. - “You can use
8. DISTINCT (Unique Values)
✅ Exercises
-
Get a list of all distinct countries that appear in the
customerstable.💡 Answer
-- 1) Unique list of countries SELECT DISTINCT country FROM customers; -
Explain the difference between:
SELECT country FROM customers;and
SELECT DISTINCT country FROM customers;💡 Answer
-- 2) Explanation: SELECT country FROM customers; - returns one row per customer, so countries can repeat many times. SELECT DISTINCT country FROM customers; - returns each country only once (duplicates removed). -
Suppose you add another column in the future (e.g.,
city). What happens if you runSELECT DISTINCT country, city FROM customers;?💡 Answer
-- 3) Explanation: SELECT DISTINCT country, city FROM customers; - returns unique combinations of (country, city). - If the same city appears in the same country multiple times, it shows once. - If the same city name exists in multiple countries, each (country, city) pair appears separately.
9. TOP (Limiting Rows)
✅ Exercises
-
Write a query to retrieve only the first 3 rows from
customers(no sorting).💡 Answer
-- 1) First 3 customers (no guaranteed order) SELECT TOP 3 * FROM customers; -
Retrieve the top 3 customers with the highest scores.
💡 Answer
-- 2) Top 3 by highest score SELECT TOP 3 * FROM customers ORDER BY score DESC; -
Retrieve the lowest 2 customers based on
score.💡 Answer
-- 3) Lowest 2 by score SELECT TOP 2 * FROM customers ORDER BY score ASC; -
Get the two most recent orders using
order_date.💡 Answer
-- 4) Two most recent orders SELECT TOP 2 * FROM orders ORDER BY order_date DESC;
10. Putting It All Together
✅ Exercises
-
Using
customers, write a query that:- Ignores rows where
score = 0, - Groups by
country, - Computes
AVG(score)asavg_score, - Only keeps countries where
avg_score > 430, - Sorts results by
avg_scorefrom highest to lowest.
💡 Answer
-- 1) Full pipeline: WHERE + GROUP BY + HAVING + ORDER BY SELECT country, AVG(score) AS avg_score FROM customers WHERE score != 0 GROUP BY country HAVING AVG(score) > 430 ORDER BY AVG(score) DESC; - Ignores rows where
-
Modify the above query so it returns only the top 2 countries by
avg_score.💡 Answer
-- 2) Top 2 countries by avg_score SELECT TOP 2 country, AVG(score) AS avg_score FROM customers WHERE score != 0 GROUP BY country HAVING AVG(score) > 430 ORDER BY AVG(score) DESC; -
Describe in words the logical execution order of this query:
SELECT country, AVG(score) AS avg_score FROM customers WHERE score != 0 GROUP BY country HAVING AVG(score) > 430 ORDER BY AVG(score) DESC;💡 Answer
-- 3) Logical execution order (how SQL "thinks"): 1) FROM customers 2) WHERE score != 0 -- filter rows 3) GROUP BY country -- group remaining rows by country 4) AVG(score) -- compute average score per country 5) HAVING AVG(score) > 430 -- filter groups by average score 6) SELECT country, AVG(score) AS avg_score -- project output columns 7) ORDER BY AVG(score) DESC -- sort resulting groups by avg_score
11. Cool Stuff – Additional Features
✅ Exercises
-
Write two queries that run together:
- One selects all
customers, - One selects all
orders.
💡 Answer
-- 1) Two queries at once (many tools support this) SELECT * FROM customers; SELECT * FROM orders; - One selects all
-
Select the constant number
999and give it the aliasmagic_number.💡 Answer
-- 2) Constant number with alias SELECT 999 AS magic_number; -
Select the constant string
'VIP'ascustomer_typealongsideidandfirst_namefromcustomers.💡 Answer
-- 3) Constant string column with other columns SELECT id, first_name, 'VIP' AS customer_type FROM customers; -
Explain what the result looks like for:
SELECT id, first_name, 'New Customer' AS customer_type FROM customers;💡 Answer
-- 4) Explanation: The query SELECT id, first_name, 'New Customer' AS customer_type FROM customers; returns one row per customer with three columns: - id -> from the table - first_name -> from the table - customer_type -> always the text 'New Customer' for every row So you effectively "tag" each row with a constant label.
SQL Data Definition Language (DDL)
SQL Data Definition Language (DDL)
This guide covers the essential DDL commands used for defining and managing database structures, including creating, modifying, and deleting tables.
Table of Contents:
- CREATE - Creating Tables
- ALTER - Modifying Table Structure
- DROP - Removing Tables
/* ==============================================================================
CREATE
=============================================================================== */
/* Create a new table called persons
with columns: id, person_name, birth_date, and phone */
CREATE TABLE persons (
id INT NOT NULL,
person_name VARCHAR(50) NOT NULL,
birth_date DATE,
phone VARCHAR(15) NOT NULL,
CONSTRAINT pk_persons PRIMARY KEY (id)
)
/* ==============================================================================
ALTER
=============================================================================== */
-- Add a new column called email to the persons table
ALTER TABLE persons
ADD email VARCHAR(50) NOT NULL
-- Remove the column phone from the persons table
ALTER TABLE persons
DROP COLUMN phone
/* ==============================================================================
DROP
=============================================================================== */
-- Delete the table persons from the database
DROP TABLE persons
PDF Slides
SQL Data Manipulation Language (DML)
This guide covers the essential DML commands used for inserting, updating, and deleting data in database tables.
Table of Contents:
- INSERT - Adding Data to Tables
- UPDATE - Modifying Existing Data
- DELETE - Removing Data from Tables
/* ==============================================================================
INSERT
=============================================================================== */
/* #1 Method: Manual INSERT using VALUES */
-- Insert new records into the customers table
INSERT INTO customers (id, first_name, country, score)
VALUES
(6, 'Anna', 'USA', NULL),
(7, 'Sam', NULL, 100)
-- Incorrect column order
INSERT INTO customers (id, first_name, country, score)
VALUES
(8, 'Max', 'USA', NULL)
-- Incorrect data type in values
INSERT INTO customers (id, first_name, country, score)
VALUES
('Max', 9, 'Max', NULL)
-- Insert a new record with full column values
INSERT INTO customers (id, first_name, country, score)
VALUES (8, 'Max', 'USA', 368)
-- Insert a new record without specifying column names (not recommended)
INSERT INTO customers
VALUES
(9, 'Andreas', 'Germany', NULL)
-- Insert a record with only id and first_name (other columns will be NULL or default values)
INSERT INTO customers (id, first_name)
VALUES
(10, 'Sahra')
/* #2 Method: INSERT DATA USING SELECT - Moving Data From One Table to Another */
-- Copy data from the 'customers' table into 'persons'
INSERT INTO persons (id, person_name, birth_date, phone)
SELECT
id,
first_name,
NULL,
'Unknown'
FROM customers
/* ==============================================================================
UPDATE
=============================================================================== */
-- Change the score of customer with ID 6 to 0
UPDATE customers
SET score = 0
WHERE id = 6
-- Change the score of customer with ID 10 to 0 and update the country to 'UK'
UPDATE customers
SET score = 0,
country = 'UK'
WHERE id = 10
-- Update all customers with a NULL score by setting their score to 0
UPDATE customers
SET score = 0
WHERE score IS NULL
-- Verify the update
SELECT *
FROM customers
WHERE score IS NULL
/* ==============================================================================
DELETE
=============================================================================== */
-- Select customers with an ID greater than 5 before deleting
SELECT *
FROM customers
WHERE id > 5
-- Delete all customers with an ID greater than 5
DELETE FROM customers
WHERE id > 5
-- Delete all data from the persons table
DELETE FROM persons
-- Faster method to delete all rows, especially useful for large tables
TRUNCATE TABLE persons
PDF Slides
SQL Filtering Data
Overview of SQL filtering techniques using WHERE and various operators for precise data retrieval.
Table of Contents:
- Comparison Operators :
=, <>, >, >=, <, <= - Logical Operators:
AND, OR, NOT - Range Filtering:
BETWEEN - Set Filtering:
IN - Pattern Matching:
LIKE
Comparison Operators : =, <>, >, >=, <, <=
/* ==============================================================================
1. COMPARISON OPERATORS : =, <>, >, >=, <, <=
============================================================================== */
-- Retrieve all customers from Germany.
SELECT *
FROM customers
WHERE country = 'Germany';
-- Retrieve all customers who are not from Germany.
SELECT *
FROM customers
WHERE country <> 'Germany';
-- Retrieve all customers with a score greater than 500.
SELECT *
FROM customers
WHERE score > 500;
-- Retrieve all customers with a score of 500 or more.
SELECT *
FROM customers
WHERE score >= 500;
-- Retrieve all customers with a score less than 500.
SELECT *
FROM customers
WHERE score < 500;
-- Retrieve all customers with a score of 500 or less.
SELECT *
FROM customers
WHERE score <= 500;
Logical Operators: AND, OR, NOT
/* ==============================================================================
2. LOGICAL OPERATORS : AND, OR, NOT
============================================================================== */
-- Retrieve all customers who are from the USA and have a score greater than 500.
SELECT *
FROM customers
WHERE country = 'USA' AND score > 500;
-- Retrieve all customers who are either from the USA or have a score greater than 500.
SELECT *
FROM customers
WHERE country = 'USA' OR score > 500;
-- Retrieve all customers with a score not less than 500.
SELECT *
FROM customers
WHERE NOT score < 500;
Range Filtering: BETWEEN
/* ==============================================================================
3. RANGE FILTERING : BETWEEN
============================================================================== */
-- Retrieve all customers whose score falls in the range between 100 and 500.
SELECT *
FROM customers
WHERE score BETWEEN 100 AND 500;
-- Alternative method (Equivalent to BETWEEN).
SELECT *
FROM customers
WHERE score >= 100 AND score <= 500;
Set Filtering: IN
/* ==============================================================================
4. SET FILTERING : IN
============================================================================== */
-- Retrieve all customers from either Germany or the USA.
SELECT *
FROM customers
WHERE country IN ('Germany', 'USA');
Pattern Matching: LIKE
/* ==============================================================================
5. PATTERN MATCHING : LIKE
============================================================================== */
-- Find all customers whose first name starts with 'M'.
SELECT *
FROM customers
WHERE first_name LIKE 'M%';
-- Find all customers whose first name ends with 'n'.
SELECT *
FROM customers
WHERE first_name LIKE '%n';
-- Find all customers whose first name contains 'r'.
SELECT *
FROM customers
WHERE first_name LIKE '%r%';
-- Find all customers whose first name has 'r' in the third position.
SELECT *
FROM customers
WHERE first_name LIKE '__r%';
PDF Slides
SQL JOINS & SET OPERATORS
Combining data across tables and queries
PDF Slides
1. Short summary
Two big ways to combine data in SQL:
- JOINS – glue tables side by side (add columns).
- SET OPERATORS – stack result sets on top of each other (add rows).
Main join types (INNER, LEFT, RIGHT, FULL, ANTI, CROSS), how to join multiple tables, and how to use UNION, UNION ALL, EXCEPT and INTERSECT safely and correctly.
2. Big picture – where this fits
Two main families for combining data:
- Joins – combine data by columns (A ⟷ B), matching rows using keys.
- Set operators – combine data by rows (A ↑ B), treating each SELECT result as a set.
Typical tasks:
- Recombine data – get the “big picture” by merging related tables (customers + orders + addresses).
- Data enrichment – add extra info from lookup/reference tables (e.g., ZIP codes).
- Check existence / filtering – keep or remove rows based on whether they have matches elsewhere (e.g., customers with no orders – anti joins).
- Combine information from similar tables (Employees + Customers) or current vs archive (Orders + OrdersArchive).
- Delta detection / completeness checks using EXCEPT/INTERSECT + unions.
3. Key concepts & definitions
3.1 Types of JOINS
(“A” = left table, “B” = right table)
| Join type | What rows do you get? | Does table order matter? | Typical use |
|---|---|---|---|
| No join | Separate results from each table (two independent SELECTs). | N/A | Just view data from each table, no relationship. |
| INNER JOIN | Only rows that match in both tables (the overlapping part of the Venn diagram). | No – A ⋈ B = B ⋈ A. | When you only care about matching data (e.g., customers with orders). |
| LEFT JOIN | All rows from left table + matching rows from right; non-matches on right = NULL. | Yes – left side is “kept”. | Primary table + optional extra info; find “missing” matches via NULL. |
| RIGHT JOIN | All rows from right table + matching rows from left; non-matches on left = NULL. | Yes – right side is “kept”. | Same as LEFT, but with emphasis on right table (less common). |
| FULL JOIN | All rows from both tables; matches combined, non-matches filled with NULLs. | No – A FULL B = B FULL A. | Union of both sides with alignment where possible. |
| LEFT ANTI JOIN | Rows from left table with no match in right (using LEFT JOIN + WHERE B.key IS NULL). | Yes | Find “only in A” rows, e.g. customers without orders. |
| RIGHT ANTI JOIN | Rows from right table with no match in left. | Yes | Find “only in B” rows, e.g. orders without customers. |
| FULL ANTI JOIN | Rows that don’t match on either side (unmatched from A + unmatched from B). | No | See all inconsistencies between two tables. |
| CROSS JOIN | All combinations of rows from A and B (Cartesian product). | N/A | Rare, mostly for testing, generating grids, or careful analytics. |
Extra ideas from the diagrams:
- “Primary / main source of data” = the table from which you want all rows (LEFT / RIGHT).
- “Lookup table” for anti joins is used as a filter, not as a source of columns.
3.2 Set operators
They combine results of two SELECTs (each SELECT is like its own virtual table).
| Operator | Effect (set theory) | Duplicates? |
|---|---|---|
| UNION | All rows from both sets, duplicates removed (like mathematical union). | Removed. |
| UNION ALL | All rows from both sets, keeping duplicates. | Kept. |
| EXCEPT | Rows in first set that are not in second (“A minus B”). | Unique results. |
| INTERSECT | Only rows that appear in both sets (common rows). | Unique results. |
ORDER BY can only be used at the very end of the combined query, not after each SELECT.
3.3 Shared rules for SET operators (from 06_SETs.sql)
When using UNION/UNION ALL/EXCEPT/INTERSECT:
- The data types of corresponding columns must match (or be compatible).
- The order of columns in each SELECT must be the same.
- The column names in the final result come from the first SELECT.
- You must use the correct columns in matching positions; swapping first/last names etc. may produce nonsense.
- (Implied by SQL standard, though not literally written): the SELECTs must have the same number of columns.
The script includes example queries under “RULE: Data Types / Column Order / Column Aliases / Correct Columns” to illustrate these rules.
4. Syntax cheat-sheet
4.1 Core join patterns
Basic INNER JOIN
SELECT
A.col1,
B.col2
FROM A
INNER JOIN B
ON A.key = B.key;
- Returns only rows where
A.keyequalsB.key.
LEFT JOIN
SELECT
A.col1,
B.col2
FROM A
LEFT JOIN B
ON A.key = B.key;
- Keeps all rows from A, adds B’s columns when a match exists; otherwise B’s columns are NULL.
RIGHT JOIN
SELECT
A.col1,
B.col2
FROM A
RIGHT JOIN B
ON A.key = B.key;
- Keeps all rows from B.
- Alternative: flip the tables and use LEFT JOIN instead (see slides and script).
FULL JOIN
SELECT
A.col1,
B.col2
FROM A
FULL JOIN B
ON A.key = B.key;
- Returns everything from both tables, matching where possible.
4.2 Anti-join patterns (from slides + SQL)
LEFT ANTI JOIN – “rows in A with no match in B”
SELECT
A.*
FROM A
LEFT JOIN B
ON A.key = B.key
WHERE B.key IS NULL;
RIGHT ANTI JOIN
SELECT
B.*
FROM A
RIGHT JOIN B
ON A.key = B.key
WHERE A.key IS NULL;
Alternative to RIGHT ANTI JOIN using LEFT JOIN
SELECT
B.*
FROM B
LEFT JOIN A
ON A.key = B.key
WHERE A.key IS NULL;
FULL ANTI JOIN – unmatched on either side
SELECT
A.*,
B.*
FROM A
FULL JOIN B
ON A.key = B.key
WHERE A.key IS NULL
OR B.key IS NULL;
4.3 Cross join pattern
SELECT
*
FROM A
CROSS JOIN B;
- Returns all combinations of rows from A and B (A×B).
4.4 Joining multiple tables (4-table pattern)
From the multi-table example in 05_JOINs.sql:
SELECT
o.OrderID,
o.Sales,
c.FirstName AS CustomerFirstName,
c.LastName AS CustomerLastName,
p.Product AS ProductName,
p.Price,
e.FirstName AS EmployeeFirstName,
e.LastName AS EmployeeLastName
FROM Sales.Orders AS o
LEFT JOIN Sales.Customers AS c
ON o.CustomerID = c.CustomerID
LEFT JOIN Sales.Products AS p
ON o.ProductID = p.ProductID
LEFT JOIN Sales.Employees AS e
ON o.SalesPersonID = e.EmployeeID;
General pattern:
FROM fact_table ft
LEFT JOIN dim1 d1 ON ft.key1 = d1.key1
LEFT JOIN dim2 d2 ON ft.key2 = d2.key2
LEFT JOIN dim3 d3 ON ft.key3 = d3.key3
...
4.5 Set operator pattern
General template :
SELECT ...
FROM ...
-- JOIN / WHERE / GROUP BY etc.
<SET OPERATOR> -- UNION / UNION ALL / EXCEPT / INTERSECT
SELECT ...
FROM ...
-- JOIN / WHERE / GROUP BY etc.
ORDER BY ...; -- only here, at the very end
Important:
- You can use joins, WHERE, GROUP BY, etc. in each SELECT.
- You cannot put an
ORDER BYinside each part (unless you wrap each SELECT in parentheses / subqueries); by defaultORDER BYgoes once at the end.
5. Worked examples (with explanations)
5.1 Basic joins with Customers & Orders
No join
SELECT * FROM customers;
SELECT * FROM orders;
- Two completely separate result sets; nothing combined.
INNER JOIN – “customers with orders”
SELECT
c.id,
c.first_name,
o.order_id,
o.sales
FROM customers AS c
INNER JOIN orders AS o
ON c.id = o.customer_id;
- Only customers whose
idappears ascustomer_idinorders. - If a customer never ordered, they do not appear.
LEFT JOIN – “all customers, orders optional”
SELECT
c.id,
c.first_name,
o.order_id,
o.sales
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id;
- All customers appear.
order_idandsalesare NULL if the customer has no orders.- Good for “data enrichment” or for later filtering to find “no orders” via
WHERE o.order_id IS NULL.
RIGHT JOIN – “all orders, customers optional”
SELECT
c.id,
c.first_name,
o.order_id,
o.customer_id,
o.sales
FROM customers AS c
RIGHT JOIN orders AS o
ON c.id = o.customer_id;
- Every order appears, even if
customer_iddoesn’t match a real customer. - If there’s no matching customer,
c.idandc.first_nameare NULL.
Alternative using LEFT JOIN (from script & slide “Alternative To RIGHT JOIN”):
SELECT
c.id,
c.first_name,
o.order_id,
o.sales
FROM orders AS o
LEFT JOIN customers AS c
ON c.id = o.customer_id;
- Same result but often easier to read because we mostly use LEFT JOINs.
FULL JOIN – “everything”
SELECT
c.id,
c.first_name,
o.order_id,
o.customer_id,
o.sales
FROM customers AS c
FULL JOIN orders AS o
ON c.id = o.customer_id;
-
Contains:
- matching customer+order rows,
- customers without orders (order columns NULL),
- orders without customers (customer columns NULL).
5.2 Anti-joins
LEFT ANTI JOIN – “customers with no orders”
SELECT *
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
WHERE o.customer_id IS NULL;
- LEFT JOIN keeps all customers.
WHERE o.customer_id IS NULLfilters down to rows where no order matched.- This matches the “Left Anti Join” diagram: we keep only unmatched rows from the left, using the right as a filter.
RIGHT ANTI JOIN – “orders without customers”
SELECT *
FROM customers AS c
RIGHT JOIN orders AS o
ON c.id = o.customer_id
WHERE c.id IS NULL;
Alternative using LEFT JOIN (slide “Alternative To RIGHT Anti JOIN”):
SELECT *
FROM orders AS o
LEFT JOIN customers AS c
ON c.id = o.customer_id
WHERE c.id IS NULL;
- Same logic: keep unmatched rows from the table you keep all rows from.
FULL ANTI JOIN – “all mismatches”
SELECT
c.id,
c.first_name,
o.order_id,
o.customer_id,
o.sales
FROM customers AS c
FULL JOIN orders AS o
ON c.id = o.customer_id
WHERE o.customer_id IS NULL
OR c.id IS NULL;
-
Collects both:
- customers without orders,
- orders without customers.
5.3 Alternative INNER JOIN via LEFT JOIN
SELECT *
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
WHERE o.customer_id IS NOT NULL;
- LEFT JOIN first, then filter out non-matches.
- Final effect is exactly like an INNER JOIN (only matching rows).
5.4 CROSS JOIN
SELECT *
FROM customers
CROSS JOIN orders;
- If you have 10 customers and 5 orders, you get 50 rows.
- Useful mainly when you want all combinations (e.g., scenario modelling).
5.5 Multi-table join (SalesDB example)
From the script:
USE SalesDB;
SELECT
o.OrderID,
o.Sales,
c.FirstName AS CustomerFirstName,
c.LastName AS CustomerLastName,
p.Product AS ProductName,
p.Price,
e.FirstName AS EmployeeFirstName,
e.LastName AS EmployeeLastName
FROM Sales.Orders AS o
LEFT JOIN Sales.Customers AS c
ON o.CustomerID = c.CustomerID
LEFT JOIN Sales.Products AS p
ON o.ProductID = p.ProductID
LEFT JOIN Sales.Employees AS e
ON o.SalesPersonID = e.EmployeeID;
What’s happening:
-
Sales.Ordersis the fact table (one row per order). -
Each LEFT JOIN enriches each order with:
- customer info,
- product info,
- employee (salesperson) info.
-
LEFT JOIN means you still see orders even if some dimension data is missing.
5.6 Set operators – Employees & Customers
Using Sales.Customers and Sales.Employees.
UNION – combine without duplicates
SELECT
FirstName,
LastName
FROM Sales.Customers
UNION
SELECT
FirstName,
LastName
FROM Sales.Employees;
- Returns each unique (FirstName, LastName) pair at most once.
- Think of it as “unique people either as customer or employee”.
UNION ALL – combine with duplicates
SELECT
FirstName,
LastName
FROM Sales.Customers
UNION ALL
SELECT
FirstName,
LastName
FROM Sales.Employees;
- Keeps duplicates – useful when counts matter (e.g., number of records).
EXCEPT – “employees who are not customers”
SELECT
FirstName,
LastName
FROM Sales.Employees
EXCEPT
SELECT
FirstName,
LastName
FROM Sales.Customers;
- Returns employees whose (FirstName, LastName) pair never appears in Customers.
INTERSECT – “employees who are also customers”
SELECT
FirstName,
LastName
FROM Sales.Employees
INTERSECT
SELECT
FirstName,
LastName
FROM Sales.Customers;
- Returns only names that are in both tables.
5.7 UNION over Orders & OrdersArchive
SELECT
'Orders' AS SourceTable,
OrderID,
ProductID,
CustomerID,
SalesPersonID,
OrderDate,
ShipDate,
OrderStatus,
ShipAddress,
BillAddress,
Quantity,
Sales,
CreationTime
FROM Sales.Orders
UNION
SELECT
'OrdersArchive' AS SourceTable,
OrderID,
ProductID,
CustomerID,
SalesPersonID,
OrderDate,
ShipDate,
OrderStatus,
ShipAddress,
BillAddress,
Quantity,
Sales,
CreationTime
FROM Sales.OrdersArchive
ORDER BY OrderID;
Key points:
- Uses UNION (duplicates removed) to merge current and archived orders.
- Adds a
SourceTablecolumn to show where each row came from. ORDER BYappears once at the end.
6. Common mistakes & tips
6.1 Joins
-
Forgetting the join condition (
ONclause)- This effectively becomes a CROSS JOIN and multiplies rows.
- Always check: “What keys am I matching on?”
-
Mixing up LEFT and RIGHT tables
- Remember: in
A LEFT JOIN B, A is the one that never gets dropped. - Slides emphasise “The order of tables is IMPORTANT” for LEFT/RIGHT and anti joins.
- Remember: in
-
Using RIGHT JOIN when a LEFT JOIN would do
- Often clearer to flip tables and use LEFT JOIN (slide “Alternative To RIGHT JOIN”).
-
Wrong anti-join condition
- For LEFT ANTI JOIN, the NULL check must be on the right table’s key column (
WHERE B.key IS NULL). - For FULL ANTI JOIN, you need
A.key IS NULL OR B.key IS NULL.
- For LEFT ANTI JOIN, the NULL check must be on the right table’s key column (
-
Putting filters in WHERE instead of ON (with OUTER joins)
- If you add
WHERE B.some_col = 'X'after a LEFT JOIN, you can unintentionally turn it into something like an INNER join (because rows with NULL B values are dropped). - Strategy: if the condition is about matching rows, put it in ON; if it’s logical filtering after the join, use WHERE.
- If you add
-
Ambiguous column names in SELECT/WHERE/ORDER BY
- When multiple tables have
idorFirstName, always qualify:c.FirstName,o.OrderID.
- When multiple tables have
6.2 Set operators
-
Mismatched column lists / types
- E.g., selecting
(FirstName, LastName, Country)on one side and(FirstName, LastName)on the other breaks the “data types and order” rule shown in the script.
- E.g., selecting
-
Wrong column order
- The
RULE: Column Orderexample showsLastName, CustomerIDvsEmployeeID, LastName– this flips meaning of columns. - Always match columns by position and meaning.
- The
-
Relying on column names from second SELECT
- Final column names come from the first SELECT (
CustomerID AS ID→ final column name isID).
- Final column names come from the first SELECT (
-
Using ORDER BY in each SELECT
- As slides note,
ORDER BYcan only be used once at the end of the combined result (unless you wrap subqueries).
- As slides note,
-
Expecting UNION and UNION ALL to behave the same
- UNION removes duplicates; UNION ALL doesn’t – this affects counts and performance.
7. Memory boosters (mnemonics & analogies)
7.1 Joins
- INNER JOIN – “inner circle only” → intersection of both circles.
- LEFT JOIN – “LEFT is Loved” → the left table is never dropped.
- RIGHT JOIN – same idea, but “Right = Retained”.
- FULL JOIN – “FULL = Everything” → all rows from both sides.
- Anti joins – think of anti-social rows: they appear only on one side with no friend on the other.
- Cross join – “grid of all pairs”, like a multiplication table: rows(A) × rows(B).
Visual hook (from diagrams):
-
Picture the Venn diagrams from slides:
- Inner = overlapping middle.
- Left = full left + overlap.
- Left anti = left side excluding overlap.
7.2 Set operators
-
UNION vs UNION ALL
- “UNION is Unique” (both start with U): removes duplicates.
- “UNION ALL keeps All”.
-
EXCEPT
- Think “Employees EXCEPT Customers” = who is only an employee.
-
INTERSECT
- The intersection of sets – common part.
Mini-story:
You have two guest lists: Employees and Customers.
- The UNION is everyone invited to the party (no duplicate invitations).
- UNION ALL is all invitations actually sent (even duplicates).
- EXCEPT (Employees EXCEPT Customers) are staff-only invites.
- INTERSECT are VIPs who are both employees and customers.
8. Active-recall questions & mini-exercises
Use these like flashcards. Cover the answer, try to recall, then check.
8.1 Concept questions
-
Q: In a
LEFT JOIN, which table’s rows are always kept? A: The left table (primary source of data). -
Q: What does an INNER JOIN return? A: Only rows that have matching keys in both tables.
-
Q: How do you implement a LEFT ANTI JOIN using customers and orders? A:
customers LEFT JOIN orders ON ... WHERE orders.customer_id IS NULL. -
Q: In a FULL JOIN, what types of rows appear? A: Matching rows, plus unmatched rows from each side with NULLs.
-
Q: Which set operator returns rows that are in the first result but not the second? A: EXCEPT.
-
Q: Who “owns” the column names in a UNION result? A: The first SELECT.
-
Q: Where should
ORDER BYappear in a UNION query? A: At the very end, after all SELECTs and set operators.
8.2 Join exercises (using Customers, Orders, Products, Employees)
Try to write the SQL without looking; then compare to the patterns.
-
Customers without orders
- Write a query that returns all columns from
customersfor those who never placed an order.
- Write a query that returns all columns from
-
Orders without customers
- Using the RIGHT ANTI JOIN pattern or its LEFT JOIN alternative, list all orders that don’t have a matching customer.
-
Orders with full details
- Recreate the 4-table query: Orders + Customers + Products + Employees, showing the columns from the script.
-
Inner join via LEFT join
- Rewrite the customers–orders INNER JOIN using LEFT JOIN + WHERE
IS NOT NULL.
- Rewrite the customers–orders INNER JOIN using LEFT JOIN + WHERE
-
Cross join
- Write a query that generates all combinations of customers and orders (just
SELECT *).
- Write a query that generates all combinations of customers and orders (just
8.3 Set operator exercises
-
Employees who are not customers
- Write the EXCEPT query using
Sales.EmployeesandSales.Customers.
- Write the EXCEPT query using
-
People who are both employees and customers
- Write the INTERSECT query.
-
Combine Customers and Employees into a single de-duplicated list of names
- Use UNION.
-
Combine Orders and OrdersArchive into one report
- Include a
SourceTablecolumn and order byOrderID, as in the script.
- Include a
-
Spot the bug
-
Given the rule examples:
SELECT CustomerID, LastName FROM Sales.Customers UNION SELECT FirstName, LastName FROM Sales.Employees;What’s wrong?
- (Answer: columns by position don’t match in type/meaning:
CustomerIDvsFirstName.)
- (Answer: columns by position don’t match in type/meaning:
-
9. Short recap – key takeaways (5–10 bullets)
- There are two main ways to combine data: JOINS (add columns) and SET OPERATORS (add rows).
- INNER/LEFT/RIGHT/FULL joins differ only in which unmatched rows they keep.
- Anti joins are implemented using OUTER JOIN +
WHERE <other side’s key> IS NULLand are great for “who doesn’t have X?” questions. - CROSS JOIN returns all combinations of rows (use with care).
- When joining multiple tables, start from a main/fact table and add others one by one with clear keys.
- For set operators, make sure number, order, and types of columns match, and remember that final column names come from the first SELECT.
- UNION removes duplicates, UNION ALL keeps them; EXCEPT and INTERSECT work like set difference and intersection.
ORDER BYin a set-operator query goes only at the end of the entire statement.- Joins are perfect for recombining/enriching data; set operators shine for combining similar datasets, delta detection, and completeness checks.
JOINs
Table of Contents
1. Basic Joins
1.1 No Join
Task:
Retrieve all data from the customers and orders tables as separate result sets (no join between them).
💡 Answer
-- No Join
/* Retrieve all data from customers and orders as separate results */
SELECT * FROM customers;
SELECT * FROM orders;
1.2 INNER JOIN
Task: Get all customers along with their orders, but only for customers who have placed at least one order.
💡 Answer
-- INNER JOIN
/* Get all customers along with their orders,
but only for customers who have placed an order */
SELECT
c.id,
c.first_name,
o.order_id,
o.sales
FROM customers AS c
INNER JOIN orders AS o
ON c.id = o.customer_id;
1.3 LEFT JOIN
Task: Get all customers along with their orders, including customers who have not placed any order.
💡 Answer
-- LEFT JOIN
/* Get all customers along with their orders,
including those without orders */
SELECT
c.id,
c.first_name,
o.order_id,
o.sales
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id;
1.4 RIGHT JOIN
Task: Get all customers along with their orders, including orders that don’t have a matching customer.
💡 Answer
-- RIGHT JOIN
/* Get all customers along with their orders,
including orders without matching customers */
SELECT
c.id,
c.first_name,
o.order_id,
o.customer_id,
o.sales
FROM customers AS c
RIGHT JOIN orders AS o
ON c.id = o.customer_id;
1.5 Alternative to RIGHT JOIN using LEFT JOIN
Task: Rewrite the previous RIGHT JOIN query using a LEFT JOIN instead, while keeping the same logic (include orders without matching customers).
💡 Answer
-- Alternative to RIGHT JOIN using LEFT JOIN
/* Get all customers along with their orders,
including orders without matching customers */
SELECT
c.id,
c.first_name,
o.order_id,
o.sales
FROM orders AS o
LEFT JOIN customers AS c
ON c.id = o.customer_id;
1.6 FULL JOIN
Task: Get all customers and all orders, even if there is no match between them.
💡 Answer
-- FULL JOIN
/* Get all customers and all orders, even if there’s no match */
SELECT
c.id,
c.first_name,
o.order_id,
o.customer_id,
o.sales
FROM customers AS c
FULL JOIN orders AS o
ON c.id = o.customer_id;
2. Advanced Joins
2.1 LEFT ANTI JOIN
Task: Get all customers who haven’t placed any order (customers with no matching order).
💡 Answer
-- LEFT ANTI JOIN
/* Get all customers who haven't placed any order */
SELECT *
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
WHERE o.customer_id IS NULL;
2.2 RIGHT ANTI JOIN
Task: Get all orders that don’t have matching customers (orders with no valid customer).
💡 Answer
-- RIGHT ANTI JOIN
/* Get all orders without matching customers */
SELECT *
FROM customers AS c
RIGHT JOIN orders AS o
ON c.id = o.customer_id
WHERE c.id IS NULL;
2.3 Alternative to RIGHT ANTI JOIN using LEFT JOIN
Task: Rewrite the previous RIGHT ANTI JOIN query using a LEFT JOIN, still returning orders without matching customers.
💡 Answer
-- Alternative to RIGHT ANTI JOIN using LEFT JOIN
/* Get all orders without matching customers */
SELECT *
FROM orders AS o
LEFT JOIN customers AS c
ON c.id = o.customer_id
WHERE c.id IS NULL;
2.4 Alternative to INNER JOIN using LEFT JOIN
Task: Using a LEFT JOIN + WHERE filter, return all customers along with their orders, but only for customers who have placed an order (i.e., replicate INNER JOIN behaviour).
💡 Answer
-- Alternative to INNER JOIN using LEFT JOIN
/* Get all customers along with their orders,
but only for customers who have placed an order */
SELECT *
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
WHERE o.customer_id IS NOT NULL;
2.5 FULL ANTI JOIN
Task: Find all customers without orders and all orders without customers in a single result set.
💡 Answer
-- FULL ANTI JOIN
/* Find customers without orders and orders without customers */
SELECT
c.id,
c.first_name,
o.order_id,
o.customer_id,
o.sales
FROM customers AS c
FULL JOIN orders AS o
ON c.id = o.customer_id
WHERE o.customer_id IS NULL OR c.id IS NULL;
2.6 CROSS JOIN
Task: Generate all possible combinations of customers and orders (Cartesian product).
💡 Answer
-- CROSS JOIN
/* Generate all possible combinations of customers and orders */
SELECT *
FROM customers
CROSS JOIN orders;
3. Multiple Table Joins (4 Tables)
3.1 Orders with Customer, Product, and Employee Details
Task:
Using the SalesDB database, retrieve a list of all orders along with related customer, product, and employee details. For each order, display:
-
Order ID
-
Customer’s name
-
Product name
-
Sales amount
-
Product price
-
Salesperson’s name
💡 Answer
/* Task: Using SalesDB, Retrieve a list of all orders, along with the related customer, product, and employee details. For each order, display: - Order ID - Customer's name - Product name - Sales amount - Product price - Salesperson's name */ USE SalesDB; SELECT o.OrderID, o.Sales, c.FirstName AS CustomerFirstName, c.LastName AS CustomerLastName, p.Product AS ProductName, p.Price, e.FirstName AS EmployeeFirstName, e.LastName AS EmployeeLastName FROM Sales.Orders AS o LEFT JOIN Sales.Customers AS c ON o.CustomerID = c.CustomerID LEFT JOIN Sales.Products AS p ON o.ProductID = p.ProductID LEFT JOIN Sales.Employees AS e ON o.SalesPersonID = e.EmployeeID;
SQL SET Operations
SQL set operations enable you to combine results from multiple queries into a single result set. This script demonstrates the rules and usage of set operations, including UNION, UNION ALL, EXCEPT, and INTERSECT.
Table of Contents
1. SQL Operation Rules
1.1 Rule: Data Types – Column Count & Types
Task:
For a UNION, the number and data types of columns must match between the two SELECT statements.
Write a UNION query combining Sales.Customers and Sales.Employees that (intentionally) violates this rule so you can observe the error in practice.
💡 Suggested Answers
/* RULE: Data Types
The data types of columns in each query should match.
*/
SELECT
FirstName,
LastName,
Country
FROM Sales.Customers
UNION
SELECT
FirstName,
LastName
FROM Sales.Employees;
1.2 Rule: Data Types – Incompatible Types
Task:
Still focusing on the data type rule for set operations, write a UNION query where the first column in the first SELECT is CustomerID and in the second SELECT is FirstName, illustrating a data type mismatch across the two SELECTs.
💡 Suggested Answers
/* RULE: Data Types (Example)
The data types of columns in each query should match.
*/
SELECT
CustomerID,
LastName
FROM Sales.Customers
UNION
SELECT
FirstName,
LastName
FROM Sales.Employees;
1.3 Rule: Column Order
Task:
Show a UNION query where the column order differs between the two SELECTs (e.g., LastName, CustomerID vs EmployeeID, LastName) to illustrate why column position matters in set operations.
💡 Suggested Answers
/* RULE: Column Order
The order of the columns in each query must be the same.
*/
SELECT
LastName,
CustomerID
FROM Sales.Customers
UNION
SELECT
EmployeeID,
LastName
FROM Sales.Employees;
1.4 Rule: Column Aliases
Task:
Demonstrate that the column names in the result of a set operation are taken from the first SELECT.
Write a UNION query that aliases CustomerID as ID and LastName as Last_Name in the first SELECT, while the second SELECT uses plain EmployeeID and LastName.
💡 Suggested Answers
/* RULE: Column Aliases
The column names in the result set are determined by the column names
specified in the first SELECT statement.
*/
SELECT
CustomerID AS ID,
LastName AS Last_Name
FROM Sales.Customers
UNION
SELECT
EmployeeID,
LastName
FROM Sales.Employees;
1.5 Rule: Correct Columns
Task:
Illustrate why it’s important to use the correct matching columns in each SELECT of a set operation.
Write a UNION query where the first SELECT is (FirstName, LastName) from Sales.Customers and the second is (LastName, FirstName) from Sales.Employees, showing a semantically wrong but syntactically valid combination.
💡 Suggested Answers
/* RULE: Correct Columns
Ensure that the correct columns are used to maintain data consistency.
*/
SELECT
FirstName,
LastName
FROM Sales.Customers
UNION
SELECT
LastName,
FirstName
FROM Sales.Employees;
2. UNION
2.1 Task 1 – Combine Employees and Customers (without duplicates)
Task:
Combine the data from Sales.Customers and Sales.Employees into one table of names, removing duplicates.
Return FirstName and LastName from both tables using UNION.
💡 Suggested Answers
/* TASK 1:
Combine the data from Employees and Customers into one table using UNION
*/
SELECT
FirstName,
LastName
FROM Sales.Customers
UNION
SELECT
FirstName,
LastName
FROM Sales.Employees;
2.2 Task 5 – Combine Orders and OrdersArchive (without duplicates)
Task:
Create a combined orders report from Sales.Orders and Sales.OrdersArchive using UNION so that there are no duplicate rows.
Add a column SourceTable identifying whether a row came from 'Orders' or 'OrdersArchive', and order the final result by OrderID.
💡 Suggested Answers
/* TASK 5:
Combine order data from Orders and OrdersArchive into one report without duplicates
*/
SELECT
'Orders' AS SourceTable,
OrderID,
ProductID,
CustomerID,
SalesPersonID,
OrderDate,
ShipDate,
OrderStatus,
ShipAddress,
BillAddress,
Quantity,
Sales,
CreationTime
FROM Sales.Orders
UNION
SELECT
'OrdersArchive' AS SourceTable,
OrderID,
ProductID,
CustomerID,
SalesPersonID,
OrderDate,
ShipDate,
OrderStatus,
ShipAddress,
BillAddress,
Quantity,
Sales,
CreationTime
FROM Sales.OrdersArchive
ORDER BY OrderID;
3. UNION ALL
3.1 Task 2 – Combine Employees and Customers (with duplicates)
Task:
Combine the data from Sales.Customers and Sales.Employees into a single result, but this time keep duplicates.
Return FirstName and LastName from both tables using UNION ALL.
💡 Suggested Answers
/* TASK 2:
Combine the data from Employees and Customers into one table, including duplicates, using UNION ALL
*/
SELECT
FirstName,
LastName
FROM Sales.Customers
UNION ALL
SELECT
FirstName,
LastName
FROM Sales.Employees;
4. EXCEPT
4.1 Task 3 – Employees Who Are NOT Customers
Task:
Find all people who appear as employees but not as customers.
Use EXCEPT between Sales.Employees and Sales.Customers on FirstName and LastName.
💡 Suggested Answers
/* TASK 3:
Find employees who are NOT customers using EXCEPT
*/
SELECT
FirstName,
LastName
FROM Sales.Employees
EXCEPT
SELECT
FirstName,
LastName
FROM Sales.Customers;
5. INTERSECT
5.1 Task 4 – Employees Who Are Also Customers
Task:
Find all people who are both employees and customers.
Use INTERSECT between Sales.Employees and Sales.Customers on FirstName and LastName.
💡 Suggested Answers
/* TASK 4:
Find employees who are also customers using INTERSECT
*/
SELECT
FirstName,
LastName
FROM Sales.Employees
INTERSECT
SELECT
FirstName,
LastName
FROM Sales.Customers;
3. SQL Row-Level Functions
PDF Slides
Single-row (row-level) functions in SQL:
- String functions
- Numeric functions
- Date & time functions (including formats & casting)
- NULL-handling functions
CASEexpressions (CASE WHEN)
They combine information from the slides SQL Functions and the scripts you provided.
1. Short summary
Row-level functions take input values from each row and return one output value per row (they don’t group rows like aggregates). Examples:
- Clean / transform text (TRIM, LOWER, REPLACE)
- Format or calculate numbers (ROUND, ABS)
- Extract or format dates (DATEPART, FORMAT, DATEADD)
- Handle NULLs safely (COALESCE, ISNULL, NULLIF)
- Build conditional logic inside queries (CASE WHEN)
You often nest these functions: LEN(TRIM(first_name)), FORMAT(DATEADD(year,1,OrderDate),'yyyy-MM'), etc.
2. Big picture – where does this fit?
The slide “SQL Functions” shows a tree:
-
SQL Functions
-
Single-Row Functions (this chapter)
- String
- Numeric
- Date & Time
- NULL
-
Multi-Row Functions
- Aggregate (SUM, AVG, …)
- Window functions (advanced)
-
These notes are about Single-Row Functions, which are used mainly in the SELECT, WHERE, JOIN, GROUP BY, and ORDER BY clauses to compute, clean, and transform data at row level.
Think of them as Excel-style functions but in SQL, applied to each row.
3. Key concepts & definitions
| Concept | Meaning |
|---|---|
| Function | Reusable operation that takes input(s) and returns a value. E.g. LOWER('Maria') → 'maria'. |
| Single-row function | Returns one result per row (e.g. LEN(first_name) for each customer). |
| Multi-row / aggregate function | Combines multiple rows into one result (e.g. SUM(Sales)); mentioned but not the focus here. |
| Nested functions | Using the output of one function as input to another, e.g. LEN(LOWER(LEFT('Maria',2))). |
| String functions | Work on text: CONCAT, UPPER, LOWER, TRIM, REPLACE, LEN, LEFT, RIGHT, SUBSTRING. |
| Numeric functions | Work on numbers: ROUND, ABS. |
| Date & time functions | Get or transform dates/times: GETDATE, DATEPART, DATENAME, DATETRUNC, YEAR/MONTH/DAY, EOMONTH, FORMAT, CONVERT, CAST, DATEADD, DATEDIFF, ISDATE. |
| NULL functions | Functions and patterns for missing values: ISNULL, COALESCE, NULLIF, IS NULL, IS NOT NULL. |
| CASE expression | Conditional logic in SQL: CASE WHEN condition THEN result ... ELSE result END. |
4. Syntax cheat-sheet (templates)
4.1 String functions
-- Concatenate
CONCAT(string1, string2, ...)
-- Change case
LOWER(string_expression)
UPPER(string_expression)
-- Trim spaces at both ends
TRIM(string_expression)
-- Replace text
REPLACE(string_expression, old_substring, new_substring)
-- Length
LEN(string_expression)
-- Substring extraction
LEFT(string_expression, number_of_chars)
RIGHT(string_expression, number_of_chars)
SUBSTRING(string_expression, start_position, length)
These align with the string function diagrams on pages 7–15 (manipulation, calculation, extraction).
4.2 Numeric functions
-- Round to n decimal places
ROUND(numeric_expression, decimal_places)
-- Absolute value
ABS(numeric_expression)
Slide on page 18 visualizes how ROUND(3.516, 2), ROUND(3.516, 1), ROUND(3.516, 0) behave.
4.3 Date & time functions
Basic date/time & parts
GETDATE() -- current system datetime
DATETRUNC(datepart, date_value)
DATEPART(datepart, date_value)
DATENAME(datepart, date_value)
YEAR(date_value)
MONTH(date_value)
DAY(date_value)
EOMONTH(date_value)
Examples of datepart (from the “Date Parts” slide and script): year, quarter, month, day, week, weekday, hour, minute, second, millisecond, iso_week, plus abbreviations like yy, mm, dd, wk, etc.
Formatting & casting
FORMAT(value, format_string [, culture])
CONVERT(target_data_type, value [, style])
CAST(value AS target_data_type)
FORMATuses .NET-style format codes (e.g.'dd/MM/yyyy','N2').CONVERTuses style numbers (e.g.23,101).CASTis simpler and does not accept formats/styles.
Date calculations
DATEADD(datepart, number, date_value)
DATEDIFF(datepart, start_date, end_date)
ISDATE(value) -- returns 1 or 0
4.4 NULL & CASE
-- NULL handling
ISNULL(expression, replacement)
COALESCE(expr1, expr2, ..., exprN)
NULLIF(expr1, expr2)
-- Compare to NULL
expression IS NULL
expression IS NOT NULL
-- CASE (long form)
CASE
WHEN condition1 THEN result1
WHEN condition2 THEN result2
...
ELSE default_result -- optional
END
-- CASE (quick form / simple CASE)
CASE expression
WHEN value1 THEN result1
WHEN value2 THEN result2
...
ELSE default_result
END
Slides show both the “full form” (conditions after WHEN) and “quick form” where you compare a single column to many values.
5. Worked examples (with explanations)
5.1 String functions on customers table
5.1.1 CONCAT – combine columns
SELECT
CONCAT(first_name, '-', country) AS full_info
FROM customers;
CONCATjoinsfirst_nameandcountrywith a hyphen.- Unlike
+,CONCATsafely handles NULLs (in SQL Server it treats NULL as empty string).
5.1.2 LOWER / UPPER – normalize case
SELECT
LOWER(first_name) AS lower_case_name,
UPPER(first_name) AS upper_case_name
FROM customers;
Use these to standardize text before comparisons or reporting. The slide “LOWER & UPPER” shows visually converting "Maria" ↔ "maria" in both directions.
5.1.3 TRIM – remove leading/trailing spaces
SELECT
first_name,
LEN(first_name) AS len_name,
LEN(TRIM(first_name)) AS len_trim_name,
LEN(first_name) - LEN(TRIM(first_name)) AS flag
FROM customers
WHERE LEN(first_name) != LEN(TRIM(first_name));
TRIMcleans spaces at both ends (see TRIM diagram on page 11).- Comparing lengths helps find “dirty” names with hidden spaces.
5.1.4 REPLACE – swap or remove characters
-- Replace '-' with '/'
SELECT
'123-456-7890' AS phone,
REPLACE('123-456-7890', '-', '/') AS clean_phone;
-- Change file extension .txt → .csv
SELECT
'report.txt' AS old_filename,
REPLACE('report.txt', '.txt', '.csv') AS new_filename;
Slide shows that if you replace '-' with an empty string, you remove the character completely.
5.1.5 LEN – string length
SELECT
first_name,
LEN(first_name) AS name_length
FROM customers;
- Counts characters including spaces.
- Slide example counts characters in
"Maria","350", and a date string.
5.1.6 LEFT, RIGHT – fixed-size substrings
-- First 2 characters (after trimming)
SELECT
first_name,
LEFT(TRIM(first_name), 2) AS first_2_chars
FROM customers;
-- Last 2 characters
SELECT
first_name,
RIGHT(first_name, 2) AS last_2_chars
FROM customers;
- Useful for codes like
"US123"→"US"or"23". - Slide “LEFT & RIGHT” clearly shows indexes from both ends of
"Maria".
5.1.7 SUBSTRING – flexible extraction
SELECT
first_name,
SUBSTRING(TRIM(first_name), 2, LEN(first_name)) AS trimmed_name
FROM customers;
SUBSTRING(value, start, length)– slide shows usingLEN()for dynamic length to “take from position X until the end”.
5.1.8 Nesting string functions
SELECT
first_name,
UPPER(LOWER(first_name)) AS nesting
FROM customers;
- Demonstrates nesting: apply
LOWERthenUPPER. - The “Nested Function” slide shows a deeper example
LEN(LOWER(LEFT('Maria', 2))).
5.2 Numeric functions
SELECT
3.516 AS original_number,
ROUND(3.516, 2) AS round_2,
ROUND(3.516, 1) AS round_1,
ROUND(3.516, 0) AS round_0;
ROUND(3.516, 2)→3.52ROUND(3.516, 1)→3.5ROUND(3.516, 0)→4(rounded to nearest whole number).
SELECT
-10 AS original_number,
ABS(-10) AS absolute_value_negative,
ABS(10) AS absolute_value_positive;
ABSremoves the sign, helpful in distance/difference calculations.
5.3 Date & time functions
5.3.1 GETDATE & basic date display
SELECT
OrderID,
CreationTime,
'2025-08-20' AS HardCoded,
GETDATE() AS Today
FROM Sales.Orders;
- Shows the difference between stored date (
CreationTime), hard-coded constants, and system date.
5.3.2 Extracting parts: DATETRUNC, DATENAME, DATEPART, YEAR/MONTH/DAY
SELECT
OrderID,
CreationTime,
DATETRUNC(year, CreationTime) AS Year_dt,
DATETRUNC(day, CreationTime) AS Day_dt,
DATETRUNC(minute, CreationTime) AS Minute_dt,
DATENAME(month, CreationTime) AS Month_dn,
DATENAME(weekday, CreationTime) AS Weekday_dn,
DATEPART(year, CreationTime) AS Year_dp,
DATEPART(hour, CreationTime) AS Hour_dp,
YEAR(CreationTime) AS Year,
MONTH(CreationTime) AS Month,
DAY(CreationTime) AS Day
FROM Sales.Orders;
- DATETRUNC → beginning of the unit (start of year, day, minute).
- DATENAME → textual name (
'August','Wednesday'). - DATEPART/YEAR/MONTH/DAY → integers (2025, 8, 20, etc.). The big comparison table on the “How to Choose the Right Function?” slide matches these outputs.
5.3.3 Aggregation by truncated dates
SELECT
DATETRUNC(year, CreationTime) AS Creation,
COUNT(*) AS OrderCount
FROM Sales.Orders
GROUP BY DATETRUNC(year, CreationTime);
- Truncate to year, group on that, count rows.
- Same pattern works for months, weeks, etc.
5.3.4 EOMONTH – last day of month
SELECT
OrderID,
CreationTime,
EOMONTH(CreationTime) AS EndOfMonth
FROM Sales.Orders;
- Gives last calendar day of the month of
CreationTime.
5.3.5 Typical “how many orders?” queries
-- Per year
SELECT
YEAR(OrderDate) AS OrderYear,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY YEAR(OrderDate);
-- Per month (numeric)
SELECT
MONTH(OrderDate) AS OrderMonth,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY MONTH(OrderDate);
-- Per month (friendly name)
SELECT
DATENAME(month, OrderDate) AS OrderMonth,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY DATENAME(month, OrderDate);
- Use
YEAR/MONTHfor easy grouping;DATENAMEfor human-readable month names.
5.3.6 FILTER by month (February example)
SELECT *
FROM Sales.Orders
WHERE MONTH(OrderDate) = 2;
- Alternative:
WHERE DATENAME(month, OrderDate) = 'February'(but numeric is usually faster).
5.3.7 FORMAT – pretty dates and numbers
SELECT
OrderID,
CreationTime,
FORMAT(CreationTime, 'MM-dd-yyyy') AS USA_Format,
FORMAT(CreationTime, 'dd-MM-yyyy') AS EURO_Format,
FORMAT(CreationTime, 'ddd') AS Abbrev_Day,
FORMAT(CreationTime, 'MMMM') AS FullMonth
FROM Sales.Orders;
Slide and the extra script list date format specifiers (d, dd, ddd, MMMM, yyyy, HH, etc.) and number format specifiers (N, P, C, F, N1, N2, etc.).
Custom format example:
SELECT
OrderID,
CreationTime,
'Day ' + FORMAT(CreationTime, 'ddd MMM') +
' Q' + DATENAME(quarter, CreationTime) + ' ' +
FORMAT(CreationTime, 'yyyy hh:mm:ss tt') AS CustomFormat
FROM Sales.Orders;
5.3.8 FORMAT + GROUP BY
SELECT
FORMAT(CreationTime, 'MMM yy') AS OrderDate,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY FORMAT(CreationTime, 'MMM yy');
- Good for charts labeled like
"Jan 25".
5.3.9 CONVERT – style-based casting
SELECT
CONVERT(INT, '123') AS [String to Int CONVERT],
CONVERT(DATE, '2025-08-20') AS [String to Date CONVERT],
CreationTime,
CONVERT(DATE, CreationTime) AS [Datetime to Date CONVERT],
CONVERT(VARCHAR, CreationTime, 32) AS [USA Std. Style:32],
CONVERT(VARCHAR, CreationTime, 34) AS [EURO Std. Style:34]
FROM Sales.Orders;
The slide and script give large style tables – e.g., style 23 = yyyy-mm-dd, 101 = mm/dd/yyyy, 120 = yyyy-mm-dd hh:mm:ss.
5.3.10 CAST – simple type conversion
SELECT
CAST('123' AS INT) AS [String to Int],
CAST(123 AS VARCHAR) AS [Int to String],
CAST('2025-08-20' AS DATE) AS [String to Date],
CAST('2025-08-20' AS DATETIME2) AS [String to Datetime],
CreationTime,
CAST(CreationTime AS DATE) AS [Datetime to Date]
FROM Sales.Orders;
- Difference from
CONVERT: no style numbers; syntax is a bit more standard.
5.3.11 DATEADD / DATEDIFF – date arithmetic
SELECT
OrderID,
OrderDate,
DATEADD(day, -10, OrderDate) AS TenDaysBefore,
DATEADD(month, 3, OrderDate) AS ThreeMonthsLater,
DATEADD(year, 2, OrderDate) AS TwoYearsLater
FROM Sales.Orders;
SELECT
EmployeeID,
BirthDate,
DATEDIFF(year, BirthDate, GETDATE()) AS Age
FROM Sales.Employees;
SELECT
MONTH(OrderDate) AS OrderMonth,
AVG(DATEDIFF(day, OrderDate, ShipDate)) AS AvgShip
FROM Sales.Orders
GROUP BY MONTH(OrderDate);
SELECT
OrderID,
OrderDate AS CurrentOrderDate,
LAG(OrderDate) OVER (ORDER BY OrderDate) AS PreviousOrderDate,
DATEDIFF(day, LAG(OrderDate) OVER (ORDER BY OrderDate), OrderDate) AS NrOfDays
FROM Sales.Orders;
- Shows DATEDIFF in basic use and combined with window function
LAGfor time gaps.
5.3.12 ISDATE – validate date strings
SELECT
OrderDate,
ISDATE(OrderDate) AS IsValidDate,
CASE
WHEN ISDATE(OrderDate) = 1 THEN CAST(OrderDate AS DATE)
ELSE '9999-01-01'
END AS NewOrderDate
FROM (
SELECT '2025-08-20' AS OrderDate UNION
SELECT '2025-08-21' UNION
SELECT '2025-08-23' UNION
SELECT '2025-08'
) AS t;
-- WHERE ISDATE(OrderDate) = 0 -- to keep only invalid rows
- Useful before casting external text data to dates.
5.4 NULL functions & patterns
Slides explain what NULL means, where it comes from (missing data, outer joins, etc.), and several handling functions.
5.4.1 COALESCE & AVG – NULL-safe aggregation
SELECT
CustomerID,
Score,
COALESCE(Score, 0) AS Score2,
AVG(Score) OVER () AS AvgScores,
AVG(COALESCE(Score, 0)) OVER () AS AvgScores2
FROM Sales.Customers;
COALESCE(Score, 0)substitutes 0 when Score is NULL.- Shows how averages change when you treat NULL as 0 instead of ignoring it.
5.4.2 COALESCE with concatenation & math
SELECT
CustomerID,
FirstName,
LastName,
FirstName + ' ' + COALESCE(LastName, '') AS FullName,
Score,
COALESCE(Score, 0) + 10 AS ScoreWithBonus
FROM Sales.Customers;
- For last names that are NULL, use empty string so full name still works.
- Add 10 bonus points even when Score is NULL.
5.4.3 Sorting with NULL at the end
SELECT
CustomerID,
Score
FROM Sales.Customers
ORDER BY
CASE WHEN Score IS NULL THEN 1 ELSE 0 END, -- 0 first, 1 last
Score;
- Clever trick so NULLs appear after all real scores.
5.4.4 NULLIF – avoid division by zero
SELECT
OrderID,
Sales,
Quantity,
Sales / NULLIF(Quantity, 0) AS Price
FROM Sales.Orders;
- If
Quantity = 0,NULLIF(Quantity, 0)→ NULL, and the division result becomes NULL instead of error.
5.4.5 IS NULL / IS NOT NULL
-- Customers without scores
SELECT *
FROM Sales.Customers
WHERE Score IS NULL;
-- Customers with scores
SELECT *
FROM Sales.Customers
WHERE Score IS NOT NULL;
Slides emphasize: use IS NULL, not = NULL.
5.4.6 LEFT ANTI JOIN pattern – “no match” rows
SELECT
c.*,
o.OrderID
FROM Sales.Customers AS c
LEFT JOIN Sales.Orders AS o
ON c.CustomerID = o.CustomerID
WHERE o.CustomerID IS NULL;
- Returns customers who have no orders.
- This is called a left anti join. NULL appears on the right table side for non-matching rows.
5.4.7 NULL vs empty vs blank
WITH Orders AS (
SELECT 1 AS Id, 'A' AS Category UNION
SELECT 2, NULL UNION
SELECT 3, '' UNION
SELECT 4, ' '
)
SELECT
*,
DATALENGTH(Category) AS LenCategory,
TRIM(Category) AS Policy1,
NULLIF(TRIM(Category), '') AS Policy2,
COALESCE(NULLIF(TRIM(Category), ''), 'unknown') AS Policy3
FROM Orders;
NULL= no value;''= empty string;' '= spaces.- Policy2 converts “empty after trimming” to NULL; Policy3 replaces those NULLs with
'unknown'. Slides have a diagram comparingNULL, empty, and blank spaces.
5.5 CASE statements
Slides introduce CASE syntax and use cases: categorizing sales into High/Medium/Low, mapping country names to codes, transforming gender codes, etc.
5.5.1 Categorize data
SELECT
Category,
SUM(Sales) AS TotalSales
FROM (
SELECT
OrderID,
Sales,
CASE
WHEN Sales > 50 THEN 'High'
WHEN Sales > 20 THEN 'Medium'
ELSE 'Low'
END AS Category
FROM Sales.Orders
) AS t
GROUP BY Category
ORDER BY TotalSales DESC;
- Inner query uses CASE to create a Category column.
- Outer query aggregates by that category.
5.5.2 Mapping values
SELECT
CustomerID,
FirstName,
LastName,
Country,
CASE
WHEN Country = 'Germany' THEN 'DE'
WHEN Country = 'USA' THEN 'US'
ELSE 'n/a'
END AS CountryAbbr
FROM Sales.Customers;
- Exactly like the “Germany → DE, France → FR, Italy → IT” example on the slide.
Quick form:
SELECT
CustomerID,
FirstName,
LastName,
Country,
CASE Country
WHEN 'Germany' THEN 'DE'
WHEN 'USA' THEN 'US'
ELSE 'n/a'
END AS CountryAbbr2
FROM Sales.Customers;
Here CASE Country means “compare Country with each WHEN value”.
5.5.3 Handling NULLs with CASE
SELECT
CustomerID,
LastName,
Score,
CASE
WHEN Score IS NULL THEN 0
ELSE Score
END AS ScoreClean,
AVG(
CASE
WHEN Score IS NULL THEN 0
ELSE Score
END
) OVER () AS AvgCustomerClean,
AVG(Score) OVER () AS AvgCustomer
FROM Sales.Customers;
- Another way (besides COALESCE) to replace NULL before calculations.
5.5.4 Conditional aggregation
SELECT
CustomerID,
SUM(
CASE
WHEN Sales > 30 THEN 1
ELSE 0
END
) AS TotalOrdersHighSales,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY CustomerID;
CASEinsideSUMcounts only rows where condition holds.- Very common pattern for “conditional counts”.
6. Common mistakes & tips
-
Using
= NULLinstead ofIS NULL- Wrong:
WHERE Score = NULL - Right:
WHERE Score IS NULL
- Wrong:
-
Forgetting to TRIM before LEN, LEFT/RIGHT, or comparisons
' John '≠'John'. UseTRIM(first_name)for cleaner logic.
-
Losing NULL handling in concatenation
FirstName + ' ' + LastNamebecomes NULL ifLastNameis NULL.- Use
COALESCE(LastName, '')orCONCAT(FirstName, ' ', LastName).
-
Interpreting
FORMAToutput as dates/numbersFORMATreturns strings; don’t use formatted values for numeric sorting or date math. UseFORMATonly in SELECT for display.
-
Using the wrong date part function
- You want integer? Use
DATEPART/YEAR/MONTH/DAY. - You want name? Use
DATENAME. - You want “start of period”? Use
DATETRUNC. The “How to choose the right function?” slide summarises this.
- You want integer? Use
-
Dividing by 0 without NULLIF
- Always guard denominators that may be 0:
value / NULLIF(den, 0).
- Always guard denominators that may be 0:
-
Not distinguishing NULL vs empty vs spaces
- Treat
' ','', andNULLseparately if needed; useTRIM,NULLIF,COALESCE.
- Treat
-
Using CASE quick form incorrectly
- In quick form, all WHEN clauses compare to one expression:
CASE Country WHEN 'Germany' THEN ... - Don’t put full conditions like
WHEN Country = 'Germany'in quick form (that’s for searched CASE, the full form). Slides show the difference explicitly.
- In quick form, all WHEN clauses compare to one expression:
7. Memory boosters (mnemonics & associations)
7.1 String functions
-
“Cute Upper Lower Trimmed Robots Learn Little Right Stories”
- CONCAT
- UPPER
- LOWER
- TRIM
- REPLACE
- LEN
- LEFT
- RIGHT
- SUBSTRING
-
Visual: imagine a factory on the string-functions slide (page 7):
- Manipulation room → CONCAT, UPPER, LOWER, TRIM, REPLACE
- Calculation room → LEN
- Extraction room → LEFT, RIGHT, SUBSTRING.
7.2 Numeric functions
-
RA for numbers: ROUND, ABS.
- Round = “make it neat”; Abs = “make it positive”.
7.3 Date & time
Remember the four families from the date & time function overview slide:
-
C-P-V-F:
- Calculations →
DATEADD,DATEDIFF - Parts →
DATEPART,DATENAME,DATETRUNC,YEAR,MONTH,DAY,EOMONTH - Validation →
ISDATE - Formatting & Casting →
FORMAT,CONVERT,CAST
- Calculations →
7.4 NULL functions
-
I Can Not Ignore NULLs
- ISNULL
- COALESCE
- NULLIF
- IS NULL / IS NOT NULL
7.5 CASE
Think of CASE as a decision flowchart (like the arrows on the CASE slides):
CASE = Check conditions → pick the first true → stop → return result; otherwise use ELSE.
8. Active-recall questions & mini-exercises
Try answering these without looking; then check with the notes/scripts.
8.1 String functions
- Write a query that outputs
FirstNameand a columnInitialwith only the first character (no leading spaces). - How would you detect rows in
customerswherefirst_namehas leading/trailing spaces? - What is the difference between
SUBSTRING(first_name, 2, 3)andRIGHT(first_name, 3)?
8.2 Numeric functions
- What does
ROUND(3.516, 1)return? What aboutROUND(3.516, 0)? - Give an example where
ABS()is helpful in a report.
8.3 Date & time
- Write a query to show, for each order,
OrderDateand the year start date of that order (useDATETRUNC). - How do
DATEPART(month, OrderDate)andDATENAME(month, OrderDate)differ? - How can you count how many orders were placed in each calendar year using
YEAR()? - Format
CreationTimeas"Wed 20-08-2025"usingFORMAT. - Convert
CreationTimetoDATEusingCONVERTand also usingCAST. - Write a query to show
OrderDate,ShipDate, and the number of days between them. - How would you find only the rows where a text column might be an invalid date string, using
ISDATE?
8.4 NULL handling
- Using
Sales.Customers, showFullNamecomposed ofFirstName+ space +LastName, even ifLastNameis NULL. - What’s the difference between
COALESCE(Score, 0)andISNULL(Score, 0)conceptually? - How can you safely compute
Sales / QuantitywhenQuantitymight be 0? - Write an ORDER BY that sorts by
Scoreascending, but puts NULL scores last. - In the
NULL vs empty vs blankexample, what doesNULLIF(TRIM(Category), '')do?
8.5 CASE
- Write a CASE expression that labels each order as
'Big'whenSales > 100,'Medium'whenSalesbetween 50 and 100, else'Small'. - Convert country names
'Germany'and'USA'to codes'DE'and'US'using quick form CASE. - How would you use CASE inside
SUM()to count how many orders haveSales > 30per customer?
9. Ultra-short recap (exam-style checklist)
- Single-row functions = one output per row; can be nested.
- String functions: CONCAT, UPPER, LOWER, TRIM, REPLACE, LEN, LEFT, RIGHT, SUBSTRING – think manipulate, measure, extract.
- Numeric functions: ROUND for precision, ABS for magnitude.
- Date & time grouped into: parts (DATEPART/DATENAME/DATETRUNC/YEAR/MONTH/DAY/EOMONTH), calculations (DATEADD/DATEDIFF), validation (ISDATE), formatting & casting (FORMAT/CONVERT/CAST).
- Use FORMAT for display only; underlying types remain date/number.
- NULLs: use COALESCE/ISNULL/NULLIF and IS NULL / IS NOT NULL; know difference between NULL, empty string, space.
- CASE adds conditional logic for categorizing, mapping, cleaning NULLs, and conditional aggregation.
- Always guard against division by zero and mis-handling NULLs.
String Functions
Table of Contents
1. Manipulations
1.1 CONCAT
Task 1 – Concatenate columns
Write a query that concatenates first_name and country from the customers table into a single column called full_info, separated by a hyphen (-).
💡 Suggested Answers
SELECT
CONCAT(first_name, '-', country) AS full_info
FROM customers
1.2 LOWER & UPPER
Task 2 – Convert first names to lowercase
Write a query that returns all customers’ first names in lowercase, aliased as lower_case_name.
💡 Suggested Answers
SELECT
LOWER(first_name) AS lower_case_name
FROM customers
Task 3 – Convert first names to uppercase
Write a query that returns all customers’ first names in uppercase, aliased as upper_case_name.
💡 Suggested Answers
SELECT
UPPER(first_name) AS upper_case_name
FROM customers
1.3 TRIM
Task 4 – Find names with leading/trailing spaces
Write a query that finds customers whose first_name has leading or trailing spaces.
Show:
first_namelen_name= length offirst_namelen_trim_name= length ofTRIM(first_name)flag= difference between original length and trimmed length
Filter only rows where the original and trimmed lengths are different.
💡 Suggested Answers
SELECT
first_name,
LEN(first_name) len_name,
LEN(TRIM(first_name)) len_trim_name,
LEN(first_name) - LEN(TRIM(first_name)) flag
FROM customers
WHERE LEN(first_name) != LEN(TRIM(first_name))
-- WHERE first_name != TRIM(first_name)
1.4 REPLACE
Task 5 – Replace dashes in a phone number
Write a query that takes the literal string '123-456-7890' as phone and returns a clean_phone version where all dashes (-) are replaced with slashes (/).
💡 Suggested Answers
SELECT
'123-456-7890' AS phone,
REPLACE('123-456-7890', '-', '/') AS clean_phone
Task 6 – Change file extension from .txt to .csv
Write a query that takes the literal string 'report.txt' as old_filename and returns a new_filename where the .txt extension is replaced by .csv.
💡 Suggested Answers
SELECT
'report.txt' AS old_filename,
REPLACE('report.txt', '.txt', '.csv') AS new_filename
2. Calculation
2.1 LEN
Task 7 – Calculate name length
Write a query that returns each customer’s first_name and its length as name_length.
💡 Suggested Answers
SELECT
first_name,
LEN(first_name) AS name_length
FROM customers
3. Substring Extraction
3.1 LEFT & RIGHT
Task 8 – First two characters of first name
Write a query that returns first_name and the first two characters of the trimmed first_name, aliased as first_2_chars.
💡 Suggested Answers
SELECT
first_name,
LEFT(TRIM(first_name), 2) AS first_2_chars
FROM customers
Task 9 – Last two characters of first name
Write a query that returns first_name and the last two characters of first_name, aliased as last_2_chars.
💡 Suggested Answers
SELECT
first_name,
RIGHT(first_name, 2) AS last_2_chars
FROM customers
3.2 SUBSTRING
Task 10 – Remove first character from trimmed name
Write a query that returns first_name and a trimmed_name where you:
- trim spaces from
first_name - remove the first character
- keep the rest of the characters using
SUBSTRING
💡 Suggested Answers
SELECT
first_name,
SUBSTRING(TRIM(first_name), 2, LEN(first_name)) AS trimmed_name
FROM customers
4. Nesting Functions
4.1 UPPER(LOWER(first_name))
Task 11 – Demonstrate function nesting
Write a query that selects first_name and a second column nesting, where nesting applies LOWER and then UPPER to first_name using nested functions.
💡 Suggested Answers
SELECT
first_name,
UPPER(LOWER(first_name)) AS nesting
FROM customers
Number Functions
Table of Contents
1. Rounding Functions
1.1 ROUND
Task 1 – Round a number to different decimal places Write a query that:
-
Starts from the number
3.516(aliased asoriginal_number) -
Rounds it to:
- 2 decimal places (alias:
round_2) - 1 decimal place (alias:
round_1) - 0 decimal places (alias:
round_0)
- 2 decimal places (alias:
Using the ROUND() function.
💡 Suggested Answers
SELECT
3.516 AS original_number,
ROUND(3.516, 2) AS round_2,
ROUND(3.516, 1) AS round_1,
ROUND(3.516, 0) AS round_0
2. Absolute Value Function
2.1 ABS
Task 2 – Demonstrate the ABS (absolute value) function Write a query that:
- Shows
-10asoriginal_number - Returns the absolute value of
-10asabsolute_value_negative - Returns the absolute value of
10asabsolute_value_positive
Using the ABS() function.
💡 Suggested Answers
SELECT
-10 AS original_number,
ABS(-10) AS absolute_value_negative,
ABS(10) AS absolute_value_positive
Date and Time Functions
Table of Contents
- GETDATE | Date Values
- Date Part Extractions (DATETRUNC, DATENAME, DATEPART, YEAR, MONTH, DAY)
- DATETRUNC – Data Aggregation
- EOMONTH
- Date Parts | Use Cases
- FORMAT
- CONVERT
- CAST
- DATEADD / DATEDIFF
- ISDATE
1. GETDATE | Date Values
Task 1 – Display order dates and current system date
Display OrderID, CreationTime, a hard-coded date '2025-08-20' as HardCoded, and the current system date as Today using GETDATE() from Sales.Orders.
💡 Suggested Answers
SELECT
OrderID,
CreationTime,
'2025-08-20' AS HardCoded,
GETDATE() AS Today
FROM Sales.Orders;
2. Date Part Extractions (DATETRUNC, DATENAME, DATEPART, YEAR, MONTH, DAY)
Task 2 – Extract multiple date parts from CreationTime
For each order, extract different parts of CreationTime using:
DATETRUNCfor year, day, and minuteDATENAMEfor month, weekday, day, yearDATEPARTfor year, month, day, hour, quarter, weekYEAR,MONTH,DAY
Return all of these in a single query.
💡 Suggested Answers
SELECT
OrderID,
CreationTime,
-- DATETRUNC Examples
DATETRUNC(year, CreationTime) AS Year_dt,
DATETRUNC(day, CreationTime) AS Day_dt,
DATETRUNC(minute, CreationTime) AS Minute_dt,
-- DATENAME Examples
DATENAME(month, CreationTime) AS Month_dn,
DATENAME(weekday, CreationTime) AS Weekday_dn,
DATENAME(day, CreationTime) AS Day_dn,
DATENAME(year, CreationTime) AS Year_dn,
-- DATEPART Examples
DATEPART(year, CreationTime) AS Year_dp,
DATEPART(month, CreationTime) AS Month_dp,
DATEPART(day, CreationTime) AS Day_dp,
DATEPART(hour, CreationTime) AS Hour_dp,
DATEPART(quarter, CreationTime) AS Quarter_dp,
DATEPART(week, CreationTime) AS Week_dp,
YEAR(CreationTime) AS Year,
MONTH(CreationTime) AS Month,
DAY(CreationTime) AS Day
FROM Sales.Orders;
3. DATETRUNC – Data Aggregation
Task 3 – Aggregate orders by year with DATETRUNC
Using DATETRUNC, aggregate orders from Sales.Orders by year (based on CreationTime) and return the truncated year and the count of orders per year.
💡 Suggested Answers
SELECT
DATETRUNC(year, CreationTime) AS Creation,
COUNT(*) AS OrderCount
FROM Sales.Orders
GROUP BY DATETRUNC(year, CreationTime);
4. EOMONTH
Task 4 – Show end-of-month for each CreationTime
For each order, show OrderID, CreationTime, and the end of that month using EOMONTH(CreationTime).
💡 Suggested Answers
SELECT
OrderID,
CreationTime,
EOMONTH(CreationTime) AS EndOfMonth
FROM Sales.Orders;
5. Date Parts | Use Cases
Task 5 – Orders per year
Count how many orders were placed each year using YEAR(OrderDate) and GROUP BY.
💡 Suggested Answers
SELECT
YEAR(OrderDate) AS OrderYear,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY YEAR(OrderDate);
Task 6 – Orders per month (numeric month)
Count how many orders were placed each month using MONTH(OrderDate).
💡 Suggested Answers
SELECT
MONTH(OrderDate) AS OrderMonth,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY MONTH(OrderDate);
Task 7 – Orders per month (friendly month names)
Count how many orders were placed each month, using DATENAME(month, OrderDate) to show the month name.
💡 Suggested Answers
SELECT
DATENAME(month, OrderDate) AS OrderMonth,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY DATENAME(month, OrderDate);
Task 8 – Orders in February
Show all orders that were placed during the month of February, using MONTH(OrderDate).
💡 Suggested Answers
SELECT
*
FROM Sales.Orders
WHERE MONTH(OrderDate) = 2;
6. FORMAT
Task 9 – Format CreationTime with various patterns
For each order, show OrderID, CreationTime, and formatted versions using FORMAT:
'MM-dd-yyyy'asUSA_Format'dd-MM-yyyy'asEURO_Format'dd','ddd','dddd','MM','MMM','MMMM'in separate columns
💡 Suggested Answers
SELECT
OrderID,
CreationTime,
FORMAT(CreationTime, 'MM-dd-yyyy') AS USA_Format,
FORMAT(CreationTime, 'dd-MM-yyyy') AS EURO_Format,
FORMAT(CreationTime, 'dd') AS dd,
FORMAT(CreationTime, 'ddd') AS ddd,
FORMAT(CreationTime, 'dddd') AS dddd,
FORMAT(CreationTime, 'MM') AS MM,
FORMAT(CreationTime, 'MMM') AS MMM,
FORMAT(CreationTime, 'MMMM') AS MMMM
FROM Sales.Orders;
Task 10 – Custom formatted CreationTime
Display CreationTime in a custom string format like:
Day Wed Jan Q1 2025 12:34:56 PM
by combining FORMAT and DATENAME(quarter, ...).
💡 Suggested Answers
SELECT
OrderID,
CreationTime,
'Day ' + FORMAT(CreationTime, 'ddd MMM') +
' Q' + DATENAME(quarter, CreationTime) + ' ' +
FORMAT(CreationTime, 'yyyy hh:mm:ss tt') AS CustomFormat
FROM Sales.Orders;
Task 11 – Orders per month-year (e.g., “Jan 25”)
Using FORMAT(CreationTime, 'MMM yy'), count how many orders were placed for each "MMM yy" period.
💡 Suggested Answers
SELECT
FORMAT(CreationTime, 'MMM yy') AS OrderDate,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY FORMAT(CreationTime, 'MMM yy');
7. CONVERT
Task 12 – Demonstrate CONVERT for dates and strings
Use CONVERT to:
- Convert
'123'toINT - Convert
'2025-08-20'toDATE - Convert
CreationTimetoDATE - Convert
CreationTimetoVARCHARwith two different styles (32 and 34)
💡 Suggested Answers
SELECT
CONVERT(INT, '123') AS [String to Int CONVERT],
CONVERT(DATE, '2025-08-20') AS [String to Date CONVERT],
CreationTime,
CONVERT(DATE, CreationTime) AS [Datetime to Date CONVERT],
CONVERT(VARCHAR, CreationTime, 32) AS [USA Std. Style:32],
CONVERT(VARCHAR, CreationTime, 34) AS [EURO Std. Style:34]
FROM Sales.Orders;
8. CAST
Task 13 – Demonstrate CAST for type conversion
Using CAST, convert:
'123'toINT123toVARCHAR'2025-08-20'toDATEandDATETIME2CreationTimetoDATE
💡 Suggested Answers
SELECT
CAST('123' AS INT) AS [String to Int],
CAST(123 AS VARCHAR) AS [Int to String],
CAST('2025-08-20' AS DATE) AS [String to Date],
CAST('2025-08-20' AS DATETIME2) AS [String to Datetime],
CreationTime,
CAST(CreationTime AS DATE) AS [Datetime to Date]
FROM Sales.Orders;
9. DATEADD / DATEDIFF
Task 14 – Date arithmetic on OrderDate
For each order, show:
OrderDateTenDaysBefore= 10 days beforeOrderDateThreeMonthsLater= 3 months afterOrderDateTwoYearsLater= 2 years afterOrderDate
using DATEADD.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
DATEADD(day, -10, OrderDate) AS TenDaysBefore,
DATEADD(month, 3, OrderDate) AS ThreeMonthsLater,
DATEADD(year, 2, OrderDate) AS TwoYearsLater
FROM Sales.Orders;
Task 15 – Calculate employee age
Using DATEDIFF, calculate the age (in years) of employees based on BirthDate and current date from Sales.Employees.
💡 Suggested Answers
SELECT
EmployeeID,
BirthDate,
DATEDIFF(year, BirthDate, GETDATE()) AS Age
FROM Sales.Employees;
Task 16 – Average shipping duration per month
From Sales.Orders, compute the average shipping duration in days for each month (based on OrderDate and ShipDate).
💡 Suggested Answers
SELECT
MONTH(OrderDate) AS OrderMonth,
AVG(DATEDIFF(day, OrderDate, ShipDate)) AS AvgShip
FROM Sales.Orders
GROUP BY MONTH(OrderDate);
Task 17 – Time gap between consecutive orders
Using DATEDIFF and LAG, compute for each order:
CurrentOrderDatePreviousOrderDateNrOfDays= number of days between the previous order and the current one
💡 Suggested Answers
SELECT
OrderID,
OrderDate AS CurrentOrderDate,
LAG(OrderDate) OVER (ORDER BY OrderDate) AS PreviousOrderDate,
DATEDIFF(day, LAG(OrderDate) OVER (ORDER BY OrderDate), OrderDate) AS NrOfDays
FROM Sales.Orders;
10. ISDATE
Task 18 – Validate dates and convert valid ones
Given a derived table with several OrderDate strings, use ISDATE to:
- Show whether each string is a valid date (
IsValidDate) - Convert valid values to
DATEand otherwise set them to'9999-01-01'inNewOrderDate
(Optionally, you can filter only invalid rows using the commented WHERE.)
💡 Suggested Answers
SELECT
OrderDate,
ISDATE(OrderDate) AS IsValidDate,
CASE
WHEN ISDATE(OrderDate) = 1 THEN CAST(OrderDate AS DATE)
ELSE '9999-01-01'
END AS NewOrderDate
FROM (
SELECT '2025-08-20' AS OrderDate UNION
SELECT '2025-08-21' UNION
SELECT '2025-08-23' UNION
SELECT '2025-08'
) AS t
-- WHERE ISDATE(OrderDate) = 0
Date and Time Formats
Table of Contents
1. Number Format Specifiers
Task 1 – Explore numeric format specifiers with FORMAT
Write a query that shows how the number 1234.56 looks when formatted using different numeric format specifiers:
- Standard numeric formats:
N,P,C,E,F - Custom numeric formats with precision:
N0,N1,N2 - Culture-specific numeric formats using
de-DEanden-US
The result should show two columns:
FormatType– the format specifier usedFormattedValue– the output ofFORMAT(1234.56, ...)
💡 Suggested Answers
SELECT 'N' AS FormatType, FORMAT(1234.56, 'N') AS FormattedValue
UNION ALL
SELECT 'P' AS FormatType, FORMAT(1234.56, 'P') AS FormattedValue
UNION ALL
SELECT 'C' AS FormatType, FORMAT(1234.56, 'C') AS FormattedValue
UNION ALL
SELECT 'E' AS FormatType, FORMAT(1234.56, 'E') AS FormattedValue
UNION ALL
SELECT 'F' AS FormatType, FORMAT(1234.56, 'F') AS FormattedValue
UNION ALL
SELECT 'N0' AS FormatType, FORMAT(1234.56, 'N0') AS FormattedValue
UNION ALL
SELECT 'N1' AS FormatType, FORMAT(1234.56, 'N1') AS FormattedValue
UNION ALL
SELECT 'N2' AS FormatType, FORMAT(1234.56, 'N2') AS FormattedValue
UNION ALL
SELECT 'N_de-DE' AS FormatType, FORMAT(1234.56, 'N', 'de-DE') AS FormattedValue
UNION ALL
SELECT 'N_en-US' AS FormatType, FORMAT(1234.56, 'N', 'en-US') AS FormattedValue;
2. Date Format Specifiers
Task 2 – Explore date and time format specifiers with FORMAT
Write a query that shows how GETDATE() is formatted using different date and time format specifiers (for example: D, d, dd, ddd, dddd, M, MM, MMM, MMMM, yy, yyyy, hh, HH, m, mm, s, ss, f, ff, fff, t, tt, etc.).
Each row should show:
FormatType– the format string you pass toFORMATFormattedValue– the result ofFORMAT(GETDATE(), FormatType)Description– a short explanation of what that format does
Note: The original script lists many specifiers; this is the same pattern in one big
UNION ALLquery.
💡 Suggested Answers
SELECT
'D' AS FormatType,
FORMAT(GETDATE(), 'D') AS FormattedValue,
'Full date pattern' AS Description
UNION ALL
SELECT
'd',
FORMAT(GETDATE(), 'd'),
'Short date pattern'
UNION ALL
SELECT
'dd',
FORMAT(GETDATE(), 'dd'),
'Day of month with leading zero'
UNION ALL
SELECT
'ddd',
FORMAT(GETDATE(), 'ddd'),
'Abbreviated name of day'
UNION ALL
SELECT
'dddd',
FORMAT(GETDATE(), 'dddd'),
'Full name of day'
UNION ALL
SELECT
'M',
FORMAT(GETDATE(), 'M'),
'Month without leading zero'
UNION ALL
SELECT
'MM',
FORMAT(GETDATE(), 'MM'),
'Month with leading zero'
UNION ALL
SELECT
'MMM',
FORMAT(GETDATE(), 'MMM'),
'Abbreviated name of month'
UNION ALL
SELECT
'MMMM',
FORMAT(GETDATE(), 'MMMM'),
'Full name of month'
UNION ALL
SELECT
'yy',
FORMAT(GETDATE(), 'yy'),
'Two-digit year'
UNION ALL
SELECT
'yyyy',
FORMAT(GETDATE(), 'yyyy'),
'Four-digit year'
UNION ALL
SELECT
'hh',
FORMAT(GETDATE(), 'hh'),
'Hour in 12-hour clock with leading zero'
UNION ALL
SELECT
'HH',
FORMAT(GETDATE(), 'HH'),
'Hour in 24-hour clock with leading zero'
UNION ALL
SELECT
'm',
FORMAT(GETDATE(), 'm'),
'Minute without leading zero'
UNION ALL
SELECT
'mm',
FORMAT(GETDATE(), 'mm'),
'Minute with leading zero'
UNION ALL
SELECT
's',
FORMAT(GETDATE(), 's'),
'Second without leading zero'
UNION ALL
SELECT
'ss',
FORMAT(GETDATE(), 'ss'),
'Second with leading zero'
UNION ALL
SELECT
'f',
FORMAT(GETDATE(), 'f'),
'Tenths of a second'
UNION ALL
SELECT
'ff',
FORMAT(GETDATE(), 'ff'),
'Hundredths of a second'
UNION ALL
SELECT
'fff',
FORMAT(GETDATE(), 'fff'),
'Milliseconds (thousandths of a second)'
UNION ALL
SELECT
't',
FORMAT(GETDATE(), 't'),
'Single character AM/PM designator'
UNION ALL
SELECT
'tt',
FORMAT(GETDATE(), 'tt'),
'Two character AM/PM designator';
This is a representative subset of the full list in the script. The original file continues in the same
UNION ALLpattern for all other date/time specifiers.
3. All Date Parts
Task 3 – Compare DATEPART, DATENAME, and DATETRUNC for different date parts
Write a query that:
-
For each supported date part (e.g.
year,yy,yyyy,quarter,month,mm,m,day,dd,d,dayofyear,dy,y,week,wk,isowk,weekday,dw,hour,hh,minute,mi,n,second,ss,s,millisecond,ms…) -
Shows one row with:
DatePart– the name/abbreviation of the date partDatePart_Output–DATEPART(<datepart>, GETDATE())DateName_Output–DATENAME(<datepart>, GETDATE())DateTrunc_Output–DATETRUNC(<datepart>, GETDATE())
This should be written as a single query using many UNION ALL blocks.
💡 Suggested Answers
SELECT
'Year' AS DatePart,
DATEPART(year, GETDATE()) AS DatePart_Output,
DATENAME(year, GETDATE()) AS DateName_Output,
DATETRUNC(year, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'YY',
DATEPART(yy, GETDATE()) AS DatePart_Output,
DATENAME(yy, GETDATE()) AS DateName_Output,
DATETRUNC(yy, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'YYYY',
DATEPART(yyyy, GETDATE()) AS DatePart_Output,
DATENAME(yyyy, GETDATE()) AS DateName_Output,
DATETRUNC(yyyy, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Quarter',
DATEPART(quarter, GETDATE()) AS DatePart_Output,
DATENAME(quarter, GETDATE()) AS DateName_Output,
DATETRUNC(quarter, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Q',
DATEPART(q, GETDATE()) AS DatePart_Output,
DATENAME(q, GETDATE()) AS DateName_Output,
DATETRUNC(q, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Month',
DATEPART(month, GETDATE()) AS DatePart_Output,
DATENAME(month, GETDATE()) AS DateName_Output,
DATETRUNC(month, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'MM',
DATEPART(mm, GETDATE()) AS DatePart_Output,
DATENAME(mm, GETDATE()) AS DateName_Output,
DATETRUNC(mm, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'M',
DATEPART(m, GETDATE()) AS DatePart_Output,
DATENAME(m, GETDATE()) AS DateName_Output,
DATETRUNC(m, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'DayOfYear',
DATEPART(dayofyear, GETDATE()) AS DatePart_Output,
DATENAME(dayofyear, GETDATE()) AS DateName_Output,
DATETRUNC(dayofyear, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'DY',
DATEPART(dy, GETDATE()) AS DatePart_Output,
DATENAME(dy, GETDATE()) AS DateName_Output,
DATETRUNC(dy, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Y',
DATEPART(y, GETDATE()) AS DatePart_Output,
DATENAME(y, GETDATE()) AS DateName_Output,
DATETRUNC(y, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Day',
DATEPART(day, GETDATE()) AS DatePart_Output,
DATENAME(day, GETDATE()) AS DateName_Output,
DATETRUNC(day, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'DD',
DATEPART(dd, GETDATE()) AS DatePart_Output,
DATENAME(dd, GETDATE()) AS DateName_Output,
DATETRUNC(dd, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'D',
DATEPART(d, GETDATE()) AS DatePart_Output,
DATENAME(d, GETDATE()) AS DateName_Output,
DATETRUNC(d, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Week',
DATEPART(week, GETDATE()) AS DatePart_Output,
DATENAME(week, GETDATE()) AS DateName_Output,
DATETRUNC(week, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'WK',
DATEPART(wk, GETDATE()) AS DatePart_Output,
DATENAME(wk, GETDATE()) AS DateName_Output,
DATETRUNC(wk, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'ISO_WEEK',
DATEPART(isowk, GETDATE()) AS DatePart_Output,
DATENAME(isowk, GETDATE()) AS DateName_Output,
DATETRUNC(isowk, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'WeekDay',
DATEPART(weekday, GETDATE()) AS DatePart_Output,
DATENAME(weekday, GETDATE()) AS DateName_Output,
DATETRUNC(weekday, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'DW',
DATEPART(dw, GETDATE()) AS DatePart_Output,
DATENAME(dw, GETDATE()) AS DateName_Output,
DATETRUNC(dw, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Hour',
DATEPART(hour, GETDATE()) AS DatePart_Output,
DATENAME(hour, GETDATE()) AS DateName_Output,
DATETRUNC(hour, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'HH',
DATEPART(hh, GETDATE()) AS DatePart_Output,
DATENAME(hh, GETDATE()) AS DateName_Output,
DATETRUNC(hh, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Minute',
DATEPART(minute, GETDATE()) AS DatePart_Output,
DATENAME(minute, GETDATE()) AS DateName_Output,
DATETRUNC(minute, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'MI',
DATEPART(mi, GETDATE()) AS DatePart_Output,
DATENAME(mi, GETDATE()) AS DateName_Output,
DATETRUNC(mi, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'N',
DATEPART(n, GETDATE()) AS DatePart_Output,
DATENAME(n, GETDATE()) AS DateName_Output,
DATETRUNC(n, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Second',
DATEPART(second, GETDATE()) AS DatePart_Output,
DATENAME(second, GETDATE()) AS DateName_Output,
DATETRUNC(second, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'SS',
DATEPART(ss, GETDATE()) AS DatePart_Output,
DATENAME(ss, GETDATE()) AS DateName_Output,
DATETRUNC(ss, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'S',
DATEPART(s, GETDATE()) AS DatePart_Output,
DATENAME(s, GETDATE()) AS DateName_Output,
DATETRUNC(s, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'Millisecond',
DATEPART(millisecond, GETDATE()) AS DatePart_Output,
DATENAME(millisecond, GETDATE()) AS DateName_Output,
DATETRUNC(millisecond, GETDATE()) AS DateTrunc_Output
UNION ALL
SELECT
'MS',
DATEPART(ms, GETDATE()) AS DatePart_Output,
DATENAME(ms, GETDATE()) AS DateName_Output,
DATETRUNC(ms, GETDATE()) AS DateTrunc_Output;
4. All Culture Formats
Task 4 – Explore culture-specific number and date formatting
Write a query that shows how:
- The number
1234567.89 - And
GETDATE()
are formatted in various cultures (locale codes), using FORMAT with a culture argument, like:
FORMAT(1234567.89, 'N', 'en-US')
FORMAT(GETDATE(), 'D', 'en-US')
Each row should show:
CultureCode– e.g.'en-US','fr-FR','de-DE'FormattedNumber–FORMAT(1234567.89, 'N', CultureCode)FormattedDate–FORMAT(GETDATE(), 'D', CultureCode)
as a single query with UNION ALL.
💡 Suggested Answers
SELECT
'en-US' AS CultureCode,
FORMAT(1234567.89, 'N', 'en-US') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'en-US') AS FormattedDate
UNION ALL
SELECT
'en-GB' AS CultureCode,
FORMAT(1234567.89, 'N', 'en-GB') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'en-GB') AS FormattedDate
UNION ALL
SELECT
'fr-FR' AS CultureCode,
FORMAT(1234567.89, 'N', 'fr-FR') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'fr-FR') AS FormattedDate
UNION ALL
SELECT
'de-DE' AS CultureCode,
FORMAT(1234567.89, 'N', 'de-DE') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'de-DE') AS FormattedDate
UNION ALL
SELECT
'es-ES' AS CultureCode,
FORMAT(1234567.89, 'N', 'es-ES') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'es-ES') AS FormattedDate
UNION ALL
SELECT
'zh-CN' AS CultureCode,
FORMAT(1234567.89, 'N', 'zh-CN') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'zh-CN') AS FormattedDate
UNION ALL
SELECT
'ja-JP' AS CultureCode,
FORMAT(1234567.89, 'N', 'ja-JP') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'ja-JP') AS FormattedDate
UNION ALL
SELECT
'ko-KR' AS CultureCode,
FORMAT(1234567.89, 'N', 'ko-KR') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'ko-KR') AS FormattedDate
UNION ALL
SELECT
'pt-BR' AS CultureCode,
FORMAT(1234567.89, 'N', 'pt-BR') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'pt-BR') AS FormattedDate
UNION ALL
SELECT
'it-IT' AS CultureCode,
FORMAT(1234567.89, 'N', 'it-IT') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'it-IT') AS FormattedDate
UNION ALL
SELECT
'nl-NL' AS CultureCode,
FORMAT(1234567.89, 'N', 'nl-NL') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'nl-NL') AS FormattedDate
UNION ALL
SELECT
'ru-RU' AS CultureCode,
FORMAT(1234567.89, 'N', 'ru-RU') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'ru-RU') AS FormattedDate
UNION ALL
SELECT
'ar-SA' AS CultureCode,
FORMAT(1234567.89, 'N', 'ar-SA') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'ar-SA') AS FormattedDate
UNION ALL
SELECT
'el-GR' AS CultureCode,
FORMAT(1234567.89, 'N', 'el-GR') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'el-GR') AS FormattedDate
UNION ALL
SELECT
'tr-TR' AS CultureCode,
FORMAT(1234567.89, 'N', 'tr-TR') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'tr-TR') AS FormattedDate
UNION ALL
SELECT
'he-IL' AS CultureCode,
FORMAT(1234567.89, 'N', 'he-IL') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'he-IL') AS FormattedDate
UNION ALL
SELECT
'hi-IN' AS CultureCode,
FORMAT(1234567.89, 'N', 'hi-IN') AS FormattedNumber,
FORMAT(GETDATE(), 'D', 'hi-IN') AS FormattedDate;
NULL Functions
Table of Contents
1. Handle NULL – Data Aggregation
1.1 Average scores with COALESCE
Task 1 – Compare averages with and without replacing NULL
Find the average scores of customers from Sales.Customers.
Show:
CustomerIDScoreScore2=Scorewith NULL replaced by 0 usingCOALESCEAvgScores= average ofScore(standard AVG, ignores NULLs)AvgScores2= average ofScore2(NULLs treated as 0)
Use COALESCE and window AVG().
💡 Suggested Answers
SELECT
CustomerID,
Score,
COALESCE(Score, 0) AS Score2,
AVG(Score) OVER () AS AvgScores,
AVG(COALESCE(Score, 0)) OVER () AS AvgScores2
FROM Sales.Customers;
2. Handle NULL – Mathematical Operators
2.1 Full name and bonus score
Task 2 – Build full name and add score bonus safely
Display customers from Sales.Customers with:
CustomerID,FirstName,LastNameFullName=FirstName + ' ' + LastName, but handle NULLLastNameScoreScoreWithBonus=Score + 10, treating NULL as 0
Use COALESCE for both name and score.
💡 Suggested Answers
SELECT
CustomerID,
FirstName,
LastName,
FirstName + ' ' + COALESCE(LastName, '') AS FullName,
Score,
COALESCE(Score, 0) + 10 AS ScoreWithBonus
FROM Sales.Customers;
3. Handle NULL – Sorting Data
3.1 Sort with NULLs last
Task 3 – Order by score with NULLs at the end
From Sales.Customers, return:
CustomerIDScore
Sort from lowest to highest score, but ensure that rows where Score is NULL appear after all non-NULL scores.
💡 Suggested Answers
SELECT
CustomerID,
Score
FROM Sales.Customers
ORDER BY
CASE WHEN Score IS NULL THEN 1 ELSE 0 END,
Score;
4. NULLIF – Division by Zero
4.1 Safe division for price
Task 4 – Compute price while avoiding division by zero
From Sales.Orders, calculate the price as Sales / Quantity.
Show:
OrderIDSalesQuantityPrice=Sales / Quantity, but avoid division by zero usingNULLIF
Rows with Quantity = 0 should have Price as NULL instead of causing an error.
💡 Suggested Answers
SELECT
OrderID,
Sales,
Quantity,
Sales / NULLIF(Quantity, 0) AS Price
FROM Sales.Orders;
5. IS NULL – IS NOT NULL
5.1 Customers with no score
Task 5 – Find customers where Score is NULL
Select all columns from Sales.Customers for customers whose Score is NULL.
💡 Suggested Answers
SELECT *
FROM Sales.Customers
WHERE Score IS NULL;
5.2 Customers with a score
Task 6 – Find customers where Score is NOT NULL
Select all columns from Sales.Customers for customers whose Score is not NULL.
💡 Suggested Answers
SELECT *
FROM Sales.Customers
WHERE Score IS NOT NULL;
6. LEFT ANTI JOIN
6.1 Customers with no orders
Task 7 – List customers who never placed any order
Using Sales.Customers (c) and Sales.Orders (o), return all details for customers who have not placed any orders.
- Use a
LEFT JOINonCustomerID - Filter for rows where there is no match in
Sales.Orders(left anti join pattern)
💡 Suggested Answers
SELECT
c.*,
o.OrderID
FROM Sales.Customers AS c
LEFT JOIN Sales.Orders AS o
ON c.CustomerID = o.CustomerID
WHERE o.CustomerID IS NULL;
7. NULL vs Empty String vs Blank Spaces
7.1 Compare NULL / ‘’ / ’ ’
Task 8 – Demonstrate NULL vs empty vs blank
Create a small dataset Orders with 4 rows:
Id = 1,Category = 'A'Id = 2,Category = NULLId = 3,Category = ''(empty string)Id = 4,Category = ' '(two spaces)
Then select:
- All columns (
*) LenCategory=DATALENGTH(Category)Policy1=TRIM(Category)Policy2=NULLIF(TRIM(Category), '')Policy3=COALESCE(NULLIF(TRIM(Category), ''), 'unknown')
to see how NULL, empty string, and blank spaces behave differently.
💡 Suggested Answers
WITH Orders AS (
SELECT 1 AS Id, 'A' AS Category UNION
SELECT 2, NULL UNION
SELECT 3, '' UNION
SELECT 4, ' '
)
SELECT
*,
DATALENGTH(Category) AS LenCategory,
TRIM(Category) AS Policy1,
NULLIF(TRIM(Category), '') AS Policy2,
COALESCE(NULLIF(TRIM(Category), ''), 'unknown') AS Policy3
FROM Orders;
CASE Statements
Table of Contents
1. Categorize Data
Task 1 – Categorize orders into High / Medium / Low and sum sales
Create a report showing total sales for each category:
- High: Sales over 50
- Medium: Sales between 20 and 50
- Low: Sales 20 or less
Return:
Category(High / Medium / Low)TotalSales(sum ofSalesper category)
Sort the result from highest to lowest TotalSales.
💡 Suggested Answers
SELECT
Category,
SUM(Sales) AS TotalSales
FROM (
SELECT
OrderID,
Sales,
CASE
WHEN Sales > 50 THEN 'High'
WHEN Sales > 20 THEN 'Medium'
ELSE 'Low'
END AS Category
FROM Sales.Orders
) AS t
GROUP BY Category
ORDER BY TotalSales DESC;
2. Mapping
Task 2 – Map country names to country codes
Retrieve customer details and add a country abbreviation column based on Country:
'Germany'→'DE''USA'→'US'- Anything else →
'n/a'
Return:
CustomerID,FirstName,LastName,Country,CountryAbbr
💡 Suggested Answers
SELECT
CustomerID,
FirstName,
LastName,
Country,
CASE
WHEN Country = 'Germany' THEN 'DE'
WHEN Country = 'USA' THEN 'US'
ELSE 'n/a'
END AS CountryAbbr
FROM Sales.Customers;
3. Quick Form of CASE Statement
Task 3 – Use quick-form CASE to map country codes
Retrieve customer details again, but this time:
- Keep the full-form CASE version as
CountryAbbr - Add a quick-form CASE version as
CountryAbbr2, usingCASE Country WHEN ...
Both should map:
'Germany'→'DE''USA'→'US'- Others →
'n/a'
Return:
CustomerID,FirstName,LastName,Country,CountryAbbr,CountryAbbr2
💡 Suggested Answers
SELECT
CustomerID,
FirstName,
LastName,
Country,
CASE
WHEN Country = 'Germany' THEN 'DE'
WHEN Country = 'USA' THEN 'US'
ELSE 'n/a'
END AS CountryAbbr,
CASE Country
WHEN 'Germany' THEN 'DE'
WHEN 'USA' THEN 'US'
ELSE 'n/a'
END AS CountryAbbr2
FROM Sales.Customers;
4. Handling Nulls
Task 4 – Clean NULL scores and compare averages
From Sales.Customers, calculate the average score of customers, treating NULL as 0.
Return:
CustomerIDLastNameScoreScoreClean=Score, butNULLreplaced with0usingCASEAvgCustomerClean= windowAVG()ofScoreCleanAvgCustomer= windowAVG()of the originalScore(ignoring NULLs)
💡 Suggested Answers
SELECT
CustomerID,
LastName,
Score,
CASE
WHEN Score IS NULL THEN 0
ELSE Score
END AS ScoreClean,
AVG(
CASE
WHEN Score IS NULL THEN 0
ELSE Score
END
) OVER () AS AvgCustomerClean,
AVG(Score) OVER () AS AvgCustomer
FROM Sales.Customers;
5. Conditional Aggregation
Task 5 – Count high-sales orders per customer
Using Sales.Orders, count how many orders each customer made with Sales greater than 30.
Return:
CustomerIDTotalOrdersHighSales= number of orders whereSales > 30TotalOrders= total number of orders per customer
Use CASE inside SUM() for conditional counting.
💡 Suggested Answers
SELECT
CustomerID,
SUM(
CASE
WHEN Sales > 30 THEN 1
ELSE 0
END
) AS TotalOrdersHighSales,
COUNT(*) AS TotalOrders
FROM Sales.Orders
GROUP BY CustomerID;
4. Aggregation Analytical Functions
PDF Slides
1. Title & Short Summary
Aggregate & Window (Analytical) Functions in SQL
This lesson shows how to:
- Summarize data with aggregate functions (
COUNT,SUM,AVG,MIN,MAX). - Use GROUP BY to turn detailed rows into summary rows.
- Use window (analytic) functions to compute aggregates, rankings, and comparisons without losing row-level detail.
2. Big Picture – Where This Fits
In SQL learning, this chapter sits in “data analysis & analytics”:
-
Earlier you learned basic SELECT–FROM–WHERE and maybe simple
GROUP BY. -
Here you go beyond simple aggregation:
- Aggregate functions + GROUP BY → classic reports (“sales per customer”).
- Window functions → advanced analytics: running totals, moving averages, top-N per group, month-over-month changes, percentiles, etc.
Think of it like this:
- GROUP BY → compress rows into summaries (you lose row detail).
- WINDOW → add extra calculated columns alongside the original rows. (Diagram on pages 6–8 of the slides illustrates this contrast. )
3. Key Concepts & Definitions
3.1 Core aggregates
| Concept | Meaning | Notes / Data types |
|---|---|---|
COUNT(expr) | Number of rows (or non-NULL values of expr) | Works for any type. COUNT(*) counts rows including NULLs; COUNT(col) ignores NULLs. |
SUM(expr) | Sum of numeric values | Numbers only. NULLs are ignored. |
AVG(expr) | Average of numeric values | Numbers only. NULLs ignored by default. Use COALESCE to treat NULLs as 0. |
MIN(expr) | Smallest value | Any comparable type. |
MAX(expr) | Largest value | Any comparable type. |
3.2 GROUP BY vs Window functions
| Feature | GROUP BY | Window functions (OVER) |
|---|---|---|
| Output row count | Decreases (rows collapsed per group) | Same as input (row-level) |
| Purpose | Simple aggregation reports | Aggregation + detailed rows |
| Typical use | “Total sales per product” | “Total sales per product on each row”, running totals, rankings |
| Syntax idea | SELECT group_col, SUM(x) FROM t GROUP BY group_col | SELECT x, SUM(x) OVER (PARTITION BY group_col) FROM t |
(See the side-by-side diagrams on pages 6–10. )
3.3 Window syntax pieces
From the slides, a generic window expression looks like:
<window_function>(expression)
OVER (
[PARTITION BY ...]
[ORDER BY ...]
[frame_clause]
)
Pieces:
- Window function – what calculation to do (e.g.
SUM,ROW_NUMBER,LAG). - Expression – argument(s) you pass, e.g.
Sales,NTILE(3),LEAD(Sales, 2, 0). - PARTITION BY – splits rows into windows (groups); calculations restart in each partition.
- ORDER BY – defines the order inside each partition (essential for running totals, rankings, LAG/LEAD).
- FRAME clause (
ROWS/RANGE ...) – defines the subset of rows in the partition the function can “see” (for running vs rolling totals).
3.4 Categories of window functions
Visual on pages 9, 15, 39, 41, 98 groups window functions into:
| Category | Functions | What they do |
|---|---|---|
| Window aggregates | COUNT, SUM, AVG, MIN, MAX | One summary value per row (over a window) |
| Ranking | ROW_NUMBER, RANK, DENSE_RANK, NTILE, CUME_DIST, PERCENT_RANK | Assign positions / buckets based on ordering |
| Value / analytic | LAG, LEAD, FIRST_VALUE, LAST_VALUE | Pull a value from another row in the window |
4. Syntax Cheat-Sheet
4.1 Plain aggregate functions
From 13_Aggregate_Functions.sql:
-- Count rows
SELECT COUNT(*) AS total_customers
FROM customers;
-- Total sales
SELECT SUM(sales) AS total_sales
FROM orders;
-- Average, max, min
SELECT AVG(sales) AS avg_sales FROM orders;
SELECT MAX(score) AS max_score FROM customers;
SELECT MIN(score) AS min_score FROM customers;
4.2 GROUP BY pattern
SELECT
customer_id,
COUNT(*) AS total_orders,
SUM(sales) AS total_sales,
AVG(sales) AS avg_sales,
MAX(sales) AS highest_sales,
MIN(sales) AS lowest_sales
FROM orders
GROUP BY customer_id;
This matches the classic “aggregations per group” example.
4.3 Basic window function patterns
Total across all rows (no partition):
SUM(Sales) OVER () AS Total_Sales
Used in several scripts and in slides to show “entire dataset” calculations.
Per group / partition:
SUM(Sales) OVER (PARTITION BY ProductID) AS Sales_By_Product
Multiple partition levels:
SUM(Sales) OVER () AS Total_Sales,
SUM(Sales) OVER (PARTITION BY ProductID) AS Sales_By_Product,
SUM(Sales) OVER (PARTITION BY ProductID, OrderStatus) AS Sales_By_Product_Status
(from Window Basics Task 5).
4.4 ORDER and FRAME – running vs rolling
Running total by status (default frame):
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS Total_Sales
Rolling window (current + next 2 rows):
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS BETWEEN CURRENT ROW AND 2 FOLLOWING
) AS Total_Sales
(from Tasks 7–11 in Window Basics and the rolling/ running diagrams on pages 59–64).
4.5 Window aggregate samples
-- Count orders overall and per customer
SELECT
OrderID,
OrderDate,
CustomerID,
COUNT(*) OVER () AS TotalOrders,
COUNT(*) OVER (PARTITION BY CustomerID) AS OrdersByCustomers
FROM Sales.Orders;
-- Moving average of Sales per product
SELECT
OrderID,
ProductID,
OrderDate,
Sales,
AVG(Sales) OVER (PARTITION BY ProductID) AS AvgByProduct,
AVG(Sales) OVER (PARTITION BY ProductID ORDER BY OrderDate) AS MovingAvg
FROM Sales.Orders;
(from 15_Window_Aggregations.sql).
4.6 Ranking function patterns
-- Basic ranking three ways
SELECT
OrderID,
ProductID,
Sales,
ROW_NUMBER() OVER (ORDER BY Sales DESC) AS SalesRank_Row,
RANK() OVER (ORDER BY Sales DESC) AS SalesRank_Rank,
DENSE_RANK() OVER (ORDER BY Sales DESC) AS SalesRank_Dense
FROM Sales.Orders;
-- Top-1 sale per product (Top-N per group)
SELECT *
FROM (
SELECT
OrderID,
ProductID,
Sales,
ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY Sales DESC) AS RankByProduct
FROM Sales.Orders
) AS TopProductSales
WHERE RankByProduct = 1;
-- Buckets with NTILE
SELECT
OrderID,
Sales,
NTILE(3) OVER (ORDER BY Sales DESC) AS Buckets
FROM Sales.Orders;
(from 16_Window_Ranking.sql and ranking diagrams on pages 75–85).
4.7 Value function patterns (LEAD/LAG/FIRST/LAST)
-- Month-over-month change using LAG
SELECT
* ,
CurrentMonthSales - PreviousMonthSales AS MoM_Change,
ROUND(
CAST((CurrentMonthSales - PreviousMonthSales) AS FLOAT)
/ PreviousMonthSales * 100, 1
) AS MoM_Perc
FROM (
SELECT
MONTH(OrderDate) AS OrderMonth,
SUM(Sales) AS CurrentMonthSales,
LAG(SUM(Sales)) OVER (ORDER BY MONTH(OrderDate)) AS PreviousMonthSales
FROM Sales.Orders
GROUP BY MONTH(OrderDate)
) AS MonthlySales;
-- FIRST_VALUE / LAST_VALUE per product
SELECT
OrderID,
ProductID,
Sales,
FIRST_VALUE(Sales) OVER (PARTITION BY ProductID ORDER BY Sales) AS LowestSales,
LAST_VALUE(Sales) OVER (
PARTITION BY ProductID
ORDER BY Sales
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
) AS HighestSales,
Sales - FIRST_VALUE(Sales) OVER (PARTITION BY ProductID ORDER BY Sales) AS SalesDifference
FROM Sales.Orders;
(from 17_Window_Value_Functions.sql and the value-function diagrams on pages 99–104, 106–110).
5. Worked Examples with Explanations
5.1 Aggregates vs GROUP BY
Example 1 – basic aggregates
SELECT COUNT(*) AS total_customers
FROM customers;
- What it does: returns one row with the total count of customers.
- Pattern: “Apply aggregate to whole table → 1-row summary”.
Example 2 – grouped aggregates
SELECT
customer_id,
COUNT(*) AS total_orders,
SUM(sales) AS total_sales
FROM orders
GROUP BY customer_id;
- Each
customer_idbecomes one row. - Aggregates are computed within each group.
- This matches the “squeeze many rows per customer into 1 summary row” diagram on page 6.
5.2 GROUP BY vs Window SUM
From Window Basics Tasks 1–4:
-- Task 1: plain total
SELECT SUM(Sales) AS Total_Sales
FROM Sales.Orders;
-- Task 2: total per product using GROUP BY
SELECT
ProductID,
SUM(Sales) AS Total_Sales
FROM Sales.Orders
GROUP BY ProductID;
-- Task 3: total across all orders as a window (detail kept)
SELECT
OrderID,
OrderDate,
ProductID,
Sales,
SUM(Sales) OVER () AS Total_Sales
FROM Sales.Orders;
-- Task 4: per-product totals as a window
SELECT
OrderID,
OrderDate,
ProductID,
Sales,
SUM(Sales) OVER () AS Total_Sales,
SUM(Sales) OVER (PARTITION BY ProductID) AS Sales_By_Product
FROM Sales.Orders;
Key idea:
-
GROUP BYversion returns one row per product (no order details). -
Window version returns one row per order, plus columns showing:
- total over all orders
- total per product
This exactly matches the “Window Functions keep details” diagram on page 7.
5.3 Multi-level partitions
From Task 5:
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER () AS Total_Sales,
SUM(Sales) OVER (PARTITION BY ProductID) AS Sales_By_Product,
SUM(Sales) OVER (PARTITION BY ProductID, OrderStatus) AS Sales_By_Product_Status
FROM Sales.Orders;
SUM(Sales) OVER ()– full dataset total.PARTITION BY ProductID– total per product.PARTITION BY ProductID, OrderStatus– total per product + status pair. This corresponds to the slide that shows combined columns in PARTITION BY.
5.4 Frames: running & rolling totals
Compare with the running/rolling Total diagrams on pages 59–64.
Running total example:
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS Total_Sales
FROM Sales.Orders;
- For each order status, sums from first order in time up to current order.
Rolling total example:
SELECT
OrderID,
ProductID,
OrderDate,
Sales,
AVG(Sales) OVER (
PARTITION BY ProductID
ORDER BY OrderDate
ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING
) AS RollingAvg
FROM Sales.Orders;
- For each product, takes the average of current order + next order only (2-row moving window).
5.5 Window aggregates in analysis use cases
-
Counting & duplicate detection
SELECT * FROM ( SELECT *, COUNT(*) OVER(PARTITION BY OrderID) AS CheckDuplicates FROM Sales.OrdersArchive ) t WHERE CheckDuplicates > 1;COUNT(*) OVER(PARTITION BY OrderID)counts how many rows share the sameOrderID.- Filtering for
> 1returns only duplicated orders.
-
Percent of total
SELECT OrderID, ProductID, Sales, SUM(Sales) OVER () AS TotalSales, ROUND(CAST(Sales AS FLOAT) / SUM(Sales) OVER () * 100, 2) AS PercentageOfTotal FROM Sales.Orders;- Each row knows its own share of the global total.
-
Deviation from min / max
SELECT OrderID, OrderDate, ProductID, Sales, MAX(Sales) OVER () AS HighestSales, MIN(Sales) OVER () AS LowestSales, Sales - MIN(Sales) OVER () AS DeviationFromMin, MAX(Sales) OVER () - Sales AS DeviationFromMax FROM Sales.Orders;- Lets you compare each row to global extremes quickly.
5.6 Ranking examples
From 16_Window_Ranking.sql and ranking slides.
-
Top sale per product (Top-N per group)
SELECT * FROM ( SELECT OrderID, ProductID, Sales, ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY Sales DESC) AS RankByProduct FROM Sales.Orders ) AS TopProductSales WHERE RankByProduct = 1; -
Bottom 2 customers by total sales
SELECT * FROM ( SELECT CustomerID, SUM(Sales) AS TotalSales, ROW_NUMBER() OVER (ORDER BY SUM(Sales)) AS RankCustomers FROM Sales.Orders GROUP BY CustomerID ) AS BottomCustomerSales WHERE RankCustomers <= 2; -
Bucketization with NTILE
SELECT OrderID, Sales, Buckets, CASE WHEN Buckets = 1 THEN 'High' WHEN Buckets = 2 THEN 'Medium' WHEN Buckets = 3 THEN 'Low' END AS SalesSegmentations FROM ( SELECT OrderID, Sales, NTILE(3) OVER (ORDER BY Sales DESC) AS Buckets FROM Sales.Orders ) AS SalesBuckets;- Similar to NTILE bucket diagrams on pages 83–85: rows are split into roughly equal groups; higher buckets get bigger groups first.
-
CUME_DIST for top 40% of prices
SELECT Product, Price, DistRank, CONCAT(DistRank * 100, '%') AS DistRankPerc FROM ( SELECT Product, Price, CUME_DIST() OVER (ORDER BY Price DESC) AS DistRank FROM Sales.Products ) AS PriceDistribution WHERE DistRank <= 0.4;CUME_DIST= (# rows with value ≥ current when ordered DESC) / total rows, as in formulas on pages 89–91.
5.7 Value function examples
From 17_Window_Value_Functions.sql and LAG/LEAD diagrams on pages 102–110.
-
Month-over-month sales change
SELECT *, CurrentMonthSales - PreviousMonthSales AS MoM_Change, ROUND( CAST((CurrentMonthSales - PreviousMonthSales) AS FLOAT) / PreviousMonthSales * 100, 1 ) AS MoM_Perc FROM ( SELECT MONTH(OrderDate) AS OrderMonth, SUM(Sales) AS CurrentMonthSales, LAG(SUM(Sales)) OVER (ORDER BY MONTH(OrderDate)) AS PreviousMonthSales FROM Sales.Orders GROUP BY MONTH(OrderDate) ) AS MonthlySales;LAGpeeks at the previous month’s aggregated sales.
-
Customer loyalty – avg days between orders
SELECT CustomerID, AVG(DaysUntilNextOrder) AS AvgDays, RANK() OVER (ORDER BY COALESCE(AVG(DaysUntilNextOrder), 999999)) AS RankAvg FROM ( SELECT OrderID, CustomerID, OrderDate AS CurrentOrder, LEAD(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS NextOrder, DATEDIFF( day, OrderDate, LEAD(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate) ) AS DaysUntilNextOrder FROM Sales.Orders ) AS CustomerOrdersWithNext GROUP BY CustomerID;- Shorter average gaps → more frequent, “loyal” customers.
-
First & last value per product
Already shown in syntax section; it matches the “timeline” diagrams where FIRST_VALUE picks the first sales value and LAST_VALUE the last one (with an explicit frame).
6. Common Mistakes & Tips
-
Using window functions in
WHEREorGROUP BY- Rule slide: “Window functions can only be used in SELECT or ORDER BY clauses.”
- The script shows an invalid example:
WHERE SUM(Sales) OVER (...) > 100. Instead, wrap the query and filter in an outer SELECT.
-
Nesting window functions
- Slides explicitly say: “Not allowed to nest window functions!” and show
AVG( SUM(Sales) OVER() ) OVER(...)as invalid.
- Slides explicitly say: “Not allowed to nest window functions!” and show
-
Confusing
COUNT(*)vsCOUNT(col)COUNT(*)/COUNT(1)counts rows including NULLs.COUNT(col)ignores rows wherecolis NULL (example table withGloves NULLonly counted byCOUNT(*)in the slides).
-
Forgetting that AVG ignores NULLs
- Slides and script show using
COALESCE(Score,0)to include NULLs in average.
- Slides and script show using
-
Misunderstanding frames
- Frame requires
ORDER BY. - “Lower” boundary must not be after the “higher” boundary.
- Default frame for ordered windows is effectively
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(running from start to current).
- Frame requires
-
Assuming all window functions allow frames
-
Slides show a matrix:
- Aggregate and value functions: frame optional, and especially recommended for
LAST_VALUE. - Ranking functions: frame not allowed; they always consider the entire partition.
- Aggregate and value functions: frame optional, and especially recommended for
-
-
Expecting ROW_NUMBER to handle ties “nicely”
ROW_NUMBERalways gives unique numbers → ties are broken arbitrarily by the ORDER BY.- Use
RANK(with gaps) orDENSE_RANK(no gaps) when ties should share the same rank, as highlighted in the ranking diagrams.
-
Forgetting default offset / default value in LEAD/LAG
- Offset default is 1.
- Default value is NULL, but you can override it (slides show
LEAD(Sales, 2, 10)).
7. Memory Boosters (Mnemonics & Hooks)
-
“C-S-A-M-M” for aggregates
- Count, Sum, Average, Min, Max.
- Imagine squeezing an orange: you Count oranges, Sum the juice, find Average juice per orange, Min/Max juiciest and driest.
-
“P-O-F inside OVER” – Window clause order
- PARTITION BY → groups.
- ORDER BY → sequence.
- FRAME → slice of that sequence.
-
“RowNumber, Rank, DenseRank – 1, gap, no-gap”
ROW_NUMBER→ 1-2-3-4… always unique.RANK→ 1-2-2-4 (gap after tie).DENSE_RANK→ 1-2-2-3 (no gap).
-
Running vs Rolling totals: “Snowball vs Window”
- Running total = snowball rolling downhill, keeps growing (from first row to current).
- Rolling total = fixed window sliding along (e.g., last 3 months).
-
Value functions = “Time machine”
LAG= travel to previous row.LEAD= jump to future row.FIRST_VALUE= go to the start;LAST_VALUE= go to the end of the partition.
8. Active-Recall Questions & Mini-Exercises
Use these as flashcards. Try to answer without looking back.
8.1 Aggregates & GROUP BY
- What is the difference between
COUNT(*)andCOUNT(Sales)when someSalesvalues are NULL? - Write a query to show total sales per customer using
GROUP BY. - Why does
GROUP BYusually produce fewer rows than the original table?
8.2 Window basics & PARTITION / ORDER / FRAME
- Rewrite “total sales per product” using a window function instead of
GROUP BYso that each order row still appears. - What does
PARTITION BY ProductID, OrderStatusdo compared toPARTITION BY ProductIDalone? - What is the effect of
ORDER BYinsideOVERwhen computing a running total?
8.3 Window aggregates
- How would you compute a moving average of
Salesper product, ordered byOrderDate? - How can you use window functions to detect duplicate
OrderIDvalues? - How could you calculate, for each order, the percentage of total sales?
8.4 Ranking functions
- When should you choose
ROW_NUMBERvsRANKvsDENSE_RANK? Give a scenario for each. - How would you find the top 1 order per product using
ROW_NUMBER? - Explain what
NTILE(4) OVER (ORDER BY Sales DESC)does. - In the CUME_DIST price example, what does a value of
0.4mean?
8.5 Value functions
- How can
LAGbe used to calculate month-over-month sales change? - How would you compute the average days between orders per customer using
LEAD? - Why does
LAST_VALUEoften require an explicit frame (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)?
9. Ultra-Short Recap (Key Takeaways)
- Aggregate functions (
COUNT,SUM,AVG,MIN,MAX) summarize multiple rows into one value. GROUP BYcollapses rows into summary rows; window functions keep all rows and just add extra columns.- A window function is
func(expr) OVER (PARTITION BY ... ORDER BY ... frame); remember P-O-F. - Use window aggregates for running totals, rolling averages, percent of total, min/max comparisons, and duplicate checks.
- Ranking functions (
ROW_NUMBER,RANK,DENSE_RANK,NTILE,CUME_DIST,PERCENT_RANK) let you do top-N, segmentation, and distribution analysis. - Value functions (
LAG,LEAD,FIRST_VALUE,LAST_VALUE) are perfect for time-based and before/after comparisons. - Window functions cannot be used in
WHEREand cannot be nested. Put them in a subquery if you need to filter by them. COUNT(*)vsCOUNT(col)and default AVG behaviour with NULLs are classic gotchas—always check how NULLs are treated.
SQL Aggregate Functions
Table of Contents
PDF Slides
1. Basic Aggregate Functions
1.1 COUNT – Total number of customers
Task:
Write a query to find the total number of customers in the customers table.
💡 Suggested Answers
SELECT COUNT(*) AS total_customers
FROM customers
1.2 SUM – Total sales of all orders
Task:
Write a query to find the total sales amount across all orders in the orders table.
💡 Suggested Answers
SELECT SUM(sales) AS total_sales
FROM orders
1.3 AVG – Average sales of all orders
Task:
Write a query to calculate the average sales value of all rows in the orders table.
💡 Suggested Answers
SELECT AVG(sales) AS avg_sales
FROM orders
1.4 MAX – Highest customer score
Task:
Write a query to find the highest score among all customers in the customers table.
💡 Suggested Answers
SELECT MAX(score) AS max_score
FROM customers
1.5 MIN – Lowest customer score
Task:
Write a query to find the lowest score among all customers in the customers table.
💡 Suggested Answers
SELECT MIN(score) AS min_score
FROM customers
2. Grouped Aggregations – GROUP BY
2.1 Per-customer order and sales summary
Task: For each customer, show:
- the number of orders
- the total sales
- the average sales
- the highest sales
- the lowest sales
Use the orders table and group the results by customer_id.
💡 Suggested Answers
SELECT
customer_id,
COUNT(*) AS total_orders,
SUM(sales) AS total_sales,
AVG(sales) AS avg_sales,
MAX(sales) AS highest_sales,
MIN(sales) AS lowest_sales
FROM orders
GROUP BY customer_id
SQL Window Functions – Basics
Table of Contents
1. SQL Window Basics
1.1 Calculate the Total Sales Across All Orders
Task:
Write a query to calculate the total sales across all orders in Sales.Orders.
💡 Suggested Answers
SELECT
SUM(Sales) AS Total_Sales
FROM Sales.Orders;
1.2 Calculate the Total Sales for Each Product
Task:
Write a query to calculate the total sales for each product in Sales.Orders.
💡 Suggested Answers
SELECT
ProductID,
SUM(Sales) AS Total_Sales
FROM Sales.Orders
GROUP BY ProductID;
2. SQL Window OVER Clause
2.1 Total Sales Across All Orders with Details
Task:
Find the total sales across all orders, but still show each order row with OrderID, OrderDate, ProductID, and Sales.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
Sales,
SUM(Sales) OVER () AS Total_Sales
FROM Sales.Orders;
3. SQL Window PARTITION Clause
3.1 Total Sales Overall and Per Product with Details
Task:
Find the total sales across all orders and the total sales for each product, while still showing details like OrderID, OrderDate, ProductID, and Sales.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
Sales,
SUM(Sales) OVER () AS Total_Sales,
SUM(Sales) OVER (PARTITION BY ProductID) AS Sales_By_Product
FROM Sales.Orders;
3.2 Total Sales Overall, Per Product, and Per Product+Status
Task: Find:
- total sales across all orders
- total sales for each product
- total sales for each combination of product and order status
while still showing OrderID, OrderDate, ProductID, OrderStatus, and Sales.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER () AS Total_Sales,
SUM(Sales) OVER (PARTITION BY ProductID) AS Sales_By_Product,
SUM(Sales) OVER (PARTITION BY ProductID, OrderStatus) AS Sales_By_Product_Status
FROM Sales.Orders;
4. SQL Window ORDER Clause
4.1 Rank Each Order by Sales
Task:
Rank each order by Sales from highest to lowest, using a window ranking function.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
Sales,
RANK() OVER (ORDER BY Sales DESC) AS Rank_Sales
FROM Sales.Orders;
5. SQL Window FRAME Clause
5.1 Total Sales for Current and Next Two Orders
Task:
For each order, calculate total sales by Order Status for the current order and the next two orders (based on OrderDate).
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS BETWEEN CURRENT ROW AND 2 FOLLOWING
) AS Total_Sales
FROM Sales.Orders;
5.2 Total Sales for Current and Previous Two Orders
Task:
For each order, calculate total sales by Order Status for the current order and the previous two orders (based on OrderDate).
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS Total_Sales
FROM Sales.Orders;
5.3 Total Sales from Previous Two Orders Only
Task: For each order, calculate total sales by Order Status from the previous two orders only (excluding the current row).
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS 2 PRECEDING
) AS Total_Sales
FROM Sales.Orders;
5.4 Cumulative Total Sales up to Current Order
Task: For each order, calculate the cumulative total sales by Order Status from the first order in that status up to the current order.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS Total_Sales
FROM Sales.Orders;
5.5 Cumulative Total Sales from Start to Current Row
Task: For each order, calculate the cumulative total sales by Order Status from the start of that status partition to the current row, using the shorter frame syntax.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER (
PARTITION BY OrderStatus
ORDER BY OrderDate
ROWS UNBOUNDED PRECEDING
) AS Total_Sales
FROM Sales.Orders;
6. SQL Window Rules
6.1 Rule 1 – Window Functions Only in SELECT or ORDER BY
Task:
The following query attempts to use a window function in the WHERE clause.
Write the query exactly as in the script to show this invalid usage.
💡 Suggested Answers
/* RULE 1:
Window functions can only be used in SELECT or ORDER BY clauses
*/
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(Sales) OVER (PARTITION BY OrderStatus) AS Total_Sales
FROM Sales.Orders
WHERE SUM(Sales) OVER (PARTITION BY OrderStatus) > 100; -- Invalid: window function in WHERE clause
6.2 Rule 2 – No Nesting of Window Functions
Task: The following query nests window functions, which is not allowed. Write the same query to illustrate this rule.
💡 Suggested Answers
/* RULE 2:
Window functions cannot be nested
*/
SELECT
OrderID,
OrderDate,
ProductID,
OrderStatus,
Sales,
SUM(SUM(Sales) OVER (PARTITION BY OrderStatus)) OVER (PARTITION BY OrderStatus) AS Total_Sales -- Invalid nesting
FROM Sales.Orders;
7. SQL Window with GROUP BY
7.1 Rank Customers by Their Total Sales
Task: For each customer, calculate the total sales and then rank customers by their total sales (from highest to lowest).
💡 Suggested Answers
/* TASK 12:
Rank customers by their total sales
*/
SELECT
CustomerID,
SUM(Sales) AS Total_Sales,
RANK() OVER (ORDER BY SUM(Sales) DESC) AS Rank_Customers
FROM Sales.Orders
GROUP BY CustomerID;
Window Aggregate Functions
Table of Contents
1. COUNT
1.1 Count total orders and orders per customer
Task: Find for each order:
- the total number of orders overall, and
- the total number of orders per customer.
Show OrderID, OrderDate, CustomerID, total orders, and orders by customer.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
CustomerID,
COUNT(*) OVER() AS TotalOrders,
COUNT(*) OVER(PARTITION BY CustomerID) AS OrdersByCustomers
FROM Sales.Orders
1.2 Count customers, scores, and countries
Task:
Using Sales.Customers, calculate:
- total number of customers using
COUNT(*) - total number of customers using
COUNT(1) - total number of (non-NULL) scores
- total number of (non-NULL) countries
Return all customer columns plus these four counts.
💡 Suggested Answers
SELECT
*,
COUNT(*) OVER () AS TotalCustomersStar,
COUNT(1) OVER () AS TotalCustomersOne,
COUNT(Score) OVER() AS TotalScores,
COUNT(Country) OVER() AS TotalCountries
FROM Sales.Customers
1.3 Check for duplicate rows in OrdersArchive
Task:
Check whether the table Sales.OrdersArchive contains any duplicate rows based on OrderID.
Return only those rows that are duplicates.
💡 Suggested Answers
SELECT
*
FROM (
SELECT
*,
COUNT(*) OVER(PARTITION BY OrderID) AS CheckDuplicates
FROM Sales.OrdersArchive
) t
WHERE CheckDuplicates > 1
2. SUM
2.1 Total sales overall and by product
Task: For each order, show:
- total sales across all orders, and
- total sales per product.
Include OrderID, OrderDate, Sales, and ProductID.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
Sales,
ProductID,
SUM(Sales) OVER () AS TotalSales,
SUM(Sales) OVER (PARTITION BY ProductID) AS SalesByProduct
FROM Sales.Orders
2.2 Percentage contribution of each sale to total sales
Task:
For each order, calculate what percentage of the total sales this order’s Sales value represents.
Show OrderID, ProductID, Sales, total sales, and the percentage (rounded to 2 decimals).
💡 Suggested Answers
SELECT
OrderID,
ProductID,
Sales,
SUM(Sales) OVER () AS TotalSales,
ROUND(CAST(Sales AS FLOAT) / SUM(Sales) OVER () * 100, 2) AS PercentageOfTotal
FROM Sales.Orders
3. AVG
3.1 Average sales overall and by product
Task: For each order, compute:
- average sales across all orders, and
- average sales per product.
Show OrderID, OrderDate, Sales, ProductID, and the two averages.
💡 Suggested Answers
SELECT
OrderID,
OrderDate,
Sales,
ProductID,
AVG(Sales) OVER () AS AvgSales,
AVG(Sales) OVER (PARTITION BY ProductID) AS AvgSalesByProduct
FROM Sales.Orders
3.2 Average customer scores with and without NULL handling
Task:
Using Sales.Customers, calculate:
- the average of
Score(default behavior, NULLs excluded), and - the average when NULL scores are treated as 0 using
COALESCE.
Show CustomerID, LastName, Score, the score with NULLs replaced by 0, and both averages.
💡 Suggested Answers
SELECT
CustomerID,
LastName,
Score,
COALESCE(Score, 0) AS CustomerScore,
AVG(Score) OVER () AS AvgScore,
AVG(COALESCE(Score, 0)) OVER () AS AvgScoreWithoutNull
FROM Sales.Customers
3.3 Orders with sales above the overall average
Task:
Find all orders where Sales exceed the average Sales across all orders.
Use a window function in a subquery to compute the average.
💡 Suggested Answers
SELECT
*
FROM (
SELECT
OrderID,
ProductID,
Sales,
AVG(Sales) OVER () AS Avg_Sales
FROM Sales.Orders
) t
WHERE Sales > Avg_Sales
4. MAX / MIN
4.1 Highest and lowest sales across all orders
Task:
Find the minimum and maximum Sales values across all rows in Sales.Orders.
💡 Suggested Answers
SELECT
MIN(Sales) AS MinSales,
MAX(Sales) AS MaxSales
FROM Sales.Orders
4.2 Lowest sales overall and by product
Task: For each order, show:
- the lowest Sales value across all orders, and
- the lowest Sales per Product.
Include OrderID, ProductID, OrderDate, Sales, and the two minimums.
💡 Suggested Answers
SELECT
OrderID,
ProductID,
OrderDate,
Sales,
MIN(Sales) OVER () AS LowestSales,
MIN(Sales) OVER (PARTITION BY ProductID) AS LowestSalesByProduct
FROM Sales.Orders
4.3 Employees with the highest salary
Task:
From Sales.Employees, return only the employees who have the highest salary.
Use a window function to find the maximum salary.
__
💡 Suggested Answers
SELECT *
FROM (
SELECT *,
MAX(Salary) OVER() AS HighestSalary
FROM Sales.Employees
) t
WHERE Salary = HighestSalary
</details>
---
### 4.4 Deviation from minimum and maximum sales
**Task:**
For each order, calculate:
* the **highest Sales** across all orders
* the **lowest Sales** across all orders
* the **difference from the minimum** (`Sales - LowestSales`)
* the **difference from the maximum** (`HighestSales - Sales`)
Show `OrderID`, `OrderDate`, `ProductID`, `Sales`, and these extra columns.
<details>
<summary>💡 Suggested Answers</summary>
```sql
SELECT
OrderID,
OrderDate,
ProductID,
Sales,
MAX(Sales) OVER () AS HighestSales,
MIN(Sales) OVER () AS LowestSales,
Sales - MIN(Sales) OVER () AS DeviationFromMin,
MAX(Sales) OVER () - Sales AS DeviationFromMax
FROM Sales.Orders
5. ROLLING SUM & AVERAGE Use Case
5.1 Moving average of sales per product over time
Task:
For each order, calculate the moving average of Sales per Product over time, ordered by OrderDate.
Also show the simple average per product (ignoring order of dates).
💡 Suggested Answers
SELECT
OrderID,
ProductID,
OrderDate,
Sales,
AVG(Sales) OVER (PARTITION BY ProductID) AS AvgByProduct,
AVG(Sales) OVER (PARTITION BY ProductID ORDER BY OrderDate) AS MovingAvg
FROM Sales.Orders
5.2 Rolling average including current and next order only
Task: For each order, calculate a rolling average of Sales per Product that includes only:
- the current order, and
- the next order (1 following row)
within each product partition, ordered by OrderDate.
💡 Suggested Answers
SELECT
OrderID,
ProductID,
OrderDate,
Sales,
AVG(Sales) OVER (PARTITION BY ProductID ORDER BY OrderDate ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING) AS RollingAvg
FROM Sales.Orders
Window Ranking Functions Examples
Table of Contents
1. ROW_NUMBER, RANK, DENSE_RANK
1.1 Rank orders by sales (highest to lowest)
Task 1:
Rank all orders by Sales from highest to lowest and show three ranking styles:
ROW_NUMBER()– unique rank (no ties)RANK()– ties share the same rank, gaps appearDENSE_RANK()– ties share rank, no gaps
Include OrderID, ProductID, Sales, and the three ranking columns.
💡 Suggested Answers
SELECT
OrderID,
ProductID,
Sales,
ROW_NUMBER() OVER (ORDER BY Sales DESC) AS SalesRank_Row,
RANK() OVER (ORDER BY Sales DESC) AS SalesRank_Rank,
DENSE_RANK() OVER (ORDER BY Sales DESC) AS SalesRank_Dense
FROM Sales.Orders;
1.2 Top-N: highest sale for each product
Task 2 (Top-N use case):
For each ProductID, find the order with the highest Sales (top 1 per product).
Use ROW_NUMBER() partitioned by product and filter to keep only the top row.
💡 Suggested Answers
SELECT *
FROM (
SELECT
OrderID,
ProductID,
Sales,
ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY Sales DESC) AS RankByProduct
FROM Sales.Orders
) AS TopProductSales
WHERE RankByProduct = 1;
1.3 Bottom-N: lowest 2 customers by total sales
Task 3 (Bottom-N use case):
Find the 2 customers with the lowest total Sales across all their orders.
First aggregate sales per customer, then rank them using ROW_NUMBER() and keep the bottom 2.
💡 Suggested Answers
SELECT *
FROM (
SELECT
CustomerID,
SUM(Sales) AS TotalSales,
ROW_NUMBER() OVER (ORDER BY SUM(Sales)) AS RankCustomers
FROM Sales.Orders
GROUP BY CustomerID
) AS BottomCustomerSales
WHERE RankCustomers <= 2;
1.4 Assign unique IDs to OrdersArchive rows
Task 4 (Use case – unique IDs):
Assign a unique sequential ID to each row in Sales.OrdersArchive, ordered by OrderID and OrderDate.
Return this UniqueID plus all original columns.
💡 Suggested Answers
SELECT
ROW_NUMBER() OVER (ORDER BY OrderID, OrderDate) AS UniqueID,
*
FROM Sales.OrdersArchive;
1.5 Remove duplicates from OrdersArchive (keep latest)
Task 5 (Use case – identify duplicates & clean):
In Sales.OrdersArchive, there may be multiple rows per OrderID.
Use ROW_NUMBER() to:
- identify duplicates per
OrderID, ordered so that latestCreationTimeis first - keep only the most recent row per
OrderID(i.e. remove duplicates)
💡 Suggested Answers
SELECT *
FROM (
SELECT
ROW_NUMBER() OVER (PARTITION BY OrderID ORDER BY CreationTime DESC) AS rn,
*
FROM Sales.OrdersArchive
) AS UniqueOrdersArchive
WHERE rn = 1;
2. NTILE
2.1 Divide orders into multiple numeric buckets by sales
Task 6:
Divide orders into different numbers of buckets by Sales using NTILE:
- 1 bucket
- 2 buckets
- 3 buckets
- 4 buckets
- plus 2 buckets per product (
PARTITION BY ProductID)
Show OrderID, Sales, and all bucket columns.
💡 Suggested Answers
SELECT
OrderID,
Sales,
NTILE(1) OVER (ORDER BY Sales) AS OneBucket,
NTILE(2) OVER (ORDER BY Sales) AS TwoBuckets,
NTILE(3) OVER (ORDER BY Sales) AS ThreeBuckets,
NTILE(4) OVER (ORDER BY Sales) AS FourBuckets,
NTILE(2) OVER (PARTITION BY ProductID ORDER BY Sales) AS TwoBucketByProducts
FROM Sales.Orders;
2.2 Segment orders into High / Medium / Low sales groups
Task 7 (Segmentation use case):
Segment orders into 3 sales categories using NTILE(3) over Sales DESC:
- Bucket 1 → High
- Bucket 2 → Medium
- Bucket 3 → Low
Show OrderID, Sales, the bucket number, and the text label.
💡 Suggested Answers
SELECT
OrderID,
Sales,
Buckets,
CASE
WHEN Buckets = 1 THEN 'High'
WHEN Buckets = 2 THEN 'Medium'
WHEN Buckets = 3 THEN 'Low'
END AS SalesSegmentations
FROM (
SELECT
OrderID,
Sales,
NTILE(3) OVER (ORDER BY Sales DESC) AS Buckets
FROM Sales.Orders
) AS SalesBuckets;
2.3 Divide orders into 5 groups for processing
Task 8:
Split all orders into 5 roughly equal groups (buckets) for sequential processing, based on OrderID.
Show the bucket number and all original order columns.
💡 Suggested Answers
SELECT
NTILE(5) OVER (ORDER BY OrderID) AS Buckets,
*
FROM Sales.Orders;
3. CUME_DIST
3.1 Find products in the highest 40% of prices
Task 9 (distribution / top % use case):
Using Sales.Products, find products whose price is within the highest 40% of all prices.
Steps inside the query:
- compute
CUME_DIST()overPrice DESCasDistRank - keep only rows where
DistRank <= 0.4 - also show
DistRankas a percentage string
💡 Suggested Answers
SELECT
Product,
Price,
DistRank,
CONCAT(DistRank * 100, '%') AS DistRankPerc
FROM (
SELECT
Product,
Price,
CUME_DIST() OVER (ORDER BY Price DESC) AS DistRank
FROM Sales.Products
) AS PriceDistribution
WHERE DistRank <= 0.4;
SQL Window Value Functions
Table of Contents
1. LEAD & LAG – Time-based comparisons
1.1 Month-over-Month sales performance
Task 1:
Analyze month-over-month performance by finding the percentage change in sales between the current and previous months based on Sales.Orders.
💡 Suggested Answers
SELECT
*,
CurrentMonthSales - PreviousMonthSales AS MoM_Change,
ROUND(
CAST((CurrentMonthSales - PreviousMonthSales) AS FLOAT)
/ PreviousMonthSales * 100, 1
) AS MoM_Perc
FROM (
SELECT
MONTH(OrderDate) AS OrderMonth,
SUM(Sales) AS CurrentMonthSales,
LAG(SUM(Sales)) OVER (ORDER BY MONTH(OrderDate)) AS PreviousMonthSales
FROM Sales.Orders
GROUP BY MONTH(OrderDate)
) AS MonthlySales;
1.2 Customer loyalty – average days between orders
Task 2:
Perform a customer loyalty analysis by ranking customers based on the average number of days between their orders. Use LEAD to look at the next order date per customer and DATEDIFF to compute the gap.
💡 Suggested Answers
SELECT
CustomerID,
AVG(DaysUntilNextOrder) AS AvgDays,
RANK() OVER (ORDER BY COALESCE(AVG(DaysUntilNextOrder), 999999)) AS RankAvg
FROM (
SELECT
OrderID,
CustomerID,
OrderDate AS CurrentOrder,
LEAD(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS NextOrder,
DATEDIFF(
day,
OrderDate,
LEAD(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate)
) AS DaysUntilNextOrder
FROM Sales.Orders
) AS CustomerOrdersWithNext
GROUP BY CustomerID;
2. FIRST_VALUE & LAST_VALUE – Extremes within a partition
2.1 Lowest & highest sales per product and difference from lowest
Task 3:
For each order in Sales.Orders, find:
- the lowest Sales value for that
ProductID - the highest Sales value for that
ProductID - the difference between the current Sales and the lowest Sales for that product
Use FIRST_VALUE and LAST_VALUE as window value functions.
💡 Suggested Answers
SELECT
OrderID,
ProductID,
Sales,
FIRST_VALUE(Sales) OVER (PARTITION BY ProductID ORDER BY Sales) AS LowestSales,
LAST_VALUE(Sales) OVER (
PARTITION BY ProductID
ORDER BY Sales
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
) AS HighestSales,
Sales - FIRST_VALUE(Sales) OVER (PARTITION BY ProductID ORDER BY Sales) AS SalesDifference
FROM Sales.Orders;
Infrastructure Documentation
The goal is simple: help a beginner go from four Ubuntu machines to a working Kubernetes homelab without having to reverse-engineer the repo first.
Who This Documentation Is For
This documentation is written for someone who wants to rebuild, understand, or operate the homelab from the repository itself.
It assumes you are comfortable running shell commands and editing YAML, but it does not assume you already know how this particular homelab is organized.
What You Need Before You Start
Before you follow the build path, make sure you already have:
- SSH access to four of your ubuntu server, e.g
ms-1,wk-1,wk-2, andvm-1 sudoor root privileges on each node- a Cloudflare-managed DNS zone for the public hostnames
- a public cloud VM that acts as the edge node
- the ability to run cluster-admin commands from
ms-1 - a working clone of this repository so the manifests and scripts below are available locally
What The Build Actually Looks Like In Practice
This homelab is built in layers, and each layer changes the platform in a concrete way.
First, you turn four Ubuntu machines into one private network. That means installing WireGuard, generating node keys, creating wg0.conf on each machine, applying the rp_filter sysctl, and making the home router forward UDP 51820-51822 back to the three home nodes.
Second, you turn that private mesh into a Kubernetes foundation. ms-1 becomes the K3s server, wk-1, wk-2, and the edge node join as agents, Calico replaces flannel, and labels plus taints define where edge, database, and GitOps workloads are allowed to run.
Third, you turn the cluster into a safe public platform. Traefik is pinned to the cloud edge node with hostNetwork: true, the edge firewall allows only the ports the platform truly needs, cert-manager uses Cloudflare DNS-01 for TLS, and Argo CD is pinned to wk-2 so Git becomes the deployment path.
Finally, you add the workload layer. PostgreSQL stays internal on wk-1, Keycloak uses that database and follows the same ingress/TLS pattern as the public apps, and the notebook and portfolio apps follow a base-plus-overlays model so production stays GitOps-managed while dev remains an intentional manual path when needed.
The repository contains the manifests and scripts behind those layers, but the runbook is meant to make sense on its own. The docs should tell the story first, and the repo paths should only be implementation detail.
Current Path vs Historical Notes
The primary learning path in this folder is current and repo-backed.
Some of the longer deep-dive documents were kept as historical references because they still explain useful debugging and design context. Those notes may preserve earlier path names or experiments that are no longer the preferred rebuild path.
If you see an older path such as deploy/..., prefer the current k8s-cluster/... tree and the main step-by-step guides in this introduction.
Homelab Platform Overview
This homelab is a small but serious platform: three private home nodes, one public cloud edge node, and a clear rule that only the edge is exposed to the internet.
It is designed to be reproducible, understandable, and worth learning from. You are not just standing up Kubernetes for the sake of it. You are building a compact platform that teaches networking, cluster design, ingress, TLS, GitOps, data services, identity, and clean recovery habits in one place.
What You Are Building
By following the main guides in order, you will build:
- a four-node Ubuntu 24.04 Kubernetes homelab
- a private WireGuard mesh between the home network and the cloud edge node
- a K3s cluster with Calico networking
- a single public ingress path through Traefik
- automatic TLS with cert-manager and Cloudflare DNS validation
- GitOps with Argo CD
- internal data and identity services with PostgreSQL and Keycloak
- production and development application deployment patterns
The end result is a platform where internal services stay private, public traffic enters through one controlled edge node, and the full environment can be rebuilt from documentation and version-controlled manifests.
Why This Homelab Exists
This homelab exists for three practical reasons.
First, it gives you a real environment to learn from. Many home labs stop at “the cluster works.” This one is meant to teach why the cluster works, how the parts depend on each other, and how to rebuild it confidently when something changes.
Second, it creates a safer exposure model than a typical home setup. Instead of publishing random services directly from the home network, the design puts one hardened cloud node in front of the cluster and keeps the rest of the platform private behind WireGuard.
Third, it forces good infrastructure habits early. You practice clear node roles, repeatable installation, controlled ingress, automated TLS, Git-driven deployments, and recovery-oriented thinking without needing a large cloud bill or an enterprise-sized platform team.
Why This Design Is Useful
This design is useful because it balances realism with simplicity.
- It is realistic enough to teach production-style ideas such as isolated ingress, private networking, GitOps, and service boundaries.
- It is small enough that one person can understand the whole system end to end.
- It is opinionated enough that a beginner is not left guessing where services should run or how they should be exposed.
- It is practical enough to host real applications such as a portfolio site, a notebook app, and an identity provider.
Most importantly, it teaches a valuable lesson: good infrastructure is not only about getting software running. It is about making the platform easy to reason about, safe to change, and possible to rebuild.
How To Use These Docs
Read the documents in this order:
-
01. Platform Overview Use this next for the concrete architecture, node layout, reference settings, and design rules.
-
02. Rebuild Cluster Step by Step Build the private network and the Kubernetes cluster itself.
-
06. Platform Services Step by Step Install ingress, the edge firewall guardrail, TLS automation, and Argo CD.
-
12. Data and Apps Step by Step Add PostgreSQL, Keycloak, and the application delivery pattern used by the homelab.
-
16. Operate, Verify, and Recover Use this once the platform is running to check health, troubleshoot issues, and recover cleanly.
Build Journey
You will implement the homelab in four stages:
- Build the private network and Kubernetes cluster.
- Add ingress, edge hardening, TLS, and GitOps.
- Add PostgreSQL, Keycloak, and the application delivery pattern.
- Learn how to verify, operate, and recover the platform safely.
That sequence matters. Each stage depends on the one before it.
What Success Looks Like
At the end of the tutorial:
- all four nodes are connected over WireGuard
- K3s nodes are
Readyand use WireGuard IPs internally - Traefik serves public HTTP and HTTPS only from the edge node
- certificates are issued automatically through cert-manager
- Argo CD manages the production app overlays from Git
- PostgreSQL serves internal workloads only
- Keycloak is reachable through the same ingress and TLS pattern as the apps
- the platform can be checked and rebuilt without guesswork
Important Note About Public IPs
The tutorial documentation uses documentation-safe example public IP ranges:
- home WAN / router public IP:
203.0.113.10 - cloud edge public IP:
198.51.100.25
Private LAN and WireGuard addresses match the actual platform layout because they are part of the cluster design.
Platform Architecture and Reference Layout
This document picks up where the introduction leaves off. It turns the high-level platform story into concrete architecture: node roles, network layout, platform versions, public hosts, and the design rules that guide the rest of the build.
Architecture At A Glance
flowchart TB
classDef internet fill:#fff7ed,stroke:#ea580c,color:#9a3412,stroke-width:1.5px;
classDef node fill:#eff6ff,stroke:#2563eb,color:#1d4ed8,stroke-width:1.5px;
classDef edge fill:#ecfccb,stroke:#65a30d,color:#3f6212,stroke-width:1.5px;
classDef platform fill:#ecfeff,stroke:#0f766e,color:#115e59,stroke-width:1.5px;
classDef workload fill:#fef3c7,stroke:#d97706,color:#92400e,stroke-width:1.5px;
classDef data fill:#fce7f3,stroke:#db2777,color:#9d174d,stroke-width:1.5px;
subgraph Public["Public internet"]
Users["Internet users<br/>Browsers and API clients"]:::internet
DNS["Cloudflare DNS<br/>kakde.eu and subdomains"]:::internet
end
subgraph Edge["Public edge"]
EDGE["ctb-edge-1 / vm-1<br/>Edge worker node<br/>Public 198.51.100.25<br/>WG 172.27.15.31"]:::edge
Guard["Edge guardrail<br/>Allow 22(admin), 80, 443, 51820"]:::edge
Traefik["Traefik<br/>hostNetwork ingress<br/>Ports 80 and 443"]:::platform
end
subgraph Home["Home network"]
Router["Home router<br/>WAN 203.0.113.10<br/>UDP 51820-51822 forwards"]:::internet
subgraph Cluster["K3s cluster over WireGuard"]
MS["ms-1<br/>Control plane<br/>LAN 192.168.15.2<br/>WG 172.27.15.12"]:::node
WK1["wk-1<br/>Worker / PostgreSQL node<br/>LAN 192.168.15.3<br/>WG 172.27.15.11"]:::node
WK2["wk-2<br/>Worker / Argo CD node<br/>LAN 192.168.15.4<br/>WG 172.27.15.13"]:::node
end
end
subgraph Services["Cluster services"]
Calico["Calico VXLAN<br/>Pod networking"]:::platform
Cert["cert-manager<br/>Cloudflare DNS-01"]:::platform
Argo["Argo CD<br/>GitOps reconciliation"]:::platform
Apps["Portfolio and notebook<br/>App overlays"]:::workload
Keycloak["Keycloak<br/>Identity service"]:::workload
Postgres["PostgreSQL<br/>Internal ClusterIP only"]:::data
end
Users --> DNS
DNS --> Guard
Guard --> Traefik
DNS -. DNS-01 challenge .-> Cert
Router -. WireGuard UDP forwards .-> MS
Router -. WireGuard UDP forwards .-> WK1
Router -. WireGuard UDP forwards .-> WK2
EDGE <--> MS
EDGE <--> WK1
EDGE <--> WK2
EDGE --- Guard
EDGE --- Traefik
MS --> Calico
WK1 --> Calico
WK2 --> Calico
EDGE --> Calico
Traefik --> Apps
Traefik --> Keycloak
Argo -. syncs production overlays .-> Apps
Keycloak --> Postgres
How To Read This Architecture
ms-1is the control-plane node. It is the main cluster administration machine.wk-1is the database worker. PostgreSQL is intentionally kept away from the public edge.wk-2is the GitOps worker. Argo CD is pinned there to keep its placement predictable.ctb-edge-1is the public edge node. In local SSH usage, this same machine is reached asvm-1.- WireGuard connects every node over a stable private mesh, and K3s uses those WireGuard IPs as node addresses.
- Traefik is the only intended public web entry point.
- cert-manager handles certificate issuance through Cloudflare DNS validation, so you do not need extra public challenge ports.
- PostgreSQL and most cluster services remain private inside Kubernetes.
Current Reference Layout
Nodes
| Node | Role | Location | LAN IP | WireGuard IP | Notes |
|---|---|---|---|---|---|
ms-1 | control plane | home | 192.168.15.2 | 172.27.15.12 | K3s server and main admin host |
wk-1 | worker | home | 192.168.15.3 | 172.27.15.11 | PostgreSQL node |
wk-2 | worker | home | 192.168.15.4 | 172.27.15.13 | Argo CD node |
ctb-edge-1 | edge worker | cloud | n/a | 172.27.15.31 | public ingress node, SSH alias vm-1 |
Network Settings
- home LAN:
192.168.15.0/24 - WireGuard network:
172.27.15.0/24 - WireGuard MTU:
1420 - pod CIDR:
10.42.0.0/16 - service CIDR:
10.43.0.0/16
Platform Versions
- K3s:
v1.35.1+k3s1 - Calico:
v3.31.4 - Traefik:
v2.11 - cert-manager:
v1.19.1 - Argo CD:
v3.3.3 - PostgreSQL:
17.9 - Keycloak:
26.5.5
Public Hosts
kakde.eudev.kakde.eunotebook.kakde.euargocd.kakde.eukeycloak.kakde.euwhoami.kakde.euwhoami-auth.kakde.eu
Core Design Rules
1. Private network first
WireGuard comes first. If the mesh is not healthy, do not install or troubleshoot Kubernetes yet.
2. One public entry point
Only the edge node should receive public web traffic. Home nodes should not become accidental ingress nodes.
3. Keep K3s minimal
K3s is installed with flannel, built-in network policy, built-in Traefik, and ServiceLB disabled. That keeps networking and ingress under explicit control.
4. Give important services clear homes
PostgreSQL runs on wk-1. Argo CD runs on wk-2. Traefik runs on the edge node. This makes placement easier to explain, observe, and recover.
5. Keep internal services internal
PostgreSQL is reachable through internal Kubernetes networking only. It is not published to the internet.
6. Prefer repeatable steps over clever fixes
If something drifts, the right answer is to improve the documented rebuild path, not to rely on undocumented one-off commands.
Next Step
Continue with 02. Rebuild Cluster Step by Step. That guide takes you from clean machines to a healthy base cluster.
Rebuild Cluster Step by Step
This guide takes you from clean machines to a healthy base cluster.
By the end of this phase, you will have:
- a working four-node WireGuard mesh
- a K3s control plane on
ms-1 - three joined worker nodes
- Calico networking
- the placement labels and taints that the rest of the platform depends on
This is the foundation for everything that comes later. Do not continue to ingress, TLS, Argo CD, PostgreSQL, or Keycloak until this phase is healthy.
Before You Start
Make sure these assumptions are true:
- you can SSH to
ms-1,wk-1,wk-2, andvm-1 - all four machines run Ubuntu 24.04
- you have
sudoor root access on every node - your home router can forward UDP ports for WireGuard
vm-1is the SSH name of the public cloud node that will appear in Kubernetes asctb-edge-1
Use these reference addresses throughout the build:
| Node | Purpose | LAN IP | WireGuard IP |
|---|---|---|---|
ms-1 | K3s server | 192.168.15.2 | 172.27.15.12 |
wk-1 | worker | 192.168.15.3 | 172.27.15.11 |
wk-2 | worker | 192.168.15.4 | 172.27.15.13 |
vm-1 / ctb-edge-1 | public edge worker | n/a | 172.27.15.31 |
Step 0: Confirm The Machines Are Safe To Use
On each node, run:
hostname -f
uname -a
ip -br addr
You are checking three things:
- you are on the machine you think you are on
- the network interfaces look normal
- SSH connectivity is stable before you begin making changes
If you are rebuilding on reused machines and suspect old Kubernetes, CNI, WireGuard, or firewall leftovers, stop here and use the destructive cleanup guide in 03-safety-checks-and-cleaning.md.
Step 1: Build The WireGuard Mesh
WireGuard is the first real dependency of the cluster. K3s will use the WireGuard IPs as node IPs, so do not move forward until this network is working cleanly.
1. Install WireGuard on every node
Run on ms-1, wk-1, wk-2, and vm-1:
sudo apt-get update
sudo apt-get install -y wireguard wireguard-tools
2. Generate a key pair on every node
Run on each node:
sudo install -d -m 700 /etc/wireguard
sudo sh -c 'umask 077 && wg genkey | tee /etc/wireguard/wg0.key | wg pubkey > /etc/wireguard/wg0.pub'
sudo cat /etc/wireguard/wg0.pub
Collect the four public keys before continuing. You will paste them into the matching peer entries below.
3. Configure the home router
The home router must forward these UDP ports from the home public IP to the home nodes:
203.0.113.10:51820/udp -> wk-1:51820/udp203.0.113.10:51821/udp -> ms-1:51820/udp203.0.113.10:51822/udp -> wk-2:51820/udp
The cloud edge node connects back into the home network through those forwarded ports.
4. Apply the WireGuard sysctl setting
On each node, create the sysctl file:
cat <<'EOF' | sudo tee /etc/sysctl.d/99-wireguard.conf >/dev/null
net.ipv4.conf.all.rp_filter=2
net.ipv4.conf.default.rp_filter=2
EOF
sudo sysctl --system
5. Create wg0.conf on each node
Replace every placeholder with the real private key or peer public key you generated.
For the PrivateKey field, paste the actual contents of /etc/wireguard/wg0.key on that node.
On ms-1:
[Interface]
Address = 172.27.15.12/32
ListenPort = 51820
PrivateKey = <MS_1_PRIVATE_KEY>
MTU = 1420
SaveConfig = false
[Peer]
# vm-1 / ctb-edge-1
PublicKey = <VM_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.31/32
Endpoint = 198.51.100.25:51820
PersistentKeepalive = 25
[Peer]
# wk-1
PublicKey = <WK_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.11/32
Endpoint = 192.168.15.3:51820
[Peer]
# wk-2
PublicKey = <WK_2_PUBLIC_KEY>
AllowedIPs = 172.27.15.13/32
Endpoint = 192.168.15.4:51820
On wk-1:
[Interface]
Address = 172.27.15.11/32
ListenPort = 51820
PrivateKey = <WK_1_PRIVATE_KEY>
MTU = 1420
SaveConfig = false
[Peer]
# vm-1 / ctb-edge-1
PublicKey = <VM_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.31/32
Endpoint = 198.51.100.25:51820
PersistentKeepalive = 25
[Peer]
# ms-1
PublicKey = <MS_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.12/32
Endpoint = 192.168.15.2:51820
[Peer]
# wk-2
PublicKey = <WK_2_PUBLIC_KEY>
AllowedIPs = 172.27.15.13/32
Endpoint = 192.168.15.4:51820
On wk-2:
[Interface]
Address = 172.27.15.13/32
ListenPort = 51820
PrivateKey = <WK_2_PRIVATE_KEY>
MTU = 1420
SaveConfig = false
[Peer]
# vm-1 / ctb-edge-1
PublicKey = <VM_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.31/32
Endpoint = 198.51.100.25:51820
PersistentKeepalive = 25
[Peer]
# ms-1
PublicKey = <MS_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.12/32
Endpoint = 192.168.15.2:51820
[Peer]
# wk-1
PublicKey = <WK_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.11/32
Endpoint = 192.168.15.3:51820
On vm-1:
[Interface]
Address = 172.27.15.31/32
ListenPort = 51820
PrivateKey = <VM_1_PRIVATE_KEY>
MTU = 1420
SaveConfig = false
[Peer]
# wk-1 via home router forward 51820
PublicKey = <WK_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.11/32
Endpoint = 203.0.113.10:51820
[Peer]
# ms-1 via home router forward 51821
PublicKey = <MS_1_PUBLIC_KEY>
AllowedIPs = 172.27.15.12/32
Endpoint = 203.0.113.10:51821
[Peer]
# wk-2 via home router forward 51822
PublicKey = <WK_2_PUBLIC_KEY>
AllowedIPs = 172.27.15.13/32
Endpoint = 203.0.113.10:51822
Optional: add an admin peer. If you want to access the cluster from a laptop or workstation over WireGuard (for example, to run kubectl remotely), you can add an extra [Peer] block to each node’s config. The full templates in k8s-cluster/bootstrap/wireguard/ include an example admin peer at 172.27.15.50/32 with PostUp/PostDown route commands. This is not required for the cluster to function, but it is useful for remote administration without SSH jump hosts.
Save each of those as /etc/wireguard/wg0.conf, then lock down the permissions:
sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/wg0.key
6. Enable WireGuard
Run on each node:
sudo systemctl enable --now wg-quick@wg0
sudo systemctl status wg-quick@wg0 --no-pager
7. Verify the mesh before you continue
Run on every node:
wg show
ping -c 3 172.27.15.12
ping -c 3 172.27.15.11
ping -c 3 172.27.15.13
ping -c 3 172.27.15.31
Good looks like:
- every node shows peer handshakes in
wg show - every node can ping the other three WireGuard IPs
- no node is falling back to public-IP-based cluster communication
If the mesh is not healthy, fix WireGuard now. Kubernetes will be unreliable if you continue with a half-working private network.
Step 2: Prepare the K3s DNS Resolver File
K3s will use a dedicated resolver path. Run this on every node:
for host in ms-1 wk-1 wk-2 vm-1; do
ssh "$host" 'bash -s' < k8s-cluster/bootstrap/k3s/create-k3s-resolv-conf.sh
done
Verify on one or two nodes:
ssh ms-1 'readlink -f /etc/rancher/k3s/k3s-resolv.conf'
ssh wk-1 'grep -E "^(nameserver|search|options)" /etc/rancher/k3s/k3s-resolv.conf'
Expected result:
/etc/rancher/k3s/k3s-resolv.confpoints to/run/systemd/resolve/resolv.conf
Step 3: Install the K3s Server on ms-1
The repository includes a helper script so you do not have to retype the full install flags. It installs K3s with:
- version
v1.35.1+k3s1 172.27.15.12as node IP and advertise address- flannel disabled
- built-in network policy disabled
- built-in Traefik disabled
- ServiceLB disabled
- pod CIDR
10.42.0.0/16 - service CIDR
10.43.0.0/16
Run:
ssh ms-1 'bash -s' < k8s-cluster/bootstrap/k3s/install-server-ms-1.sh
Verify:
ssh ms-1 'sudo kubectl get nodes -o wide'
ssh ms-1 'sudo systemctl status k3s --no-pager'
At this moment, only ms-1 should appear. It may still show NotReady because the CNI is not installed yet.
Step 4: Install Calico on ms-1
Calico replaces flannel in this design and provides the pod network.
Run:
ssh ms-1 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; bash -s' < k8s-cluster/bootstrap/k3s/install-calico.sh
This install path is intentionally safe to rerun. It uses server-side apply, waits for the required CRDs, waits for the Tigera operator, and only then applies the Calico custom resources.
Verify:
ssh ms-1 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; kubectl get pods -n tigera-operator'
ssh ms-1 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; kubectl get pods -n calico-system'
Step 5: Join the Three Agents
First, read the cluster join token from ms-1:
K3S_TOKEN="$(ssh ms-1 'sudo cat /var/lib/rancher/k3s/server/node-token')"
echo "$K3S_TOKEN"
Then join wk-1:
ssh wk-1 "export K3S_TOKEN='$K3S_TOKEN'; bash -s" < k8s-cluster/bootstrap/k3s/install-agent-wk-1.sh
Join wk-2:
ssh wk-2 "export K3S_TOKEN='$K3S_TOKEN'; bash -s" < k8s-cluster/bootstrap/k3s/install-agent-wk-2.sh
Join the public edge node:
ssh vm-1 "export K3S_TOKEN='$K3S_TOKEN'; bash -s" < k8s-cluster/bootstrap/k3s/install-agent-vm-1.sh
Verify from ms-1:
ssh ms-1 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; kubectl get nodes -o wide'
Expected internal IPs:
ms-1 -> 172.27.15.12wk-1 -> 172.27.15.11wk-2 -> 172.27.15.13ctb-edge-1 -> 172.27.15.31
If a node registers with the wrong IP, stop and fix that before moving on. The cluster should use WireGuard IPs internally.
Step 6: Apply Node Labels And Taints
The rest of the platform depends on predictable placement. Apply the baseline labels and taints now:
ssh ms-1 "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; EDGE_NODE=ctb-edge-1 bash -s" < k8s-cluster/bootstrap/k3s/apply-node-placement.sh
This sets the platform up like this:
ms-1: control-plane taint and server role labelwk-1: worker role labelwk-2: worker role labelctb-edge-1: edge role label pluskakde.eu/edge=true:NoSchedule
Verify:
ssh ms-1 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; kubectl get nodes --show-labels'
ssh ms-1 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; kubectl describe node ctb-edge-1 | rg -n "Taints|Labels" -A6'
Step 7: Run a Base Cluster Health Check
Run on ms-1:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o wide
kubectl get pods -n tigera-operator
kubectl get pods -n calico-system
kubectl get ns
Your base cluster is ready when:
- all four nodes are
Ready - Calico pods are healthy
- the Tigera operator is healthy
- node internal IPs match the WireGuard addresses
- the edge node is labeled and tainted correctly
What You Have Now
At this point you have a private Kubernetes foundation that the rest of the homelab can trust.
You do not have public ingress yet. You do not have TLS yet. You do not have GitOps yet. You do not have PostgreSQL or Keycloak yet.
That is exactly right. Those layers come next.
Next Step
Continue with 06. Platform Services Step by Step.
Safety Checks and Cleaning
Use this document only when you are reusing machines or you strongly suspect old K3s, CNI, WireGuard, or firewall state is still present.
Safety checks before touching anything
Purpose
Confirm the environment is safe to work on.
Run on each node
hostname -f
uname -a
ip -br addr
What success looks like
- You can log in to all four hosts.
- Each host shows the expected name.
- The network interfaces look normal.
Do not continue if
- one of the nodes is unreachable
- you are not sure which machine is which
- SSH is already flaky
Clean the old environment
Purpose
Remove old K3s, CNI, WireGuard, firewall, and routing leftovers so the rebuild starts clean.
Warning
This phase is destructive. It removes cluster state, overlay state, and old firewall rules.
Recommended node order
ctb-edge-1ms-1wk-1wk-2
1. Create a backup snapshot on each node
sudo -i
NODE="REPLACE_ME" # ctb-edge-1 OR ms-1 OR wk-1 OR wk-2
TS="$(date -u +%Y%m%dT%H%M%SZ)"
BK="/root/backup-phase1-${NODE}-${TS}"
mkdir -p "$BK"
ip -br addr > "$BK/ip-addr.txt"
ip route show table all > "$BK/ip-route-table-all.txt"
ip rule show > "$BK/ip-rule.txt"
ip -6 rule show > "$BK/ip6-rule.txt" 2>/dev/null || true
(command -v wg >/dev/null && wg show || true) > "$BK/wg-show.txt" 2>&1
sysctl net.ipv4.ip_forward > "$BK/sysctl-ip_forward.txt" 2>&1 || true
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter > "$BK/sysctl-rp_filter.txt" 2>&1 || true
sysctl net.ipv4.conf.wg0.rp_filter > "$BK/sysctl-wg0-rp_filter.txt" 2>&1 || true
iptables-save > "$BK/iptables-save.txt" 2>&1 || true
iptables -S > "$BK/iptables-S.txt" 2>&1 || true
ip6tables-save > "$BK/ip6tables-save.txt" 2>&1 || true
ip6tables -S > "$BK/ip6tables-S.txt" 2>&1 || true
nft list ruleset > "$BK/nft-ruleset.txt" 2>&1 || true
systemctl list-unit-files | grep -E 'k3s|rke2|kube|containerd|cri|wg-quick|wireguard|nftables|ufw|firewalld|iptables' \
> "$BK/unit-files-grep.txt" 2>&1 || true
tar -czf "$BK/etc-snap.tgz" /etc/wireguard /etc/rancher /etc/cni /etc/sysctl.d /etc/sysctl.conf 2>/dev/null || true
echo "Backup complete: $BK"
ls -lah "$BK"
2. Stop and disable services
sudo -i
systemctl disable --now wg-quick@wg0 2>/dev/null || true
systemctl disable --now k3s 2>/dev/null || true
systemctl disable --now k3s-agent 2>/dev/null || true
systemctl disable --now containerd 2>/dev/null || true
3. Uninstall K3s if present
sudo -i
if [ -x /usr/local/bin/k3s-uninstall.sh ]; then
/usr/local/bin/k3s-uninstall.sh
fi
if [ -x /usr/local/bin/k3s-agent-uninstall.sh ]; then
/usr/local/bin/k3s-agent-uninstall.sh
fi
4. Remove leftover files
sudo -i
rm -rf /etc/rancher/k3s /var/lib/rancher/k3s /var/lib/kubelet /var/lib/cni
rm -rf /etc/cni/net.d /opt/cni/bin
rm -rf /var/lib/calico /var/run/calico /run/calico 2>/dev/null || true
rm -rf /etc/wireguard
rm -f /etc/systemd/system/k3s.service /etc/systemd/system/k3s-agent.service
systemctl daemon-reload
5. Delete leftover interfaces
sudo -i
for IF in wg0 cni0 flannel.1 vxlan.calico kube-ipvs0 tunl0; do
ip link show "$IF" >/dev/null 2>&1 && ip link del "$IF" 2>/dev/null || true
done
for IF in $(ip -o link show | awk -F': ' '{print $2}' | grep -E '^cali' || true); do
ip link del "$IF" 2>/dev/null || true
done
6. Remove extra policy routing rules
Check first:
ip rule show
ip -6 rule show 2>/dev/null || true
Delete extra rules beyond the defaults, for example:
ip rule del pref 1000
7. Flush firewall state safely
sudo -i
systemctl disable --now ufw 2>/dev/null || true
systemctl disable --now firewalld 2>/dev/null || true
systemctl disable --now nftables 2>/dev/null || true
iptables -P INPUT ACCEPT
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
iptables -F
iptables -t nat -F
iptables -t mangle -F
iptables -t raw -F
iptables -X
ip6tables -P INPUT ACCEPT 2>/dev/null || true
ip6tables -P FORWARD ACCEPT 2>/dev/null || true
ip6tables -P OUTPUT ACCEPT 2>/dev/null || true
ip6tables -F 2>/dev/null || true
ip6tables -t nat -F 2>/dev/null || true
ip6tables -t mangle -F 2>/dev/null || true
ip6tables -t raw -F 2>/dev/null || true
ip6tables -X 2>/dev/null || true
command -v nft >/dev/null && nft flush ruleset || true
8. Remove sysctl overrides related to old experiments
sudo -i
find /etc/sysctl.d -maxdepth 1 -type f \
\( -iname '*k3s*' -o -iname '*k8s*' -o -iname '*kube*' -o -iname '*kubernetes*' -o -iname '*calico*' -o -iname '*cni*' -o -iname '*wireguard*' -o -iname '*wg*' \) \
-print
find /etc/sysctl.d -maxdepth 1 -type f \
\( -iname '*k3s*' -o -iname '*k8s*' -o -iname '*kube*' -o -iname '*kubernetes*' -o -iname '*calico*' -o -iname '*cni*' -o -iname '*wireguard*' -o -iname '*wg*' \) \
-exec rm -f {} \;
sysctl --system >/dev/null 2>&1 || true
9. Verify the cleanup
echo "== wg =="
wg show 2>/dev/null || true
echo "== k3s/kubelet/calico/flannel processes =="
ps aux | egrep -i 'k3s|kubelet|calico|flannel|cni' | grep -v egrep || true
echo "== interfaces that should NOT exist =="
ip -br link | egrep 'wg0|cni0|flannel\.1|vxlan\.calico|kube-ipvs0|tunl0|cali' || true
echo "== suspicious routes (pod/service/cni leftovers) =="
ip route show table all | egrep -i '10\.42\.|10\.43\.|cni|flannel|calico|vxlan|wg0' || true
echo "== ip rules (should be defaults only) =="
ip rule show
echo "== firewall (should be near-empty) =="
iptables -S | head -n 60
iptables -t nat -S | head -n 60
nft list ruleset 2>/dev/null | head -n 120 || true
Expected result
You should see:
- no active
wg0 - no K3s, Calico, flannel, or kubelet leftovers
- no pod/service routes from old clusters
- almost empty firewall state
Setup Wireguard Mesh
Table of contents
Jump to Phase 2 implementation if you already understand the concepts.
- Architecture
- Concepts explained from scratch
- Safety rules
- Phase 2 (WireGuard) implementation
- Design decisions and trade-offs
- Glossary
- Further learning
Architecture
Nodes
Home LAN (behind NAT/router)
-
Router LAN:
192.168.15.1/24 -
Public WAN IP:
203.0.113.10 -
Nodes:
ms-1(K3s server):192.168.15.2wk-1:192.168.15.3wk-2:192.168.15.4
Public cloud edge (Contabo)
ctb-edge-1: public IP198.51.100.25- Single public edge for
kakde.eu+ subdomains - In later phases: Traefik binds host ports 80/443 on this node
WireGuard overlay (Phase 2)
Overlay subnet (WireGuard only): 172.27.15.0/24 but each node uses a /32
wk-1=172.27.15.11/32ms-1=172.27.15.12/32wk-2=172.27.15.13/32ctb-edge-1=172.27.15.31/32
Router UDP forwards (required for edge → home)
203.0.113.10:51820 -> wk-1:51820203.0.113.10:51821 -> ms-1:51820203.0.113.10:51822 -> wk-2:51820
Concepts explained from scratch
NAT and port forwarding
Home devices usually sit behind a router using NAT (Network Address Translation). That means the internet can’t directly “reach” home machines unless the router is told to forward traffic.
Here we forward UDP ports from the router’s public IP (203.0.113.10) into each home node’s WireGuard port 51820.
WireGuard and wg-quick
WireGuard is a modern VPN that creates an encrypted network interface (commonly wg0).
wg-quick is a helper that:
- reads
/etc/wireguard/wg0.conf - creates the interface + routes
- sets keys + peers
- brings it up/down via systemd (
wg-quick@wg0)
AllowedIPs and /32 routes
In WireGuard, AllowedIPs is both:
- a routing rule (“to reach this IP, send it to that peer”), and
- an access control rule (“this peer is allowed to claim these source IPs”).
Your rule is strict and safe:
- AllowedIPs = peer /32 only (one IP per peer)
- No broad ranges like
/24 - This prevents accidental routing leaks and keeps debugging predictable.
PersistentKeepalive
When a node is behind NAT, inbound traffic can die when the NAT mapping expires.
PersistentKeepalive = 25 sends a tiny packet every ~25 seconds to keep the path open.
Rule in this build:
- Keepalives only from home nodes toward the edge (because home is behind NAT)
rp_filter and why it breaks “handshake ok but ping fails”
Linux has a “reverse path filtering” setting called rp_filter. If it’s too strict, Linux drops packets that arrive on an interface that it doesn’t think is the “best reverse path”.
WireGuard full-mesh and multi-homing often benefit from:
rp_filter=2(loose mode) This reduces false drops while staying safer than disabling it entirely.
MTU basics
MTU is the maximum packet size an interface sends without fragmentation. VPN encapsulation adds overhead; if MTU is too high, you get weird “works sometimes” issues.
Baseline used here:
MTU = 1420on wg0 everywhere (safe general value for WAN VPN links)
Safety rules
-
Never lock yourself out of SSH. Don’t enable a firewall policy unless you’ve confirmed SSH is allowed.
-
Treat private keys like passwords:
/etc/wireguard/wg0.keymust stay private on that node- share public keys only
-
Make small changes, verify after each step.
Phased workflow
- Phase 1: Wipe old cluster + network artifacts ✅ Reported complete
- Phase 2: WireGuard full mesh ✅ This doc provides exact configs + commands
- Phase 3: K3s + Calico VXLAN ⏭️ Next step after Phase 2 passes verification
- Phase 4: Traefik edge-only exposure ⏭️
Phase 1 (wipe) status + verification
You reported:
“Phase 1 complete on ctb-edge-1, ms-1, wk-1, wk-2. All k3s/cni/wireguard artifacts removed, ip rules are defaults only, and firewall is flushed with managers disabled.”
Because Phase 1 is already done, here are verification commands to confirm the “pristine baseline” is real.
Run on each node:
ip -br addr
ip route show table all
ip rule show
wg show || true
# Firewall state checks
sudo iptables -S
sudo iptables-save | head -n 40
sudo nft list ruleset 2>/dev/null | head -n 80 || true
# Common leftovers checks
systemctl list-units --type=service | egrep -i 'k3s|rke2|wireguard|wg-quick|cilium|calico|flannel' || true
ls -la /etc/cni /var/lib/cni /etc/rancher /var/lib/rancher 2>/dev/null || true
Good looks like
wg showshows nothing (or “interface: wg0” does not exist yet)- No
k3sservices running /etc/cni,/var/lib/cni,/etc/rancher,/var/lib/rancherare empty or absent (depending on how you wiped)ip rule showcontains only default rules (no custom policy routing)
STOP/GO: If anything looks “not clean”, stop and fix before Phase 2.
Phase 2 (WireGuard) implementation
2.0 STOP/GO pre-checks
Run on each node:
ip -br a
ip route
sudo iptables -S | head -n 20
sudo nft list ruleset 2>/dev/null | head -n 40 || true
Good looks like
- SSH is still connected and stable
- No firewall rule is silently blocking UDP/51820
2.1 Install WireGuard
Run on each node:
sudo apt-get update
sudo apt-get install -y wireguard
wg version
2.2 Generate keys
Run on each node:
sudo -i
umask 077
wg genkey | tee /etc/wireguard/wg0.key | wg pubkey > /etc/wireguard/wg0.pub
cat /etc/wireguard/wg0.pub
What to do with the output
-
Copy the public key and label it:
<CTB_EDGE_1_PUB><MS_1_PUB><WK_1_PUB><WK_2_PUB>
Never share
/etc/wireguard/wg0.key(private key)
2.3 Set rp_filter safely
Run on each node:
sudo tee /etc/sysctl.d/99-wireguard.conf >/dev/null <<'EOF'
net.ipv4.conf.all.rp_filter=2
net.ipv4.conf.default.rp_filter=2
EOF
sudo sysctl --system
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter
Expected output
- both values show
= 2
2.4 Create wg-quick configs (per node)
Where to put configs:
/etc/wireguard/wg0.confHow to start it:systemctl enable --now wg-quick@wg0
A) ctb-edge-1 — /etc/wireguard/wg0.conf
[Interface]
Address = 172.27.15.31/32
ListenPort = 51820
PrivateKey = <CTB_EDGE_1_PRIV>
MTU = 1420
SaveConfig = false
[Peer]
# wk-1 (via router WAN port-forward 51820 -> wk-1:51820)
PublicKey = <WK_1_PUB>
AllowedIPs = 172.27.15.11/32
Endpoint = 203.0.113.10:51820
[Peer]
# ms-1 (via router WAN port-forward 51821 -> ms-1:51820)
PublicKey = <MS_1_PUB>
AllowedIPs = 172.27.15.12/32
Endpoint = 203.0.113.10:51821
[Peer]
# wk-2 (via router WAN port-forward 51822 -> wk-2:51820)
PublicKey = <WK_2_PUB>
AllowedIPs = 172.27.15.13/32
Endpoint = 203.0.113.10:51822
Get the private key value for <CTB_EDGE_1_PRIV>:
sudo cat /etc/wireguard/wg0.key
Start + verify:
sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/wg0.key
sudo systemctl enable --now wg-quick@wg0
ip -br a show wg0
sudo ss -lunp | grep 51820
sudo wg show
ip route | grep 172.27.15
Good looks like
- wg0 exists and has
172.27.15.31/32 - UDP :51820 is listening
/32routes exist for each peer via wg0
B) ms-1 — /etc/wireguard/wg0.conf
[Interface]
Address = 172.27.15.12/32
ListenPort = 51820
PrivateKey = <MS_1_PRIV>
MTU = 1420
SaveConfig = false
[Peer]
# ctb-edge-1 (public)
PublicKey = <CTB_EDGE_1_PUB>
AllowedIPs = 172.27.15.31/32
Endpoint = 198.51.100.25:51820
PersistentKeepalive = 25
[Peer]
# wk-1 (LAN)
PublicKey = <WK_1_PUB>
AllowedIPs = 172.27.15.11/32
Endpoint = 192.168.15.3:51820
[Peer]
# wk-2 (LAN)
PublicKey = <WK_2_PUB>
AllowedIPs = 172.27.15.13/32
Endpoint = 192.168.15.4:51820
Start + verify:
sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/wg0.key
sudo systemctl enable --now wg-quick@wg0
ip -br a show wg0
sudo wg show
ip route | grep 172.27.15
ping -c 3 172.27.15.31
STOP/GO
- Do not proceed until
ms-1 -> ctb-edge-1ping succeeds.
C) wk-1 — /etc/wireguard/wg0.conf
[Interface]
Address = 172.27.15.11/32
ListenPort = 51820
PrivateKey = <WK_1_PRIV>
MTU = 1420
SaveConfig = false
[Peer]
# ctb-edge-1 (public)
PublicKey = <CTB_EDGE_1_PUB>
AllowedIPs = 172.27.15.31/32
Endpoint = 198.51.100.25:51820
PersistentKeepalive = 25
[Peer]
# ms-1 (LAN)
PublicKey = <MS_1_PUB>
AllowedIPs = 172.27.15.12/32
Endpoint = 192.168.15.2:51820
[Peer]
# wk-2 (LAN)
PublicKey = <WK_2_PUB>
AllowedIPs = 172.27.15.13/32
Endpoint = 192.168.15.4:51820
Start + verify:
sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/wg0.key
sudo systemctl enable --now wg-quick@wg0
sudo wg show
ping -c 3 172.27.15.31
ping -c 3 172.27.15.12
ping -c 3 172.27.15.13
D) wk-2 — /etc/wireguard/wg0.conf
[Interface]
Address = 172.27.15.13/32
ListenPort = 51820
PrivateKey = <WK_2_PRIV>
MTU = 1420
SaveConfig = false
[Peer]
# ctb-edge-1 (public)
PublicKey = <CTB_EDGE_1_PUB>
AllowedIPs = 172.27.15.31/32
Endpoint = 198.51.100.25:51820
PersistentKeepalive = 25
[Peer]
# ms-1 (LAN)
PublicKey = <MS_1_PUB>
AllowedIPs = 172.27.15.12/32
Endpoint = 192.168.15.2:51820
[Peer]
# wk-1 (LAN)
PublicKey = <WK_1_PUB>
AllowedIPs = 172.27.15.11/32
Endpoint = 192.168.15.3:51820
Start + verify:
sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/wg0.key
sudo systemctl enable --now wg-quick@wg0
sudo wg show
ping -c 3 172.27.15.31
ping -c 3 172.27.15.12
ping -c 3 172.27.15.11
2.5 Verification checklist
Run on each node:
1) Ensure /32 routes exist (one per peer)
ip route | grep 172.27.15
Expected
- exactly three peer routes (each
/32) viawg0
2) Ensure handshakes are recent
sudo wg show
Expected
- each peer shows a “latest handshake” within a reasonable time window
3) Ping every peer WG address
From each node, ping the other three:
ping -c 3 172.27.15.11
ping -c 3 172.27.15.12
ping -c 3 172.27.15.13
ping -c 3 172.27.15.31
(Only ping the ones that are “other nodes” from where you are.)
STOP/GO (end of Phase 2) Proceed to Phase 3 only when:
- all nodes ping all other nodes over WireGuard
wg showshows 3 peers with handshakesAllowedIPseverywhere are/32only
2.6 Debug playbook
A) No handshake at all
- Check service and UDP listener:
systemctl status wg-quick@wg0 --no-pager
sudo ss -lunp | grep 51820
- Confirm the peer public keys are correct on both sides:
sudo wg show
- Packet visibility (best “truth detector”):
- On ctb-edge-1:
sudo tcpdump -ni any udp port 51820
- Restart WG on a home node to force packets:
sudo systemctl restart wg-quick@wg0
Expected
- You see UDP packets arriving on the edge
- If edge can’t reach home:
- router port-forward mismatch is the most common cause (wrong external port → wrong internal host)
B) Handshake OK but ping fails
This is usually routes, AllowedIPs, rp_filter, or firewall.
- Check routing decision:
ip route get 172.27.15.31
ip route get 172.27.15.12
- Confirm AllowedIPs are strictly
/32:
sudo wg show
- Confirm rp_filter is loose mode:
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter
- Verify nothing is dropping ICMP:
sudo iptables -S
sudo iptables -L -nv
sudo nft list ruleset 2>/dev/null | sed -n '1,160p' || true
- Watch ICMP on wg0:
sudo tcpdump -ni wg0 icmp
C) Home↔Home pings fail but edge works
Usually a wrong LAN endpoint or local blocking.
- Confirm LAN IPs:
ip -br addr
- Confirm home↔home endpoints in configs use
192.168.15.x:51820 - Observe UDP on the LAN:
sudo tcpdump -ni any udp port 51820
2.7 Run-all scripts
ctb-edge-1 minimal run-all
sudo apt-get update && sudo apt-get install -y wireguard
sudo chmod 600 /etc/wireguard/wg0.key /etc/wireguard/wg0.pub
sudo nano /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0
sudo wg show
Home nodes (ms-1, wk-1, wk-2) minimal run-all
sudo apt-get update && sudo apt-get install -y wireguard
sudo tee /etc/sysctl.d/99-wireguard.conf >/dev/null <<'EOF'
net.ipv4.conf.all.rp_filter=2
net.ipv4.conf.default.rp_filter=2
EOF
sudo sysctl --system
sudo nano /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/wg0.key
sudo systemctl enable --now wg-quick@wg0
sudo wg show
Design decisions + trade-offs
Full mesh (instead of hub-and-spoke)
Why: direct peer-to-peer connectivity simplifies Kubernetes node-to-node paths later and avoids creating a single dependency node for internal traffic.
Trade-off: more peer blocks to manage (3 peers per node).
AllowedIPs = /32 per peer only
Why: least privilege routing; avoids accidental “route the world through WireGuard” mistakes.
Trade-off: if you later want to route whole subnets over WG, you must explicitly design that.
Keepalive only from home → edge
Why: NAT mapping exists on the home router side, so the home nodes need to keep their NAT state alive.
Trade-off: none meaningful; it’s standard practice.
MTU = 1420 on wg0
Why: safe baseline for internet paths and later VXLAN overlay work.
Trade-off: slightly lower maximum throughput than jumbo frames, but higher reliability.
rp_filter=2
Why: prevents false drops in multi-interface routing situations that commonly happen with VPN overlays.
Trade-off: looser than strict mode; still much better than disabling completely.
Open questions / assumptions
- Router UDP port forwards are correctly configured exactly as listed.
- Firewalls are flushed and not re-applied by another manager.
- Public keys are correctly exchanged and copied (most common human error is a swapped key).
If anything here is uncertain, the debug playbook (tcpdump + wg show + route checks) will reveal it quickly.
Next steps (Phase 3 preview)
Once Phase 2 is fully verified, the next phase is:
-
Install K3s with flannel disabled (
--flannel-backend=none) -
Install Calico in VXLAN mode
-
Choose MTU for pods that accounts for:
- wg0 MTU (1420)
- VXLAN overhead
Glossary
- CNI: Container Network Interface (Kubernetes networking layer)
- NAT: Network Address Translation (router shares one public IP for many devices)
- /32: single IP route (one exact host address)
- wg0: the WireGuard network interface
- wg-quick: tool that brings WireGuard interfaces up/down from config
- Endpoint: where to send encrypted WireGuard packets (IP:port)
- AllowedIPs: what IPs a peer is allowed to use + what gets routed to that peer
- MTU: maximum packet size before fragmentation
- rp_filter: reverse path filtering (Linux anti-spoof check that can drop valid VPN traffic)
Further learning links (official / high-quality)
-
WireGuard (official): https://www.wireguard.com/
-
wgandwg-quickman pages: -
Linux
rp_filteroverview (kernel docs): https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt -
Systemd services basics: https://www.freedesktop.org/software/systemd/man/systemctl.html
-
MTU and troubleshooting fragmentation (practical reference): https://wiki.wireshark.org/MTU
Setup Kubernetes
Current note This is a detailed historical deep dive. For the current rebuild path, start with 01-platform-overview.md and 02-rebuild-cluster-step-by-step.md.
Table of contents
- What this document is
- Architecture
- Concepts
- Phase 3 implementation steps
- Troubleshooting
- Official references
What this document is
This document turns the current document history into a clean Phase 3 runbook for a beginner. It explains what was decided, why it was decided, exactly which commands to run, where to run them, how to verify success, and what to watch out for.
This document covers only the material that exists in the current document history. It does not invent later phases or assume any work that has not been discussed yet.
Current architecture
This homelab has 4 Ubuntu 24.04 nodes.
Home LAN nodes
ms-1= K3s server, LAN192.168.15.2, WireGuard172.27.15.12wk-1= worker, LAN192.168.15.3, WireGuard172.27.15.11wk-2= worker, LAN192.168.15.4, WireGuard172.27.15.13
Public edge node
ctb-edge-1= Contabo public edge, public IP198.51.100.25, WireGuard172.27.15.31
Overlay networking already verified before Phase 3
The document established that a full-mesh WireGuard setup is already working, with peer /32 routes and successful handshakes and pings across 172.27.15.0/24. This Phase 3 runbook starts after that point.
What Phase 3 is trying to achieve
Phase 3 installs Kubernetes and pod networking in a way that matches the homelab design:
ms-1becomes the single K3s serverwk-1,wk-2, andctb-edge-1become K3s agents- K3s is installed with Flannel disabled
- K3s packaged Traefik is disabled
- K3s built-in network policy controller is disabled so Calico provides networking and policy
- Calico is installed in VXLAN mode
- Node internal communication is anchored to the WireGuard IPs
ms-1is treated as control-plane onlyctb-edge-1is treated as edge-only by opt-in- Minimal host firewall guardrails are added and later made reboot-safe
K3s supports disabling packaged components with --disable, including Traefik, and documents that Traefik is deployed by default unless disabled. K3s also documents disabling its embedded network policy controller with --disable-network-policy. (docs.k3s.io)
Calico’s K3s multi-node guidance explicitly shows disabling Flannel and disabling K3s default network policy when installing K3s for Calico. (docs.tigera.io)
Decisions made
1. Disable K3s Traefik now, install Traefik later
This was chosen so the cluster does not auto-deploy Traefik in a generic way and then later fight with a custom edge-only Traefik deployment. K3s deploys Traefik by default and recommends --disable=traefik to remove or prevent it. (docs.k3s.io)
2. Use Calico VXLAN, not BGP
This keeps routing simpler for a homelab. Calico documents VXLAN as an overlay mode that avoids requiring the underlay network to understand pod IPs, and notes that VXLAN does not use BGP between Calico nodes the way Calico IP-in-IP does. (docs.tigera.io)
3. Use WireGuard IPs as node internal addresses
The document intentionally pinned each node to its WireGuard IP so Kubernetes node-to-node communication stays on the overlay.
4. Keep ms-1 for control-plane duties only
The document decided that user workloads should avoid ms-1 and instead go to the workers or edge.
5. Make ctb-edge-1 opt-in only
The document decided that public-edge workloads should run on ctb-edge-1 only when explicitly requested using labels, taints, tolerations, and selectors.
6. Use a dedicated resolver file for K3s
All four nodes showed /etc/resolv.conf pointing to the systemd-resolved stub at 127.0.0.53. K3s checks resolver files for unusable loopback, multicast, or link-local nameservers and supports --resolv-conf to supply a suitable alternative file. (docs.k3s.io)
Corrections made during the document
One important correction happened.
rp_filter
Earlier document drafts used rp_filter=0 in some places, and there was confusion about 1 versus 2. The corrected recommendation is:
0= disabled1= strict2= loose
The Linux kernel documentation says strict mode is good for spoof protection, but if the system uses asymmetric or more complicated routing, loose mode (2) is recommended. It also states that the maximum of conf/all and conf/<interface> is what actually applies. For this WireGuard + Calico VXLAN setup, the corrected baseline is rp_filter=2, with 0 kept only as a fallback troubleshooting option. (Kernel Documentation)
Concepts explained simply
What is K3s?
K3s is a lightweight Kubernetes distribution. It still runs standard Kubernetes components, but it bundles common pieces like CoreDNS, metrics-server, and Traefik unless you disable them. (docs.k3s.io)
What is Flannel?
Flannel is the default CNI-style pod networking fabric in K3s. K3s documents Flannel as a layer-3 network fabric and a CNI plugin. In this homelab, Flannel is disabled because Calico will provide pod networking instead. (docs.k3s.io)
What is Calico?
Calico is the networking and network policy layer for the cluster. In this setup, it provides:
- pod networking
- Kubernetes NetworkPolicy enforcement
- VXLAN encapsulation between nodes
The Tigera operator installation API is the supported way to configure a Calico operator-based install. (docs.tigera.io)
What is VXLAN?
VXLAN is an overlay technique. It wraps workload traffic so the underlying network does not need to know pod addresses. Calico documents VXLAN as an encapsulation option that works without BGP between Calico nodes. (docs.tigera.io)
What is --resolv-conf?
K3s can pass a resolver file path through to kubelet using --resolv-conf. This matters when the normal host resolver file uses systemd-resolved’s local stub, because loopback resolvers do not work the same way inside pods. (docs.k3s.io)
What is rp_filter?
rp_filter is Linux reverse path filtering. It checks whether a packet’s source looks valid according to the routing table.
0= disabled1= strict2= loose
The kernel docs recommend loose mode when routing is asymmetric or otherwise complex, which matches this homelab better than strict mode. (Kernel Documentation)
Why is MTU discussed here?
Overlay networking adds headers and reduces effective payload size. Calico documents that VXLAN has extra per-packet overhead and also supports setting the VXLAN MTU explicitly. In this document, the chosen design was to set Calico MTU to 1370 because the underlay WireGuard interface is 1420 and the document intentionally reserved headroom for VXLAN encapsulation. That exact 1370 value is a design choice from this document, not a universal fixed value. (docs.tigera.io)
Prerequisites and assumptions
Before following this runbook, these things are assumed to already be true because they were established earlier in the document:
-
WireGuard full mesh works across all four nodes
-
Each node can reach the others on their
172.27.15.xaddress -
The kernel/sysctl basics were already checked:
net.ipv4.ip_forward = 1net.bridge.bridge-nf-call-iptables = 1
-
Each node uses Ubuntu 24.04
-
SSH remains available on port 22
-
The chosen K3s version in the document is
v1.35.1+k3s1
The resolver output captured in the document showed all four nodes using the systemd-resolved stub:
/etc/resolv.conf -> /run/systemd/resolve/stub-resolv.confnameserver 127.0.0.53
That is why this runbook uses a dedicated K3s resolver symlink and --resolv-conf.
Phase 3 implementation steps
Step 1: Prepare resolver file for K3s on all nodes
Why this step exists
K3s checks /etc/resolv.conf and /run/systemd/resolve/resolv.conf for unusable loopback, multicast, or link-local nameservers. If needed, you can explicitly point it at a suitable file with --resolv-conf. The document’s node outputs showed the normal /etc/resolv.conf on all nodes pointing at 127.0.0.53, so this step is necessary here. (docs.k3s.io)
Run on
ms-1wk-1wk-2ctb-edge-1
Commands
install -d -m 0755 /etc/rancher/k3s
ln -sf /run/systemd/resolve/resolv.conf /etc/rancher/k3s/k3s-resolv.conf
readlink -f /etc/rancher/k3s/k3s-resolv.conf
grep -E '^(nameserver|search|options)' /etc/rancher/k3s/k3s-resolv.conf
What the commands do
- create
/etc/rancher/k3sif it does not exist - create a stable symlink called
k3s-resolv.conf - verify that the file points to the non-stub resolver
- print the DNS servers that K3s will use
Good looks like
On the home nodes, you should see real upstream resolvers such as 192.168.15.1. On the edge node, you should see the Contabo-provided resolver IPs.
Step 2: Install K3s server on ms-1
Why this step exists
This turns ms-1 into the only K3s server, disables Flannel, disables Traefik, disables the embedded network policy controller, and pins the node identity to the WireGuard IP.
K3s documents that server nodes also support agent options, and K3s plus Calico guidance shows disabling Flannel and K3s default network policy for this install pattern. (docs.k3s.io)
Run on
ms-1
Commands
export INSTALL_K3S_VERSION="v1.35.1+k3s1"
curl -sfL https://get.k3s.io | \
K3S_KUBECONFIG_MODE="644" \
INSTALL_K3S_EXEC="server \
--node-ip=172.27.15.12 \
--advertise-address=172.27.15.12 \
--tls-san=172.27.15.12 \
--flannel-backend=none \
--disable-network-policy \
--disable=traefik \
--disable=servicelb \
--resolv-conf=/etc/rancher/k3s/k3s-resolv.conf \
--cluster-cidr=10.42.0.0/16 \
--service-cidr=10.43.0.0/16 \
--node-label homelab.kakde.eu/role=server" \
sh -
What each important option means
--node-ip=172.27.15.12tells K3s to register this node with the WireGuard IP--advertise-address=172.27.15.12tells the API server which address to advertise--tls-san=172.27.15.12allows the cert to include that address--flannel-backend=nonedisables Flannel because Calico will replace it--disable-network-policydisables K3s embedded network policy because Calico will provide policy--disable=traefikprevents packaged Traefik from being installed--disable=servicelbdisables K3s ServiceLB because the design will use a custom ingress strategy later--resolv-conf=...gives K3s/kubelet a safe resolver file--cluster-cidr/--service-cidrset pod and service ranges--node-label ...adds a role label at registration time; K3s agent docs note that labels and taints added this way are applied at registration time only. (docs.k3s.io)
Verify on ms-1
systemctl status k3s --no-pager -l
k3s --version
ss -lntp | egrep ':6443'
kubectl get nodes -o wide
Good looks like
k3sservice is running- TCP
6443is listening ms-1appears inkubectl get nodes- it may still be
NotReadyuntil Calico is installed
Step 3: Install Calico with VXLAN on ms-1
Why this step exists
Calico will provide:
- pod networking
- NetworkPolicy
- VXLAN overlay
Calico’s operator installation API defines the Installation and APIServer resources used here. Calico’s IP autodetection docs show how to tell Calico to use the Kubernetes node internal IP, which is exactly what this homelab wants because the Kubernetes node internal IPs are the WireGuard addresses. (docs.tigera.io)
Run on
ms-1
Commands
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.31.4/manifests/operator-crds.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.31.4/manifests/tigera-operator.yaml
cat >/root/calico-custom-resources.yaml <<'EOF'
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
bgp: Disabled
mtu: 1370
nodeAddressAutodetectionV4:
kubernetes: NodeInternalIP
ipPools:
- cidr: 10.42.0.0/16
encapsulation: VXLAN
natOutgoing: Enabled
nodeSelector: all()
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
name: default
spec: {}
EOF
kubectl create -f /root/calico-custom-resources.yaml
Why kubectl create is used here
The document used create, and Calico documentation notes that large CRD bundles can exceed request limits with apply; Calico docs recommend create or replace in those cases. (docs.tigera.io)
Why nodeAddressAutodetectionV4: kubernetes: NodeInternalIP
Calico docs say this tells Calico to pick the first internal IP from the Kubernetes node status. That matches the design here because K3s registers the nodes using the WireGuard addresses. (docs.tigera.io)
Verify on ms-1
kubectl get pods -A -o wide | egrep 'tigera|calico|coredns|metrics' || true
kubectl get nodes -o wide
ip link show vxlan.calico || true
ip -d link show vxlan.calico 2>/dev/null | egrep 'vxlan|mtu' || true
Good looks like
tigera-operatoris runningcalico-nodeis running onms-1ms-1becomesReadyvxlan.calicoexists- MTU is set to the intended value or the resulting effective value reflects the chosen config
Step 4: Add edge host guardrails before joining ctb-edge-1
Why this step exists
ctb-edge-1 is public. The cluster should not expose kubelet, VXLAN, or NodePort ranges to the internet on the public interface.
The port choices here come directly from the document design:
- block
10250/tcpon public interface - block
4789/udpon public interface - block
30000-32767on public interface
Run on
ctb-edge-1
First confirm the public interface name
Do not blindly assume it is eth0.
ip -br link
ip -br addr
If the public interface is really eth0, use the following as written. If not, replace eth0 with the correct interface name.
Commands
iptables -I INPUT 1 -i eth0 -p tcp --dport 10250 -j DROP
iptables -I INPUT 1 -i eth0 -p udp --dport 4789 -j DROP
iptables -I INPUT 1 -i eth0 -p tcp --dport 30000:32767 -j DROP
iptables -I INPUT 1 -i eth0 -p udp --dport 30000:32767 -j DROP
iptables -S INPUT | head -n 40
What these rules do
They only block those ports on the public interface, not globally. SSH and WireGuard stay separate from this step.
Good looks like
You can see the DROP rules near the top of the INPUT chain, and your SSH session remains alive.
Step 5: Join the agents
Why this step exists
This turns the other three nodes into K3s agents and pins each one to its WireGuard IP.
K3s agent docs explicitly document --node-label and --node-taint at registration time. (docs.k3s.io)
Step 5A: Get the token from ms-1
Run on
ms-1
Command
cat /var/lib/rancher/k3s/server/node-token
Copy the output somewhere temporarily. You will paste it into the agent install commands.
Step 5B: Join wk-1
Run on
wk-1
Command
export INSTALL_K3S_VERSION="v1.35.1+k3s1"
curl -sfL https://get.k3s.io | \
K3S_URL="https://172.27.15.12:6443" \
K3S_TOKEN="<PASTE_NODE_TOKEN_HERE>" \
INSTALL_K3S_EXEC="agent \
--node-ip=172.27.15.11 \
--resolv-conf=/etc/rancher/k3s/k3s-resolv.conf \
--node-label homelab.kakde.eu/role=worker" \
sh -
Step 5C: Join wk-2
Run on
wk-2
Command
export INSTALL_K3S_VERSION="v1.35.1+k3s1"
curl -sfL https://get.k3s.io | \
K3S_URL="https://172.27.15.12:6443" \
K3S_TOKEN="<PASTE_NODE_TOKEN_HERE>" \
INSTALL_K3S_EXEC="agent \
--node-ip=172.27.15.13 \
--resolv-conf=/etc/rancher/k3s/k3s-resolv.conf \
--node-label homelab.kakde.eu/role=worker" \
sh -
Step 5D: Join ctb-edge-1
Run on
ctb-edge-1
Command
export INSTALL_K3S_VERSION="v1.35.1+k3s1"
curl -sfL https://get.k3s.io | \
K3S_URL="https://172.27.15.12:6443" \
K3S_TOKEN="<PASTE_NODE_TOKEN_HERE>" \
INSTALL_K3S_EXEC="agent \
--node-ip=172.27.15.31 \
--resolv-conf=/etc/rancher/k3s/k3s-resolv.conf \
--node-label homelab.kakde.eu/role=edge" \
sh -
Verify after all joins
Run on
ms-1
Command
kubectl get nodes -o wide
kubectl get pods -A -o wide | egrep 'tigera|calico|coredns' || true
Good looks like
All four nodes appear and eventually become Ready, with INTERNAL-IP equal to the expected 172.27.15.x value.
Step 6: Apply workload placement policy
Why this step exists
This enforces the placement policy discussed in the document:
ms-1should not carry normal workloadsctb-edge-1should accept workloads only if they explicitly opt inwk-1andwk-2remain general worker nodes
Run on
ms-1
Commands
kubectl get nodes -o wide
kubectl taint nodes ms-1 node-role.kubernetes.io/control-plane=true:NoSchedule --overwrite || true
kubectl taint nodes ctb-edge-1 homelab.kakde.eu/edge=true:NoSchedule --overwrite
kubectl label node ms-1 homelab.kakde.eu/role=server --overwrite
kubectl label node wk-1 homelab.kakde.eu/role=worker --overwrite
kubectl label node wk-2 homelab.kakde.eu/role=worker --overwrite
kubectl label node ctb-edge-1 homelab.kakde.eu/role=edge --overwrite
kubectl get nodes --show-labels
kubectl describe node ms-1 | sed -n '/Taints:/,/Conditions:/p'
kubectl describe node ctb-edge-1 | sed -n '/Taints:/,/Conditions:/p'
What these commands do
- taint
ms-1so regular workloads are not scheduled there - taint
ctb-edge-1so only explicitly edge-marked workloads land there - make sure role labels are correct
Example of an edge-only pod spec
Use something like this in future manifests:
spec:
nodeSelector:
homelab.kakde.eu/role: edge
tolerations:
- key: "homelab.kakde.eu/edge"
operator: "Equal"
value: "true"
effect: "NoSchedule"
Good looks like
ms-1shows a control-plane styleNoScheduletaintctb-edge-1showshomelab.kakde.eu/edge=true:NoSchedule
Step 7: Verify the cluster
Run on
ms-1
Commands
kubectl get nodes -o wide
kubectl get pods -A | egrep 'tigera|calico|coredns'
kubectl -n tigera-operator get pods -o wide
Good looks like
- all nodes are
Ready calico-nodeis running on all nodescorednsis running- Tigera operator is running
Step 8: Smoke-test pod networking and DNS
Why this step exists
It proves that pod networking and cluster DNS work after the install.
Run on
ms-1
Commands
kubectl create ns nettest || true
kubectl -n nettest run p1 --image=busybox:1.36 --command -- sh -c "sleep 36000"
kubectl -n nettest run p2 --image=busybox:1.36 --command -- sh -c "sleep 36000"
kubectl -n nettest get pods -o wide
P2IP="$(kubectl -n nettest get pod p2 -o jsonpath='{.status.podIP}')"
kubectl -n nettest exec -it p1 -- ping -c 3 "$P2IP"
kubectl -n nettest exec -it p1 -- nslookup kubernetes.default.svc.cluster.local
Important note
This quick test proves pod networking works, but because both pods are unscheduled test pods, Kubernetes might place them on the same node by chance. If you want to prove cross-node traffic specifically, use a manifest that pins p1 and p2 to different nodes.
Good looks like
- ping succeeds
- DNS resolves
kubernetes.default.svc.cluster.local - cluster DNS server appears as something like
10.43.0.10
Make host firewall rules survive reboot
Why this section exists
Rules added with iptables are not persistent by default across reboot. The document explicitly asked about this.
Also, K3s and Calico will rebuild their own rules at startup. What needs persistence here are only the custom host guardrail rules created for:
ms-1ctb-edge-1
The chosen approach in the document was a systemd oneshot service that reapplies only the small custom guardrail set. This avoids flushing tables and avoids stepping on K3s/Calico state.
Persist ms-1 host guardrails
Run on
ms-1
Commands
cat >/usr/local/sbin/homelab-fw-ms1.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
iptables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || iptables -I INPUT 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -C INPUT -i lo -j ACCEPT 2>/dev/null || iptables -I INPUT 2 -i lo -j ACCEPT
iptables -C INPUT -p tcp --dport 22 -j ACCEPT 2>/dev/null || iptables -I INPUT 3 -p tcp --dport 22 -j ACCEPT
iptables -C INPUT -i wg0 -s 172.27.15.0/24 -p tcp --dport 6443 -j ACCEPT 2>/dev/null || iptables -I INPUT 4 -i wg0 -s 172.27.15.0/24 -p tcp --dport 6443 -j ACCEPT
iptables -C INPUT -p tcp --dport 6443 -j DROP 2>/dev/null || iptables -A INPUT -p tcp --dport 6443 -j DROP
EOF
chmod +x /usr/local/sbin/homelab-fw-ms1.sh
cat >/etc/systemd/system/homelab-fw-ms1.service <<'EOF'
[Unit]
Description=Homelab host firewall guardrails (ms-1)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/homelab-fw-ms1.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now homelab-fw-ms1.service
systemctl status homelab-fw-ms1.service --no-pager -l
iptables -S INPUT | egrep 'dport 6443|dport 22|ESTABLISHED' || true
What this does
- re-adds safe baseline accept rules
- allows K3s API only over
wg0 - drops
6443from everywhere else - makes the behavior reapply on boot
Persist ctb-edge-1 host guardrails
Run on
ctb-edge-1
Commands
cat >/usr/local/sbin/homelab-fw-edge.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
iptables -C INPUT -i eth0 -p tcp --dport 10250 -j DROP 2>/dev/null || iptables -I INPUT 1 -i eth0 -p tcp --dport 10250 -j DROP
iptables -C INPUT -i eth0 -p udp --dport 4789 -j DROP 2>/dev/null || iptables -I INPUT 1 -i eth0 -p udp --dport 4789 -j DROP
iptables -C INPUT -i eth0 -p tcp --dport 30000:32767 -j DROP 2>/dev/null || iptables -I INPUT 1 -i eth0 -p tcp --dport 30000:32767 -j DROP
iptables -C INPUT -i eth0 -p udp --dport 30000:32767 -j DROP 2>/dev/null || iptables -I INPUT 1 -i eth0 -p udp --dport 30000:32767 -j DROP
ip6tables -C INPUT -i eth0 -p tcp --dport 10250 -j DROP 2>/dev/null || ip6tables -I INPUT 1 -i eth0 -p tcp --dport 10250 -j DROP
ip6tables -C INPUT -i eth0 -p udp --dport 4789 -j DROP 2>/dev/null || ip6tables -I INPUT 1 -i eth0 -p udp --dport 4789 -j DROP
ip6tables -C INPUT -i eth0 -p tcp --dport 30000:32767 -j DROP 2>/dev/null || ip6tables -I INPUT 1 -i eth0 -p tcp --dport 30000:32767 -j DROP
ip6tables -C INPUT -i eth0 -p udp --dport 30000:32767 -j DROP 2>/dev/null || ip6tables -I INPUT 1 -i eth0 -p udp --dport 30000:32767 -j DROP
EOF
chmod +x /usr/local/sbin/homelab-fw-edge.sh
cat >/etc/systemd/system/homelab-fw-edge.service <<'EOF'
[Unit]
Description=Homelab host firewall guardrails (ctb-edge-1)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/homelab-fw-edge.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now homelab-fw-edge.service
systemctl status homelab-fw-edge.service --no-pager -l
iptables -S INPUT | egrep '10250|4789|30000:32767' || true
ip6tables -S INPUT | egrep '10250|4789|30000:32767' || true
Important reminder
If the public interface is not eth0, replace eth0 before using this script.
Expected healthy output
The document included a concrete example of what “healthy” Phase 3 output should look like. In plain English, it looked like this:
1. Pod networking works
From p1, ping to p2 succeeds with 0% packet loss.
2. Cluster DNS works
nslookup kubernetes.default.svc.cluster.local returns a valid service IP, such as 10.43.0.1, and shows a cluster DNS server like 10.43.0.10.
3. Calico and Tigera are healthy
You should see:
calico-apiservercalico-kube-controllerscalico-nodeon all nodescalico-typhacsi-node-drivertigera-operatorcoredns
all in Running state.
4. All nodes are Ready
A healthy kubectl get nodes -o wide in the document showed:
ctb-edge-1Ready, INTERNAL-IP172.27.15.31ms-1Ready, INTERNAL-IP172.27.15.12wk-1Ready, INTERNAL-IP172.27.15.11wk-2Ready, INTERNAL-IP172.27.15.13
That is the target state.
Troubleshooting guide
Symptom: K3s starts but pod DNS fails
Check the resolver configuration
K3s documents that it checks resolver files for unusable nameservers and allows an override with --resolv-conf. (docs.k3s.io)
Run
grep -R "resolv-conf" /etc/systemd/system/k3s*.service /etc/systemd/system/k3s-agent*.service 2>/dev/null || true
cat /etc/rancher/k3s/k3s-resolv.conf
kubectl -n kube-system get pods -l k8s-app=kube-dns -o wide
kubectl -n kube-system logs -l k8s-app=kube-dns --tail=100
What to look for
- the K3s service actually includes
--resolv-conf=/etc/rancher/k3s/k3s-resolv.conf - that file contains real upstream DNS servers, not
127.0.0.53
Symptom: Nodes do not become Ready after Calico install
Check
kubectl get pods -A -o wide
kubectl -n tigera-operator logs deploy/tigera-operator --tail=200
kubectl get installation default -o yaml
What to look for
- operator pod errors
- bad installation resource values
- IP autodetection choosing the wrong address
Calico documents the nodeAddressAutodetectionV4 methods, including kubernetes: NodeInternalIP, which is the expected setting here. (docs.tigera.io)
Symptom: Overlay traffic is flaky
First check rp_filter
The corrected baseline is 2, not 1, and only drop to 0 if testing proves loose mode is still interfering. The kernel docs explicitly recommend loose mode for asymmetric or more complex routing. (Kernel Documentation)
Run
sysctl net.ipv4.conf.all.rp_filter
sysctl net.ipv4.conf.default.rp_filter
sysctl net.ipv4.conf.wg0.rp_filter
Good looks like
All three return 2.
If you need to set them
cat >/etc/sysctl.d/99-k3s-calico.conf <<'EOF'
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
net.bridge.bridge-nf-call-iptables=1
net.bridge.bridge-nf-call-ip6tables=1
net.ipv4.conf.all.rp_filter=2
net.ipv4.conf.default.rp_filter=2
net.ipv4.conf.wg0.rp_filter=2
EOF
sysctl --system
Symptom: Edge node exposes ports you did not want
Run
ss -lntup | egrep ':(10250|4789|3[0-9]{4})' || true
iptables -S INPUT | egrep '10250|4789|30000:32767' || true
ip6tables -S INPUT | egrep '10250|4789|30000:32767' || true
What to look for
- listeners on unexpected public ports
- missing DROP rules
- missing persistence service
Symptom: Workloads land on ms-1 or edge unexpectedly
Run
kubectl describe node ms-1 | sed -n '/Taints:/,/Conditions:/p'
kubectl describe node ctb-edge-1 | sed -n '/Taints:/,/Conditions:/p'
kubectl get nodes --show-labels
What to look for
- missing taints
- wrong labels
- workloads that lack the required toleration or nodeSelector
Common mistakes
- forgetting
--disable=traefikand then later fighting the packaged Traefik deployment (docs.k3s.io) - forgetting
--disable-network-policywhen replacing K3s netpol with Calico netpol (docs.k3s.io) - forgetting
--resolv-confeven though/etc/resolv.confpoints to127.0.0.53(docs.k3s.io) - assuming
iptablescommands persist after reboot - assuming the public interface is always
eth0 - using
rp_filter=1in this overlay design instead of the corrected baseline2(Kernel Documentation) - treating the quick pod test as guaranteed cross-node proof when Kubernetes may schedule both test pods onto the same node
Open questions and gaps
These are the things still left open or intentionally deferred in the document:
-
Traefik deployment is not part of this phase. The design decision is clear, but the actual deployment belongs to Phase 4.
-
Public interface name on ctb-edge-1 must be confirmed. The firewall examples use
eth0, but the real name may differ. -
The exact Calico MTU value is an intentional design choice from this document. It is conservative and reasonable for
wg0=1420, but it should still be verified in practice using actual traffic tests. -
A stronger cross-node pod test can be added later. The simple
kubectl runsmoke test is fine for a first pass but does not force scheduling across different nodes.
Next step after this document
The next phase is to deploy Traefik only on ctb-edge-1:
- bind host ports
80and443 - schedule only on the edge node
- require explicit edge tolerations/selectors
- keep public exposure minimal
- verify from both raw IP and DNS
That is outside this document, but this document prepares the cluster for it cleanly.
Official references
K3s packaged components and disabling add-ons: Traefik, metrics-server, local-storage, and others are managed as packaged components; K3s supports disabling them with --disable. (docs.k3s.io)
K3s networking services: Traefik is deployed by default on server start, can be disabled with --disable=traefik, and the embedded network policy controller can be disabled with --disable-network-policy. (docs.k3s.io)
K3s advanced configuration: K3s checks resolver files for loopback, multicast, or link-local nameservers and supports --resolv-conf for a supplied alternative. (docs.k3s.io)
K3s air-gap install note: --resolv-conf is passed through to kubelet. (docs.k3s.io)
K3s server and agent CLI docs: server nodes support agent options; agents support --node-label and --node-taint at registration time. (docs.k3s.io)
Calico on K3s multi-node install: disable Flannel and disable K3s default network policy when using Calico. (docs.tigera.io)
Calico installation API: operator-driven Installation and APIServer resources configure the install. (docs.tigera.io)
Calico IP autodetection: kubernetes: NodeInternalIP makes Calico use the Kubernetes node internal IP. (docs.tigera.io)
Calico VXLAN behavior: VXLAN is an overlay mode, has more encapsulation overhead than IP-in-IP, and does not rely on BGP between Calico nodes. (docs.tigera.io)
Calico VXLAN MTU setting: Calico supports an explicit VXLAN MTU configuration. (docs.tigera.io)
Linux kernel rp_filter: values 0, 1, and 2; loose mode is recommended for asymmetric or complicated routing; the maximum of conf/all and conf/<interface> applies. (Kernel Documentation)
Prompt for the next document
Paste this into the next document when you are ready:
Phase 3 is complete and verified. K3s server is on ms-1 with flannel disabled, traefik disabled, and K3s default network policy disabled. Calico is installed via Tigera operator in VXLAN mode with MTU 1370 and nodeAddressAutodetectionV4 set to Kubernetes NodeInternalIP, so all nodes use their WireGuard IPs as INTERNAL-IP: wk-1=172.27.15.11, ms-1=172.27.15.12, wk-2=172.27.15.13, ctb-edge-1=172.27.15.31. Pod-to-pod ping works, CoreDNS resolves kubernetes.default.svc.cluster.local, and Calico/Tigera pods are healthy. ms-1 is tainted to avoid workloads, and ctb-edge-1 is tainted so only explicit edge workloads can run there. Host guardrail firewall rules are persisted using systemd oneshot services. Begin Phase 4: deploy Traefik only on ctb-edge-1, bind host ports 80/443, use strict scheduling constraints, keep no PROXY protocol, and add minimal edge firewall policy with external verification against 198.51.100.25 and kakde.eu.
If you want, I can also turn this into a shorter “commands-only runbook” version.
Platform Services Step by Step
This guide starts after the base cluster is healthy.
In this phase, you will add the four platform layers that make the homelab feel complete:
- Traefik for ingress
- a firewall guardrail on the public edge node
- cert-manager for automatic TLS
- Argo CD for GitOps
When this phase is done, the cluster is no longer just private infrastructure. It becomes a platform that can publish applications safely and manage them from Git.
What This Phase Produces
By the end of this phase, you should have:
- a
traefiknamespace with the ingress controller pinned toctb-edge-1 - an active host firewall guardrail on the public edge node
- a
cert-managernamespace with working Cloudflare-backed ClusterIssuers - an
argocdnamespace with Argo CD pinned towk-2 - a public Argo CD entry point at
https://argocd.kakde.eu
What This Phase Actually Changes
This phase changes both Kubernetes and the public edge host.
Inside Kubernetes, you create the Traefik ingress layer, install cert-manager and its ClusterIssuers, and then install Argo CD with the homelab-specific pinning and ingress behavior.
On the edge host, you make the public exposure model persistent. The firewall guardrail keeps loopback, established traffic, and internal interfaces open, allows public 80/tcp, 443/tcp, and 51820/udp, limits 22/tcp to your admin source IP, and blocks accidental public exposure of ports such as kubelet 10250, Calico VXLAN, or NodePort ranges.
That is why this phase matters so much: it is the point where the cluster stops being merely reachable and becomes intentionally publishable.
Inputs You Should Have Ready
Before you begin this phase, gather these values so you do not stop halfway through:
- the public IP or CIDR you will allow for SSH administration to
vm-1 - the public edge IP address that DNS should point to
- a Cloudflare API token that can manage the required DNS zone
- DNS records for the public hosts you intend to expose through Traefik
- confirmation that
ctb-edge-1andvm-1refer to the same edge machine in your environment
Before You Begin
All Kubernetes commands in this document assume cluster-admin access. On ms-1, set:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
Verify the base cluster first:
kubectl get nodes -o wide
kubectl get pods -A
Continue only if:
- all four nodes are
Ready - Calico is healthy
- the edge node
ctb-edge-1exists and is tainted withkakde.eu/edge=true:NoSchedule
Step 1: Install Traefik on the Edge Node
Traefik is the public web entry point for the homelab. It should run only on the public edge node and should bind ports 80 and 443 there.
Why hostNetwork: true? Traefik needs to bind ports 80 and 443 directly on the edge host’s public IP address. A regular Kubernetes Service (NodePort or LoadBalancer) would pick ephemeral or high-numbered ports, and there is no cloud load balancer in front of this node. hostNetwork lets Traefik own the host’s real network stack so incoming HTTPS traffic hits the right process without any extra translation layer.
Apply the manifests in order. The numbered filenames reflect the dependency sequence:
kubectl apply -f k8s-cluster/platform/traefik/1-namespace.yaml
kubectl apply -f k8s-cluster/platform/traefik/2-serviceaccount.yaml
kubectl apply -f k8s-cluster/platform/traefik/3-clusterrole.yaml
kubectl apply -f k8s-cluster/platform/traefik/4-clusterrolebinding.yaml
kubectl apply -f k8s-cluster/platform/traefik/5-ingressclass.yaml
kubectl apply -f k8s-cluster/platform/traefik/6-deployment.yaml
kubectl apply -f k8s-cluster/platform/traefik/7-service.yaml
Then enforce the intended edge placement:
EDGE_NODE=ctb-edge-1 bash k8s-cluster/platform/traefik/edge-node-placement.sh
Verify:
kubectl get pods -n traefik -o wide
kubectl get deploy -n traefik traefik -o yaml | rg -n "hostNetwork|kakde.eu/edge|NoSchedule" -A3
Good looks like:
- the Traefik pod is running on
ctb-edge-1 - the deployment uses
hostNetwork: true - the deployment tolerates the edge taint and selects the edge label
Step 2: Apply the Edge Firewall Guardrail
The edge node is intentionally public, but it should still be boring from the internet. The goal is to allow only what the platform actually needs.
The recommended public posture is:
22/tcpfrom your admin IP only80/tcp443/tcp51820/udp
Everything else should be blocked on the public interface.
1. Create the guardrail environment file on vm-1
SSH to the edge node and create /etc/default/edge-guardrail:
sudo tee /etc/default/edge-guardrail >/dev/null <<'EOF'
PUB_IF=eth0
ADMIN_IPV4_CIDR=203.0.113.50/32
# Optional:
# ADMIN_IPV6_CIDR=2001:db8::/128
EOF
Replace ADMIN_IPV4_CIDR with the public IP or CIDR you will use for SSH administration.
2. Install the firewall guardrail
Why a host-level firewall and not just Kubernetes NetworkPolicy? The firewall runs at the Linux kernel level, before any packet reaches Kubernetes. It protects against accidental public exposure of ports that are invisible to Kubernetes NetworkPolicy: the kubelet API (10250), Calico VXLAN (4789), the NodePort range (30000-32767), and the K3s API (6443). NetworkPolicy controls pod-to-pod traffic inside the cluster; the host firewall controls what the internet can reach on the machine itself.
The repo provides an nftables-based guardrail. Install it on vm-1:
sudo install -m 0755 k8s-cluster/platform/traefik/edge-guardrail.sh /usr/local/sbin/edge-guardrail.sh
sudo install -m 0644 k8s-cluster/platform/traefik/edge-guardrail.service /etc/systemd/system/edge-guardrail.service
sudo systemctl daemon-reload
sudo systemctl enable --now edge-guardrail.service
3. Verify from another machine
Do not rely on a self-scan from the edge node to its own public IP. Test from ms-1 or from another external host instead.
From ms-1, check what is visible on the edge public IP:
nmap -Pn -sT -p 22,80,443,10250 198.51.100.25
nmap -Pn -sU -p 51820 198.51.100.25
Good looks like:
22/tcpis reachable only from approved admin source IPs80/tcpand443/tcpare open51820/udpis open10250/tcpis not publicly reachable
Step 3: Confirm Traefik Is Listening on the Right Ports
On vm-1, verify the actual listeners:
sudo ss -lntup | egrep ':(80|443|8080)\b|:51820\b'
What you should understand here:
- ports
80and443should belong to Traefik 51820/udpbelongs to WireGuard- Traefik may still expose its internal dashboard/API on
8080locally - the firewall guardrail is what keeps that extra port from being publicly reachable
That separation is important. Kubernetes and the host firewall are working together here.
Step 4: Install cert-manager
cert-manager will request and renew TLS certificates for the public hosts. This design uses Cloudflare DNS-01 validation so certificates can be issued without opening extra inbound challenge ports.
Run:
bash k8s-cluster/platform/cert-manager/install-cert-manager.sh
This install path:
- creates the
cert-managernamespace if needed - installs chart version
v1.19.1 - installs CRDs through Helm
- waits for the deployment to become ready
Verify:
kubectl get pods -n cert-manager
kubectl get crd | rg cert-manager
Step 5: Create the Cloudflare Secret and ClusterIssuers
Create a real secret manifest for the Cloudflare API token. The value must be a token that can edit the DNS zone used by your public hosts.
cat >/tmp/cloudflare-api-token.secret.yaml <<'EOF'
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-api-token
namespace: cert-manager
type: Opaque
stringData:
api-token: CHANGE_ME
EOF
Replace CHANGE_ME, then apply the secret and the two issuers:
kubectl apply -f /tmp/cloudflare-api-token.secret.yaml
kubectl apply -f k8s-cluster/platform/cert-manager/clusterissuer-staging.yaml
kubectl apply -f k8s-cluster/platform/cert-manager/clusterissuer-prod.yaml
Verify:
kubectl get secret -n cert-manager cloudflare-api-token
kubectl get clusterissuer
Expected issuers:
letsencrypt-staging-dns01letsencrypt-prod-dns01
Step 6: Install Argo CD
Argo CD turns the repository into the desired state for the cluster. In this homelab, it is intentionally pinned to wk-2 so its placement is predictable and easy to reason about.
Install the pinned version:
bash k8s-cluster/platform/argocd/install-argocd.sh
This script uses server-side apply because Argo CD’s CRDs are large enough to hit client-side annotation limits.
Step 7: Apply the Cluster-Specific Argo CD Configuration
Now apply the homelab-specific behavior:
bash k8s-cluster/platform/argocd/configure-argocd.sh
This step does four important things:
- labels
wk-2withworkload=argocd - pins Argo CD deployments and the application controller to that node
- disables Argo CD’s internal TLS so Traefik can terminate HTTPS
- applies the Argo CD ingress and the current
Applicationobjects
Verify:
kubectl get pods -n argocd -o wide
kubectl get application -n argocd
kubectl get crd applicationsets.argoproj.io
kubectl get ingress -n argocd
Good looks like:
- Argo CD pods are healthy
- they are scheduled onto
wk-2 applicationsets.argoproj.ioexists- the ingress for
argocd.kakde.euexists
Step 8: Confirm the Platform Entry Points
At this stage, the ingress and TLS platform should be ready for real applications.
The important public hosts should now map into the cluster through the edge node:
argocd.kakde.eukakde.eunotebook.kakde.eukeycloak.kakde.euonce Keycloak is installed in the next phase
Useful checks:
kubectl get ingress -A
kubectl get certificate,challenge,order -A
If a host is not getting a certificate, verify:
- DNS points to the edge public IP
- the matching ingress exists
- the referenced ClusterIssuer exists
- the Cloudflare API token is valid
Final Verification Checklist
Before you move on to databases and apps, confirm the platform layer as a whole:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get pods -n traefik -o wide
kubectl get pods -n cert-manager
kubectl get pods -n argocd -o wide
kubectl get clusterissuer
kubectl get ingress -A
kubectl get application -n argocd
On vm-1, also check:
sudo ss -lntup | egrep ':(22|80|443|8080)\\b|:51820\\b'
sudo systemctl status edge-guardrail.service --no-pager
You are ready for the next phase when:
- Traefik is healthy on
ctb-edge-1 - the edge firewall is active
- cert-manager pods and ClusterIssuers are healthy
- Argo CD pods are healthy on
wk-2 - the
argocd.kakde.euingress exists - public DNS and TLS are behaving as expected
Common Recovery Shortcut
If Argo CD later reports that ApplicationSet is missing and argocd-applicationset-controller is unhealthy, rerun:
bash k8s-cluster/platform/argocd/install-argocd.sh
bash k8s-cluster/platform/argocd/configure-argocd.sh
That refreshes the missing CRD and reapplies the cluster-specific Argo CD settings.
What You Have Now
At this point you have:
- a single public ingress path
- a hardened edge node
- automatic certificate management
- GitOps control for the homelab applications
What you still do not have yet:
- the internal PostgreSQL service
- Keycloak
- the application data layer
Those come next.
Next Step
Continue with 12. Data and Apps Step by Step.
Setup Traefik on the Edge Node
Current note This is a detailed historical deep dive. For the current platform setup, start with 01-platform-overview.md and 06-platform-services-step-by-step.md.
Table of Contents
- Goal and architecture
- Key concepts
- Design decisions
- Step-by-step deployment
- Validation checklist
- Troubleshooting
- Glossary
What this document is
This deep-dive covers the full Traefik deployment on the homelab edge node, including the problems that were encountered and how they were solved: privileged port binding, rollout deadlock on host ports, and removal of the unnecessary admin port.
This guide assumes the cluster is already running with WireGuard, K3s, and Calico, and that Traefik is being deployed as the edge-only ingress controller. ctb-edge-1 is the only public ingress node. Traefik binds host ports 80 and 443 there.
What this phase is trying to achieve
The goal of this phase is to make Traefik the single internet-facing entry point for the cluster.
That means:
- Traefik runs only on
ctb-edge-1 - it binds directly to host ports
80and443 - the rest of the cluster stays private
- public exposure is limited to SSH, HTTP, HTTPS, and WireGuard on the edge node
- workloads are routed through Kubernetes ingress instead of public NodePorts.
This matters because the architecture intentionally centralizes ingress on one public node to reduce attack surface, simplify debugging, and keep internal services such as PostgreSQL private.
Architecture used in this phase
The cluster layout used here is:
ms-1→ K3s server →172.27.15.12wk-1→ K3s agent →172.27.15.11wk-2→ K3s agent →172.27.15.13ctb-edge-1→ public edge node + K3s agent →172.27.15.31- public IP of
ctb-edge-1→198.51.100.25
Networking assumptions:
- WireGuard full mesh is already working
- overlay subnet is
172.27.15.0/24 - K3s is installed with flannel disabled
- Calico VXLAN is used for pod networking
- K3s built-in Traefik is disabled so Traefik can be deployed manually.
High-level traffic flow:
Internet
|
v
ctb-edge-1 (Traefik)
|
WireGuard / cluster network
|
Kubernetes services inside the cluster
That matches the project architecture direction: one public edge only, private cluster internals, and ingress routed through Traefik.
Important concepts explained simply
What is an Ingress?
An Ingress is a Kubernetes rule that says:
“When traffic comes for this hostname, send it to this service.”
Example:
whoami.kakde.eu
|
Ingress
|
Service
|
Pod
But Ingress rules do nothing by themselves. They need an ingress controller to read them and enforce them. In this project, that controller is Traefik.
What is an ingress controller?
An ingress controller is the actual software that listens on web ports and routes traffic according to Ingress objects. Without it, Kubernetes has routing rules on paper but no component actually handling the traffic.
What does edge-only mean?
Edge-only means only the public edge machine should accept internet traffic. In this project, that machine is ctb-edge-1. Home nodes should not expose application ingress, Kubernetes API ports, or internal services publicly.
What is hostNetwork: true?
Normally, Kubernetes pods use isolated container networking. When a pod uses:
hostNetwork: true
it shares the node’s real network stack. That means if Traefik binds port 80 or 443, it binds the actual host ports on ctb-edge-1. This is the simplest way to guarantee Traefik really listens on the public node.
What are privileged ports?
Linux treats ports below 1024 as privileged. Examples include:
2280443
A non-root container usually cannot bind those ports unless it has the capability NET_BIND_SERVICE. That became the real cause of the Traefik crash in this project.
What are labels and taints?
A label is metadata attached to a node, such as:
kakde.eu/edge=true
A taint is a scheduling repellent, such as:
kakde.eu/edge=true:NoSchedule
Together, labels and taints help ensure that only the intended pod runs on the edge node. Traefik is allowed there, and regular workloads are discouraged from landing there by accident.
Why use nftables here?
nftables is the modern Linux firewall framework. In this phase, the project used a dedicated table called inet edge_guardrail so the rules could be managed without flushing the whole host firewall. The guardrail logic was simple: allow only the required public services on eth0, allow all non-public interfaces like WireGuard and Calico, and drop the rest on the public NIC.
Key design decisions
The merged document keeps these decisions as the final intended design:
- Traefik is deployed manually, not with K3s built-in defaults
- Traefik must run only on
ctb-edge-1 - Traefik must bind host ports
80and443 - public exposure on the edge should be minimal
- no unnecessary public admin port such as
9000 - strict edge-only placement uses the label
kakde.eu/edge=true - strict edge taint uses
kakde.eu/edge=true:NoSchedule - the older taint
homelab.kakde.eu/edge=true:NoSchedulewas removed because it caused scheduling mismatch in this phase.
Prerequisites
Before starting, make sure the cluster is healthy.
Run on ms-1:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o wide
kubectl get pods -A
Good looks like:
- all four nodes appear
- all nodes show
Ready - Calico and cluster core pods are healthy.
Also confirm the public network interface name on ctb-edge-1. The examples below assume eth0, but that should be verified first.
Run on ctb-edge-1:
ip -br link
ip -br addr
Good looks like:
- the public-facing interface is visible
- if the interface is not
eth0, adjust the firewall script accordingly. This is important because the firewall allowlist depends on the correct public interface.
Safe execution order
The safest order for this phase is:
- snapshot the edge node
- make sure ports
80and443are free - install the edge firewall guardrail
- label and taint the edge node properly
- deploy Traefik pinned to the edge
- capture logs if it fails
- fix privileged port binding
- fix rollout deadlock caused by host ports
- remove the unnecessary admin port
- verify that Traefik is really listening on the edge.
Step 1: Snapshot the edge node and verify ports
This step protects the current state before changes are made.
Run on ctb-edge-1:
sudo -i
ts="$(date +%Y%m%d-%H%M%S)"
bdir="/root/backup-phase4-edge-$ts"
mkdir -p "$bdir"
ip -br addr | tee "$bdir/ip-br-addr.txt"
ip route show table all | tee "$bdir/ip-route-table-all.txt"
ip rule show | tee "$bdir/ip-rule-show.txt"
wg show 2>/dev/null | tee "$bdir/wg-show.txt" || true
sysctl net.ipv4.ip_forward | tee "$bdir/sysctl-ip_forward.txt"
sysctl net.ipv4.conf.all.rp_filter | tee "$bdir/sysctl-rpfilter-all.txt"
sysctl net.ipv4.conf.default.rp_filter | tee "$bdir/sysctl-rpfilter-default.txt"
sysctl net.ipv4.conf.wg0.rp_filter 2>/dev/null | tee "$bdir/sysctl-rpfilter-wg0.txt" || true
iptables-save | tee "$bdir/iptables-save.txt" || true
iptables -S | tee "$bdir/iptables-S.txt" || true
nft list ruleset 2>/dev/null | tee "$bdir/nft-ruleset.txt" || true
echo "Backup written to: $bdir"
Now check whether anything is already using ports 80 or 443.
Run on ctb-edge-1:
sudo ss -lntup | egrep ':(80|443)\b' || echo "OK: nothing listening on 80/443 yet"
Good looks like:
- the backup directory exists
- nothing unexpected is already listening on
80or443.
STOP/GO: continue only if the backup exists and SSH is still working normally.
Step 2: Add the edge firewall guardrail
The firewall guardrail should only limit the public interface, not internal interfaces like WireGuard or Calico. That is why the rule set uses a dedicated nftables table and explicitly allows traffic not coming from the public NIC.
Run on ctb-edge-1:
sudo -i
cat >/usr/local/sbin/edge-guardrail.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
PUB_IF="eth0"
# Replace only our dedicated table (no global flush)
if nft list table inet edge_guardrail >/dev/null 2>&1; then
nft delete table inet edge_guardrail
fi
# Apply in one batch; interface names are quoted correctly for nft ("eth0")
nft -f - <<NFT
add table inet edge_guardrail
add chain inet edge_guardrail input { type filter hook input priority -50; policy drop; }
# Keep local + established
add rule inet edge_guardrail input iifname "lo" accept
add rule inet edge_guardrail input ct state established,related accept
# Allow all traffic not coming from the public NIC (wg0, cali*, etc.)
add rule inet edge_guardrail input iifname != "$PUB_IF" accept
# Public NIC allowlist
add rule inet edge_guardrail input iifname "$PUB_IF" ip protocol icmp accept
add rule inet edge_guardrail input iifname "$PUB_IF" ip6 nexthdr icmpv6 accept
add rule inet edge_guardrail input iifname "$PUB_IF" udp dport 51820 accept
add rule inet edge_guardrail input iifname "$PUB_IF" tcp dport 22 accept
add rule inet edge_guardrail input iifname "$PUB_IF" tcp dport { 80, 443 } accept
# Drop everything else on eth0
add rule inet edge_guardrail input iifname "$PUB_IF" counter drop
NFT
EOF
chmod +x /usr/local/sbin/edge-guardrail.sh
/usr/local/sbin/edge-guardrail.sh
# Verify
nft list chain inet edge_guardrail input
Persist it with systemd so it survives reboot.
Run on ctb-edge-1:
sudo -i
cat >/etc/systemd/system/edge-guardrail.service <<'EOF'
[Unit]
Description=Edge guardrail firewall (nftables allowlist on eth0)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/edge-guardrail.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now edge-guardrail.service
systemctl is-enabled edge-guardrail.service
systemctl is-active edge-guardrail.service
Good looks like:
nft list chain inet edge_guardrail inputshows the rulessystemctl is-enabledreturnsenabledsystemctl is-activereturnsactive.
Important note: one real bug in the original work was using single quotes in nftables like this:
iifname != 'eth0'
That fails. The correct form uses double quotes:
iifname != "eth0"
That syntax issue was already encountered and corrected in the project.
STOP/GO: continue only if SSH still works and the chain is present.
Step 3: Enforce strict edge-only scheduling
Traefik must run only on ctb-edge-1. This phase used a label and taint strategy for that. The earlier deployment hit a Pending state because of selector mismatch and an extra unwanted taint. The final corrected state removes the old taint and uses only the intended edge taint.
Run on ms-1:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .status.addresses[?(@.type=="InternalIP")]}{.address}{"\n"}{end}{end}' | sort
EDGE_NODE="$(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .status.addresses[?(@.type=="InternalIP")]}{.address}{"\n"}{end}{end}' | awk '$2=="172.27.15.31"{print $1}')"
echo "EDGE_NODE=$EDGE_NODE"
Expected:
EDGE_NODE=ctb-edge-1
Then apply the label and taint:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
EDGE_NODE="ctb-edge-1"
kubectl label node "$EDGE_NODE" kakde.eu/edge=true --overwrite
# Remove old/extra taint that caused scheduling failure
kubectl taint node "$EDGE_NODE" homelab.kakde.eu/edge- 2>/dev/null || true
# Enforce strict “edge-only” scheduling
kubectl taint node "$EDGE_NODE" kakde.eu/edge=true:NoSchedule --overwrite
# Verify
kubectl get node "$EDGE_NODE" --show-labels
kubectl describe node "$EDGE_NODE" | egrep -A3 'Taints|kakde.eu/edge|homelab.kakde.eu/edge' || true
Good looks like:
kakde.eu/edge=truelabel existskakde.eu/edge=true:NoScheduletaint existshomelab.kakde.eu/edgetaint is gone.
STOP/GO: continue only if the node shows the correct label and taint state.
Step 4: Deploy Traefik pinned to the edge node
At this stage, apply the prepared Traefik manifest. The project references /root/traefik-edge.yaml as the deployment manifest. Once scheduling is correct, the Traefik pod should land on ctb-edge-1. Earlier project notes confirm that scheduling success looked like NODE=ctb-edge-1 and pod IP 172.27.15.31.
Run on ms-1:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl apply -f /root/traefik-edge.yaml
kubectl -n traefik get pods -o wide
kubectl -n traefik rollout status deploy/traefik
Good looks like:
- the Traefik pod is created
- the pod is scheduled to
ctb-edge-1 - rollout completes, or at least reaches a state that can be inspected cleanly.
If the pod becomes CrashLoopBackOff, go directly to diagnostics instead of guessing.
Step 5: Capture diagnostics if Traefik fails
This is the correct workflow whenever Traefik crashes:
- capture events
- capture current logs
- capture previous logs
- decide the fix only after seeing the error.
Run on ms-1:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get ns traefik
kubectl -n traefik get pods -o wide
POD="$(kubectl -n traefik get pod -l app=traefik -o jsonpath='{.items[0].metadata.name}')"
kubectl -n traefik describe pod "$POD" | sed -n '/Events/,$p'
kubectl -n traefik logs "$POD" -c traefik --tail=200
kubectl -n traefik logs "$POD" -c traefik --previous --tail=200
In the earlier document, the crash was still unresolved at this point. In the later document, the logs clearly showed the real cause:
listen tcp :80: bind: permission denied
That means Traefik could not bind port 80 because it lacked the Linux capability required for privileged ports.
Step 6: Fix privileged port binding
Because Traefik is binding host ports 80 and 443, it needs the NET_BIND_SERVICE capability. The later Traefik document captured this as the real fix.
Run on ms-1:
kubectl -n traefik patch deploy traefik --type='json' -p='[
{
"op":"add",
"path":"/spec/template/spec/containers/0/securityContext",
"value":{
"allowPrivilegeEscalation":false,
"readOnlyRootFilesystem":false,
"capabilities":{
"drop":["ALL"],
"add":["NET_BIND_SERVICE"]
}
}
}
]'
Restart Traefik:
kubectl -n traefik rollout restart deploy traefik
kubectl -n traefik rollout status deploy traefik
kubectl -n traefik get pods -o wide
Good looks like:
- the Traefik pod reaches
Running - the immediate
bind: permission deniedcrash is gone.
Why this works:
drop: ["ALL"]removes unnecessary privilegesadd: ["NET_BIND_SERVICE"]gives only the capability needed to bind privileged ports.
Step 7: Fix rollout deadlock caused by host ports
A second real issue in the project happened during rollout. Kubernetes tried to start a second Traefik pod while the old one was still using the host ports 80, 443, and 9000. Because host ports cannot be shared on the same node, the second pod stayed Pending. The later document captured this correctly and fixed it by changing the Deployment rollout strategy.
Check for the issue:
kubectl -n traefik get pods
kubectl -n traefik describe pod <pending-pod>
You may see something like:
didn't have free ports for the requested pod ports
Patch the deployment strategy:
kubectl -n traefik patch deploy traefik --type='merge' -p '{
"spec": {
"strategy": {
"type": "RollingUpdate",
"rollingUpdate": {
"maxSurge": 0,
"maxUnavailable": 1
}
}
}
}'
This effectively means:
stop old pod first
then start new pod
If a stuck pending pod already exists, delete it:
kubectl -n traefik delete pod <pending-pod>
Then restart the rollout:
kubectl -n traefik rollout restart deploy traefik
kubectl -n traefik rollout status deploy traefik
kubectl -n traefik get pods -o wide
Good looks like:
- only one Traefik pod exists
- that pod is
Running - rollout completes successfully.
Step 8: Remove the unnecessary admin port 9000
The project architecture explicitly says to avoid unnecessary public admin ports. The later Traefik notes removed port 9000 and switched readiness and liveness probes to simple TCP checks on port 80. That reduces attack surface and simplifies the deployment.
Patch the deployment:
kubectl -n traefik patch deploy traefik --type='json' -p='[
{"op":"replace","path":"/spec/template/spec/containers/0/livenessProbe","value":{
"tcpSocket":{"port":80},
"initialDelaySeconds":10,
"periodSeconds":10,
"failureThreshold":6
}},
{"op":"replace","path":"/spec/template/spec/containers/0/readinessProbe","value":{
"tcpSocket":{"port":80},
"initialDelaySeconds":3,
"periodSeconds":5,
"failureThreshold":6
}}
]'
Restart after patching:
kubectl -n traefik rollout restart deploy traefik
kubectl -n traefik rollout status deploy traefik
kubectl -n traefik get pods -o wide
Good looks like:
- Traefik stays healthy
- the deployment no longer depends on port
9000 - the pod remains
Running.
Step 9: Verify Traefik on the edge node
Now confirm that Traefik is actually listening on the edge machine.
Run on ctb-edge-1:
ss -lntup | egrep ':80 |:443 '
Expected:
- Traefik is listening on
80 - Traefik is listening on
443.
Test local HTTP on the edge:
curl -I http://127.0.0.1
Good looks like:
- HTTP responds
- a common expected response is
308 Permanent Redirectto HTTPS.
External checks from another machine:
curl -I http://198.51.100.25
curl -vk https://198.51.100.25 2>&1 | head -n 40
getent ahosts kakde.eu | head
curl -vk https://kakde.eu 2>&1 | head -n 60
Also verify that blocked ports are not reachable:
nc -vz -w 3 198.51.100.25 6443
nc -vz -w 3 198.51.100.25 10250
nc -vz -w 3 198.51.100.25 30080
Good looks like:
- HTTP and HTTPS respond on the edge IP
- DNS resolves as expected
- Kubernetes-related ports are not reachable publicly.
Validation checklist
A healthy end state looks like this:
- Traefik runs only on
ctb-edge-1 - Traefik pod is
Running - the edge node listens on ports
80and443 - the edge firewall allows only SSH, WireGuard, HTTP, and HTTPS publicly
- the older conflicting taint is removed
- there is no stuck Pending rollout pod
- port
9000is no longer needed as part of the public path - the cluster remains private behind the edge.
Troubleshooting guide
Problem 1: nftables syntax error
Symptom:
unexpected junk ... iifname != 'eth0'
Cause:
- interface names were single-quoted instead of double-quoted
Fix:
- use
"eth0"instead of'eth0' - or apply the rules through
nft -fas shown above.
Problem 2: Traefik stuck in Pending
Symptom:
0/4 nodes are available ... didn't match node affinity/selector ... untolerated taints
Cause encountered in this project:
- edge node had an extra taint:
homelab.kakde.eu/edge=true:NoSchedule - Traefik did not tolerate it
Fix:
- remove the old taint
- make sure only
ctb-edge-1haskakde.eu/edge=true - keep the intended taint
kakde.eu/edge=true:NoSchedule.
Problem 3: Traefik CrashLoopBackOff
Symptom:
- pod starts, then restarts repeatedly
Correct workflow:
- capture
describe pod - capture current logs
- capture previous logs
- only then decide the fix.
Final root cause found later:
listen tcp :80: bind: permission denied
Fix:
- add
NET_BIND_SERVICEto the container security context.
Problem 4: rollout hangs with a second Pending pod
Symptom:
- a second Traefik pod stays Pending during rollout
Cause:
- host ports are already occupied by the old pod
Fix:
- set
maxSurge: 0 - optionally delete the stuck Pending pod
- restart rollout.
Problem 5: Ingress 404 after Traefik is running
This does not always mean Traefik is broken. The architecture notes explicitly say that a 404 can also come from route problems such as:
- wrong host in Ingress
- wrong service target
- wrong TLS secret
- overlay naming mismatch
- missing resource in the active overlay.
Next steps
Once Traefik is stable, the next clean validation step is to deploy the whoami test application, expose it through a Kubernetes Ingress, and validate HTTP and HTTPS routing through the edge node. The architecture notes identify whoami.kakde.eu as the canonical simple validation app for ingress and TLS testing.
Typical next flow:
- deploy
whoami - expose it through a
ClusterIPservice - create an Ingress for
whoami.kakde.eu - validate HTTP routing
- validate TLS.
Glossary
K3s A lightweight Kubernetes distribution, good for small labs and simpler rebuilds.
Calico The container networking layer used here instead of flannel. It provides pod networking and network policy.
WireGuard The encrypted host-to-host overlay network connecting all four nodes.
Ingress A Kubernetes object that defines how external HTTP or HTTPS traffic reaches internal services.
Ingress controller The software that actually enforces Ingress routing. In this project, that is Traefik.
hostNetwork A setting where the pod uses the host machine’s network namespace and can bind the host’s real ports directly.
Privileged port
A Linux port below 1024, such as 80 or 443, which often requires extra capability to bind.
nftables The modern Linux firewall framework used for the edge guardrail.
Label Metadata on a node or object, used for selection and scheduling.
Taint A scheduling repellent on a node that blocks pods unless they explicitly tolerate it.
Publish a Test App and Enable TLS
Current note This is a historical validation runbook for the test app path. For the current platform setup, start with 01-platform-overview.md and 06-platform-services-step-by-step.md.
Purpose
Before publishing real applications, first prove the ingress path using a tiny test app.
1. Deploy whoami
Run on ms-1:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl apply -f - <<'EOWHOAMI'
apiVersion: v1
kind: Namespace
metadata:
name: apps
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
namespace: apps
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: traefik/whoami:latest
ports:
- name: http
containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: whoami
namespace: apps
spec:
selector:
app: whoami
ports:
- name: http
port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: apps
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
spec:
ingressClassName: traefik
rules:
- host: whoami.kakde.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
number: 80
EOWHOAMI
Verify:
kubectl -n apps get deploy,po,svc,ingress -o wide
kubectl -n apps rollout status deploy/whoami
2. Validate routing from the edge node
Run on ctb-edge-1:
curl -sS -I -H 'Host: whoami.kakde.eu' http://127.0.0.1/
curl -sS -k -I -H 'Host: whoami.kakde.eu' https://127.0.0.1/
If HTTP works but HTTPS returns 404, Traefik is alive but the TLS route is incomplete.
3. Fix the HTTPS route
Run on ms-1:
kubectl -n apps annotate ingress whoami \
traefik.ingress.kubernetes.io/router.tls="true" \
--overwrite
kubectl -n apps annotate ingress whoami \
traefik.ingress.kubernetes.io/router.entrypoints="websecure" \
--overwrite
Retest on ctb-edge-1:
curl -sS -k -I -H 'Host: whoami.kakde.eu' https://127.0.0.1/
curl -sS -k -H 'Host: whoami.kakde.eu' https://127.0.0.1/ | head
4. Install cert-manager
Run on ms-1:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
sudo apt-get update && sudo apt-get install -y helm
helm repo add jetstack https://charts.jetstack.io
helm repo update
kubectl create namespace cert-manager --dry-run=client -o yaml | kubectl apply -f -
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--set installCRDs=true
Verify:
kubectl -n cert-manager get pods
kubectl -n cert-manager rollout status deploy/cert-manager
kubectl -n cert-manager rollout status deploy/cert-manager-webhook
kubectl -n cert-manager rollout status deploy/cert-manager-cainjector
5. Create the Cloudflare API token secret
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
read -s CF_API_TOKEN
echo
kubectl -n cert-manager create secret generic cloudflare-api-token \
--from-literal=api-token="$CF_API_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n cert-manager get secret cloudflare-api-token
6. Create the production ClusterIssuer
kubectl apply -f - <<'EOISSUER'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod-dns01
spec:
acme:
email: a.r.kakde@gmail.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-dns01-private-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
EOISSUER
kubectl get clusterissuer
kubectl describe clusterissuer letsencrypt-prod-dns01 | sed -n '1,160p'
You should also create a staging issuer for testing.
7. Issue the certificate
kubectl -n apps apply -f - <<'EOCERT'
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: whoami-kakde-eu
namespace: apps
spec:
secretName: whoami-kakde-eu-tls
issuerRef:
name: letsencrypt-prod-dns01
kind: ClusterIssuer
dnsNames:
- whoami.kakde.eu
EOCERT
Attach the secret to the Ingress:
kubectl -n apps patch ingress whoami --type='merge' -p '{
"spec": {
"tls": [
{
"hosts": ["whoami.kakde.eu"],
"secretName": "whoami-kakde-eu-tls"
}
]
}
}'
Watch issuance:
kubectl -n apps get certificate,certificaterequest,order,challenge -o wide
kubectl -n apps describe certificate whoami-kakde-eu | sed -n '1,220p'
kubectl -n apps get secret whoami-kakde-eu-tls
8. Validate externally
From an external machine:
curl -sS -I --resolve whoami.kakde.eu:80:198.51.100.25 http://whoami.kakde.eu/
curl -sS -k -I --resolve whoami.kakde.eu:443:198.51.100.25 https://whoami.kakde.eu/
curl -sS -k --resolve whoami.kakde.eu:443:198.51.100.25 https://whoami.kakde.eu/ | head
curl -sS -I https://whoami.kakde.eu/
curl -sS https://whoami.kakde.eu/ | head
Expected result
- HTTP redirects to HTTPS
- HTTPS works with a valid certificate
whoamireturns its normal diagnostic output
Edge-only Traefik → whoami → Let’s Encrypt with cert-manager (Cloudflare DNS-01)
Current note This is a detailed historical deep dive. For the current platform setup, start with 01-platform-overview.md and 06-platform-services-step-by-step.md.
Table of Contents
- Overview
- Architecture
- Design decisions
- Phase 4 workflow (end-to-end)
- Validation checklist
- Troubleshooting
- Glossary
- Learning links
1. Overview
This document explains how the environment reached a stable edge-only ingress setup using Traefik on the public server (ctb-edge-1), how a test app (whoami) was published under whoami.kakde.eu, and how production-grade TLS certificates were issued using cert-manager and Cloudflare DNS-01.
Each step includes:
- Where to run commands (which machine)
- What the commands do
- How to verify success
- Common failure modes and fixes
2. What we built (high-level)
By the end of Phase 4:
-
Traefik is deployed only on the public edge node
ctb-edge-1. -
Traefik listens on host ports 80 and 443 (
*:80and*:443). -
HTTP requests redirect to HTTPS (308 redirect).
-
A demo service
whoamiis deployed inside Kubernetes:- Exposed through Traefik with an Ingress for
whoami.kakde.eu.
- Exposed through Traefik with an Ingress for
-
TLS is handled by cert-manager using Let’s Encrypt certificates.
-
Let’s Encrypt challenges are solved using Cloudflare DNS-01 via a
ClusterIssuer.
3. Architecture (the pieces involved)
Machines involved
-
ctb-edge-1 (public / Contabo)
- Public IP:
198.51.100.25 - WireGuard internal IP:
172.27.15.31 - Runs Traefik and binds host ports 80/443
- Public IP:
-
ms-1 (home LAN, K3s server)
- Runs the Kubernetes control-plane (K3s server)
- Used for
kubectlmanagement
-
(Optional) your laptop / any external machine for real internet tests
Kubernetes components
- Traefik: Ingress Controller (routes HTTP/HTTPS to services)
- whoami: a tiny HTTP server used for testing routing and TLS
- cert-manager: Kubernetes certificate controller
- Let’s Encrypt: certificate authority issuing real TLS certs
- Cloudflare: DNS provider used for DNS-01 challenges
4. Why certain decisions were made
Why “edge-only” Traefik?
Security and simplicity:
- Only one machine is exposed to the internet.
- Home LAN nodes remain private and can be firewalled tightly.
- All inbound HTTP/HTTPS traffic terminates at the edge and then routes to Kubernetes.
Why we moved to cert-manager (Cloudflare DNS-01) instead of Traefik ACME?
Both work, but cert-manager is often preferred because:
- It standardizes TLS across the cluster (works with any Ingress Controller).
- DNS-01 avoids problems with HTTP challenge routing, NAT, or port ownership.
- It stores certs as Kubernetes Secrets (clean, auditable, GitOps-friendly).
- It avoids Traefik ACME file permission issues (the document observed “permission denied” on
/data/acme-*.jsonwhen using Traefik ACME).
5. Phase 4 workflow (end-to-end)
Prerequisites
-
You have working
kubectlaccess (commonly from ms-1):export KUBECONFIG=/etc/rancher/k3s/k3s.yaml -
Traefik is already deployed edge-only and stable.
5.1 Verify Traefik on the edge
Where: ctb-edge-1
Check Traefik is listening on host ports 80/443
ss -lntp | egrep ':(80|443)\b' || true
Expected result:
- Something like Traefik listening on
*:80and*:443.
Confirm HTTP redirects to HTTPS
curl -I http://127.0.0.1/
Expected result:
HTTP/1.1 308 Permanent Redirectwith aLocation: https://...
5.2 Fix Traefik rollout deadlock (what happened + how it was resolved)
Problem observed in the project: Traefik rollout got stuck because a rolling update tried to create a “surge” pod that could not schedule (host ports 80/443 are exclusive). With hostPort-based ingress, surge pods can deadlock.
Fix that worked:
-
Set Deployment strategy to:
maxSurge=0(so no additional pod is created), orRecreate(kill old pod then start new)
-
Delete the pending surge pod.
-
Remove admin port 9000 hostPort exposure.
-
Change readiness/liveness probes to TCP socket on port 80.
-
Rollout completed; Traefik stayed stable.
Where: ms-1 (kubectl machine)
Verify Traefik rollout status
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl -n traefik rollout status deploy/traefik
kubectl -n traefik get pods -o wide
If rollout is stuck (example workflow):
kubectl -n traefik get pods -o wide
# Identify the pending/un-schedulable "surge" pod, then delete it:
kubectl -n traefik delete pod <PENDING_POD_NAME>
How to apply the strategy change (one-time edit):
kubectl -n traefik edit deploy/traefik
What to change inside the editor (conceptually):
- Set rolling update
maxSurge: 0 - Or set
strategy.type: Recreate - Ensure probes are
tcpSocketrather than HTTP to admin ports - Remove hostPort 9000 if it was exposed
Verification:
kubectl -n traefik rollout status deploy/traefik
kubectl -n traefik logs deploy/traefik --tail=50
5.3 Deploy whoami (Deployment + Service + Ingress)
This creates a simple HTTP service so you can verify routing and TLS end-to-end.
Where: ms-1
Apply manifests
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: apps
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
namespace: apps
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: traefik/whoami:latest
ports:
- name: http
containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: whoami
namespace: apps
spec:
selector:
app: whoami
ports:
- name: http
port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: apps
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
spec:
ingressClassName: traefik
rules:
- host: whoami.kakde.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
number: 80
EOF
Verify the app is running
kubectl -n apps get deploy,po,svc,ingress -o wide
kubectl -n apps rollout status deploy/whoami
Expected result:
whoamipod isRunning- Service exists
- Ingress exists
5.4 Internal tests on the edge (Host header)
This is critical because it proves Traefik routing works locally before DNS/public testing.
Where: ctb-edge-1
HTTP request with Host header (expected redirect)
curl -sS -I -H 'Host: whoami.kakde.eu' http://127.0.0.1/
Expected:
308 Permanent Redirectto HTTPS
HTTPS request with Host header (initial failure mode)
curl -sS -k -I -H 'Host: whoami.kakde.eu' https://127.0.0.1/
Initial failure observed earlier in the project:
404on HTTPS — meaning Traefik had no TLS router for that host.
5.4.1 Fix: “Make whoami route on HTTPS (stop the 404)”
The fix used in the project was to explicitly enable TLS routing for the Ingress.
Where: ms-1
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl -n apps annotate ingress whoami \
traefik.ingress.kubernetes.io/router.tls="true" \
--overwrite
kubectl -n apps annotate ingress whoami \
traefik.ingress.kubernetes.io/router.entrypoints="websecure" \
--overwrite
Now re-test HTTPS on the edge:
Where: ctb-edge-1
curl -sS -k -I -H 'Host: whoami.kakde.eu' https://127.0.0.1/
curl -sS -k -H 'Host: whoami.kakde.eu' https://127.0.0.1/ | head
Expected (and achieved in the project):
HTTP/2 200- Body shows
Hostname, request headers, etc.
This confirms:
- Traefik terminates TLS successfully
- Routing works to the Kubernetes service/pod
5.5 External tests (curl --resolve)
This lets you test public routing even before DNS is fully propagated.
Where: your laptop or any external machine
# HTTP should redirect
curl -sS -I --resolve whoami.kakde.eu:80:198.51.100.25 http://whoami.kakde.eu/
# HTTPS route (use -k until you have a real cert)
curl -sS -k -I --resolve whoami.kakde.eu:443:198.51.100.25 https://whoami.kakde.eu/
curl -sS -k --resolve whoami.kakde.eu:443:198.51.100.25 https://whoami.kakde.eu/ | head
5.6 TLS: cert-manager + ClusterIssuer + Cloudflare DNS-01 (recommended)
Cloudflare DNS token verification (what you did)
You verified the token works using:
curl "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/tokens/verify" \
-H "Authorization: Bearer <TOKEN>"
Success means:
- The token is active
- cert-manager can use it (assuming token permissions include DNS edits)
5.7 Install cert-manager
Where: ms-1
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
# Install Helm if needed (Ubuntu):
sudo apt-get update && sudo apt-get install -y helm
helm repo add jetstack https://charts.jetstack.io
helm repo update
kubectl create namespace cert-manager --dry-run=client -o yaml | kubectl apply -f -
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--set installCRDs=true
Verify:
kubectl -n cert-manager get pods
kubectl -n cert-manager rollout status deploy/cert-manager
kubectl -n cert-manager rollout status deploy/cert-manager-webhook
kubectl -n cert-manager rollout status deploy/cert-manager-cainjector
5.8 Create Cloudflare API token Secret
Where: ms-1
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
read -s CF_API_TOKEN
echo
kubectl -n cert-manager create secret generic cloudflare-api-token \
--from-literal=api-token="$CF_API_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n cert-manager get secret cloudflare-api-token
5.9 Create ClusterIssuers (staging + prod)
You had an existing working ClusterIssuer template. Here’s the same structure (Cloudflare DNS-01, token-based):
Where: ms-1
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod-dns01
spec:
acme:
email: a.r.kakde@gmail.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-dns01-private-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
EOF
kubectl get clusterissuer
Tip (best practice): also create a staging issuer:
-
Same YAML but:
name: letsencrypt-staging-dns01server: https://acme-staging-v02.api.letsencrypt.org/directory
Verify issuers:
kubectl describe clusterissuer letsencrypt-prod-dns01 | sed -n '1,160p'
5.10 Issue a certificate for whoami.kakde.eu and attach it to the Ingress
Create a Certificate resource
Where: ms-1
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl -n apps apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: whoami-kakde-eu
namespace: apps
spec:
secretName: whoami-kakde-eu-tls
issuerRef:
name: letsencrypt-prod-dns01
kind: ClusterIssuer
dnsNames:
- whoami.kakde.eu
EOF
Add TLS to the Ingress (use the secret created by cert-manager)
Where: ms-1
kubectl -n apps patch ingress whoami --type='merge' -p '{
"spec": {
"tls": [
{
"hosts": ["whoami.kakde.eu"],
"secretName": "whoami-kakde-eu-tls"
}
]
}
}'
Watch issuance progress
Where: ms-1
kubectl -n apps get certificate,certificaterequest,order,challenge -o wide
kubectl -n apps describe certificate whoami-kakde-eu | sed -n '1,220p'
kubectl -n apps get secret whoami-kakde-eu-tls
Final external TLS verification (no -k)
Where: external machine
curl -sS -I https://whoami.kakde.eu/
curl -sS https://whoami.kakde.eu/ | head
6. Validation checklist (“good looks like”)
Traefik edge validation
On ctb-edge-1:
ss -lntpshows Traefik on*:80and*:443curl -I http://127.0.0.1/returns308redirect to HTTPS
whoami routing validation
On ctb-edge-1:
curl -k -H 'Host: whoami.kakde.eu' https://127.0.0.1/returns200and whoami output
From outside:
curl -I https://whoami.kakde.eu/returns success (no-k)- Browser shows a valid Let’s Encrypt cert for
whoami.kakde.eu
cert-manager validation
On ms-1:
kubectl -n cert-manager get podsall Runningkubectl get clusterissuershows issuers presentkubectl -n apps get certificateshowsReady=True- TLS secret exists:
whoami-kakde-eu-tls
7. Troubleshooting and quick fixes
Symptom: HTTP works but HTTPS returns 404
Cause: Ingress has no TLS router for that host.
Fix: Add Traefik TLS annotation and/or spec.tls.
Commands:
kubectl -n apps annotate ingress whoami traefik.ingress.kubernetes.io/router.tls="true" --overwrite
kubectl -n apps patch ingress whoami --type='merge' -p '{"spec":{"tls":[{"hosts":["whoami.kakde.eu"],"secretName":"whoami-kakde-eu-tls"}]}}'
Symptom: Traefik rollout stuck / Pending pod during upgrades
Cause: host ports 80/443 can’t be held by two pods at once; rolling update creates surge pod.
Fix: use maxSurge=0 or Recreate, and delete pending surge pod.
Symptom: cert-manager Challenge stuck / never becomes Ready
Common causes:
- Cloudflare token missing permissions
- Wrong zone / DNS name mismatch
- DNS propagation delay
- Wrong issuer reference name
Useful commands:
kubectl -n apps describe challenge -l acme.cert-manager.io/order-name
kubectl -n cert-manager logs deploy/cert-manager --tail=200
8. Decisions made, assumptions, and unresolved gaps
Decisions made
- Traefik runs only on ctb-edge-1 and binds host ports 80/443.
- Rollout deadlock was solved using maxSurge=0 / Recreate (hostPort safe rollout).
whoamiwas used as the first “known good” ingress test service.- TLS was moved to cert-manager + Cloudflare DNS-01 for production-grade certificate management.
Assumptions
- DNS A record
whoami.kakde.eu → 198.51.100.25exists. - cert-manager is allowed to update DNS records via Cloudflare token.
- Traefik is configured to watch Ingress resources and use the relevant IngressClass.
Unresolved gaps (next phase topics)
- Full firewall policy review for all nodes (edge-only public exposure)
- NetworkPolicies baseline (default-deny + explicit allow)
- Pod Security Admission (PSA) baseline/restricted
- Backup strategy (k3s state, manifests, secrets)
- Monitoring/logging approach
9. Glossary (simple definitions)
- Ingress Controller (Traefik): a Kubernetes component that accepts HTTP/HTTPS traffic and routes it to the right service.
- Ingress: a Kubernetes object that defines rules like “host X goes to service Y”.
- TLS: encryption for HTTPS.
- Let’s Encrypt: free certificate authority that issues trusted TLS certificates.
- cert-manager: Kubernetes controller that automatically requests/renews certificates and stores them in Secrets.
- ClusterIssuer: a cluster-wide cert-manager configuration that defines how to get certs (e.g., Let’s Encrypt + DNS provider).
- DNS-01 challenge: validation method where Let’s Encrypt checks a DNS TXT record to prove domain ownership.
- Cloudflare API token: credential cert-manager uses to create DNS records automatically.
10. Next phase preview + next-document prompt
What “next phase” means here
You’ve now got:
- stable edge ingress
- a working app behind it
- automated TLS issuance via cert-manager
The next phase is about hardening and operational readiness:
- firewall rules (edge-only open ports)
- lock down SSH
- network policy defaults
- RBAC sanity checks
- monitoring/backups/runbooks
Prompt for your next document (copy/paste)
Homelab-0 Phase 5: Traefik is stable edge-only on ctb-edge-1 (host ports 80/443). whoami.kakde.eu routes correctly and has a valid Let’s Encrypt certificate issued by cert-manager using Cloudflare DNS-01 ClusterIssuer. Next I want to harden and productionize the setup: (1) audit exposed ports and firewall rules on all 4 nodes, ensure only edge has 80/443 public, (2) restrict SSH to my admin IP(s), (3) ensure K3s API is not publicly exposed, (4) apply a baseline NetworkPolicy strategy (default deny + required allows), (5) set Pod Security Admission (baseline or restricted) per namespace, (6) quick observability (logs/metrics) and backup strategy. Please provide step-by-step commands with STOP/GO checkpoints, and verification for each change.
11. Learning links (official / high-quality)
- Traefik Kubernetes Ingress docs: https://doc.traefik.io/traefik/providers/kubernetes-ingress/
- cert-manager docs: https://cert-manager.io/docs/
- cert-manager + Cloudflare DNS-01: https://cert-manager.io/docs/configuration/acme/dns01/cloudflare/
- Let’s Encrypt challenge types (HTTP-01 vs DNS-01): https://letsencrypt.org/docs/challenge-types/
- Kubernetes Ingress concept: https://kubernetes.io/docs/concepts/services-networking/ingress/
- Kubernetes NetworkPolicies: https://kubernetes.io/docs/concepts/services-networking/network-policies/
- Pod Security Admission (PSA): https://kubernetes.io/docs/concepts/security/pod-security-admission/
Argo CD Deployment Guide for This Homelab Cluster
Current note This is a detailed historical deep dive. For the current Argo CD path, start with 01-platform-overview.md and 06-platform-services-step-by-step.md.
Table of Contents
- Overview
- What This Document Covers
- Goal
- Current Cluster Context
- Important Design Decisions
- Concepts Explained for Beginners
- Step-by-Step Implementation
- Validation Checklist
- Troubleshooting
- Mistakes and Corrections
- Operational and Security Notes
- Next Steps
- Glossary
- Further Learning
Overview
This document explains how Argo CD was deployed on the current homelab Kubernetes cluster in a way that matches the cluster’s existing design patterns.
The deployment was not treated as a brand-new, generic Kubernetes tutorial. Instead, it was adapted to the cluster that already existed:
- Argo CD was installed into its own namespace
- its workloads were scheduled onto the
wk-2node - it was exposed using the same standard Kubernetes Ingress pattern already used for existing applications like notebook and portfolio
- TLS was terminated by Traefik at the edge
- the existing cert-manager issuer pattern was reused
What This Document Covers
This document reconstructs the discussion and actions taken in the document and turns them into a self-contained technical guide. It explains:
- what was deployed
- why those decisions were made
- which options were considered and rejected
- which commands were run
- where those commands should be run
- how to verify success
- how to troubleshoot common issues
Goal
The goal was to deploy Argo CD on the existing cluster and make sure it follows the same cluster conventions as the already deployed applications.
More specifically, the desired outcome was:
- install Argo CD in namespace
argocd - schedule Argo CD workloads onto
wk-2 - expose the Argo CD web UI using Traefik
- use a standard Kubernetes Ingress
- reuse the same TLS pattern as notebook and portfolio
- keep the deployment operationally consistent with the rest of the homelab
Current Cluster Context
The cluster context inferred from the document is:
-
Kubernetes distribution: K3s
-
ms-1is the main server node wherekubectlcommands were being run -
wk-2is the node selected to host the Argo CD workloads -
Traefik is the ingress controller
-
existing applications such as notebook and portfolio already use:
IngressingressClassName: traefik- cert-manager
- TLS secrets managed through cert-manager
-
DNS already points
argocd.kakde.euto the public edge -
the public edge is already working
-
the Argo CD deployment ended in a healthy state
Important Design Decisions
Several important design choices were made during the conversation.
1. Use wk-2 for Argo CD workloads
The request was to use wk-2 for the deployment. In Kubernetes, this does not mean “install Argo CD separately on that machine” in the traditional sense. It means the Argo CD pods should be scheduled onto that node.
This was achieved through a node label and a nodeSelector.
2. Use standard Kubernetes Ingress, not Traefik IngressRoute
At one point, exposing Argo CD through Traefik’s IngressRoute CRD was considered.
That was rejected in favor of standard Ingress because:
- notebook and portfolio already use standard
Ingress - consistency matters
- standard Kubernetes objects are easier to understand for beginners
- this keeps manifests more portable
- the cluster already follows this pattern successfully
3. Reuse the same TLS pattern as notebook and portfolio
The existing notebook ingress used:
ingressClassName: traefikcert-manager.io/cluster-issuer: letsencrypt-prod-dns01- Traefik annotations for
websecureand TLS - a dedicated secret name for TLS
So Argo CD was configured to use exactly the same approach.
4. Disable Argo CD internal TLS
Argo CD’s argocd-server can serve HTTPS itself, but in this cluster, TLS is already terminated at Traefik.
So the Argo CD server was set to:
server.insecure: "true"
This means:
- Argo CD serves plain HTTP inside the cluster
- Traefik handles HTTPS at the ingress edge
- this matches how reverse-proxy-based deployments are commonly done
5. Keep browser UI public, treat CLI carefully
Argo CD has two major access patterns:
- browser UI
- CLI
The UI works cleanly behind standard Ingress.
The CLI can be trickier because Argo CD uses gRPC in addition to HTTP. Because of that, the deployment guidance noted that CLI access may need:
--grpc-web- or
kubectl port-forward
Why Argo CD Was Deployed This Way
This deployment was intentionally designed to match the cluster’s existing operational habits rather than introduce a new style just for Argo CD.
That is an important infrastructure principle:
A good cluster is not only functional. It is also consistent.
If notebook and portfolio already use a certain ingress and certificate pattern, then using the same pattern for Argo CD makes the environment:
- easier to operate
- easier to document
- easier to troubleshoot
- easier for a beginner to understand
Concepts Explained for Beginners
What is Argo CD?
Argo CD is a GitOps tool for Kubernetes.
GitOps means:
- application definitions live in Git
- Argo CD watches Git
- Argo CD makes the cluster match the desired state from Git
Instead of manually applying YAML files forever, Argo CD can continuously manage applications for the cluster.
What is a Kubernetes namespace?
A namespace is a logical area inside the cluster used to separate resources.
Examples:
- one namespace for Argo CD
- another namespace for notebook
- another namespace for portfolio
This keeps applications organized.
What is a node?
A node is one machine in the Kubernetes cluster.
Examples from this setup:
ms-1wk-1wk-2
Pods run on nodes.
What is a label?
A label is a key-value tag attached to a Kubernetes object.
Example:
kubectl label node wk-2 workload=argocd --overwrite
This adds the label:
- key:
workload - value:
argocd
What is a nodeSelector?
A nodeSelector tells Kubernetes to run a pod only on nodes that have a matching label.
If a pod has:
nodeSelector:
workload: argocd
then Kubernetes will only schedule it onto nodes with:
workload=argocd
What is an Ingress?
An Ingress is a Kubernetes object that defines how external HTTP/HTTPS traffic reaches services inside the cluster.
In this setup:
- browser visits
https://argocd.kakde.eu - Traefik receives that request
- Traefik uses the Ingress rules
- traffic is sent to the
argocd-serverservice
What is Traefik?
Traefik is the ingress controller used by the cluster.
It watches Kubernetes Ingress objects and routes web traffic to the right services.
What is TLS?
TLS is what gives HTTPS encryption.
It protects traffic between a user’s browser and the public endpoint.
What is cert-manager?
cert-manager is a Kubernetes tool that automatically obtains and renews TLS certificates.
In this cluster, it uses the issuer:
letsencrypt-prod-dns01
What does server.insecure: "true" mean?
It means Argo CD’s internal server does not serve HTTPS itself.
That sounds scary at first, but in this design it is intentional because:
- internal traffic stays inside the cluster
- external TLS is handled by Traefik
- the browser still uses HTTPS publicly
Deployment Flow Summary
The deployment sequence was:
- verify the cluster and nodes
- label
wk-2 - install Argo CD into the
argocdnamespace - confirm Argo CD pods started
- patch Argo CD workloads with
nodeSelector - restart them so they move to
wk-2 - verify all pods now run on
wk-2 - patch
argocd-cmd-params-cmto setserver.insecure: "true" - restart
argocd-server - inspect existing notebook ingress
- create a matching Argo CD ingress
- verify DNS, certificate issuance, ingress health, and external access
- retrieve the initial admin password
- confirm healthy access
Step-by-Step Implementation
Step 1: Verify the Cluster
Purpose
Before changing anything, confirm the cluster is reachable and the nodes exist.
Where to run
Run on a machine that already has working kubectl access to the cluster.
In the document, commands were run from:
ms-1
Commands
kubectl get nodes -o wide
kubectl get pods -A
What these commands do
kubectl get nodes -o wideshows cluster nodes and more details such as IPskubectl get pods -Ashows pods in all namespaces
Good result
- all expected nodes are present
wk-2is inReadystate- the cluster responds normally
Step 2: Label wk-2 for Argo CD
Purpose
Argo CD workloads need to be scheduled onto wk-2. To do that, the node must receive a label.
Where to run
Run on ms-1 or any machine with working kubectl.
Commands
kubectl label node wk-2 workload=argocd --overwrite
kubectl get nodes --show-labels | grep wk-2
What these commands do
- the first command adds or updates the label
workload=argocdon nodewk-2 - the second command verifies the label is present
Good result
The output for wk-2 includes:
workload=argocd
Step 3: Install Argo CD
Purpose
Install the standard Argo CD components into a dedicated namespace.
Where to run
Run on ms-1 or any machine with kubectl access.
Commands
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
What these commands do
- the first command creates the
argocdnamespace - the second applies the official Argo CD installation manifest into that namespace
Verification
kubectl -n argocd get deploy,statefulset,svc
kubectl -n argocd get pods -o wide
Good result
The output should show resources such as:
argocd-serverargocd-repo-serverargocd-dex-serverargocd-redisargocd-applicationset-controllerargocd-notifications-controllerargocd-application-controller
Initially, these may run on another node such as wk-1. That is normal before pinning.
Step 4: Pin Argo CD Workloads to wk-2
Purpose
Make Kubernetes place Argo CD pods on wk-2.
Important note
A first attempt used:
kubectl patch ... --all
That failed in this environment because this kubectl build did not support that flag in the way it was being used.
So the fix was to patch each workload explicitly, one by one.
Where to run
Run on ms-1.
Commands
Patch Deployments
kubectl -n argocd patch deployment argocd-applicationset-controller --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload":"argocd"}}}}}'
kubectl -n argocd patch deployment argocd-dex-server --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload":"argocd"}}}}}'
kubectl -n argocd patch deployment argocd-notifications-controller --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload":"argocd"}}}}}'
kubectl -n argocd patch deployment argocd-redis --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload":"argocd"}}}}}'
kubectl -n argocd patch deployment argocd-repo-server --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload":"argocd"}}}}}'
kubectl -n argocd patch deployment argocd-server --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload":"argocd"}}}}}'
Patch StatefulSet
kubectl -n argocd patch statefulset argocd-application-controller --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload":"argocd"}}}}}'
Verify the nodeSelector was added
kubectl -n argocd get deployment argocd-applicationset-controller -o yaml | grep -A5 nodeSelector
kubectl -n argocd get deployment argocd-server -o yaml | grep -A5 nodeSelector
kubectl -n argocd get statefulset argocd-application-controller -o yaml | grep -A5 nodeSelector
Good result
You should see:
nodeSelector:
workload: argocd
Restart workloads to move the pods
kubectl -n argocd rollout restart deployment argocd-applicationset-controller
kubectl -n argocd rollout restart deployment argocd-dex-server
kubectl -n argocd rollout restart deployment argocd-notifications-controller
kubectl -n argocd rollout restart deployment argocd-redis
kubectl -n argocd rollout restart deployment argocd-repo-server
kubectl -n argocd rollout restart deployment argocd-server
kubectl -n argocd rollout restart statefulset argocd-application-controller
Watch the pods move
kubectl -n argocd get pods -o wide -w
Press Ctrl+C when stable.
Final verification
kubectl -n argocd get pods -o wide
Good result
All main Argo CD pods should show:
NODE wk-2
The user later confirmed this was verified successfully.
Step 5: Disable Internal TLS on argocd-server
Purpose
Since Traefik handles external HTTPS, Argo CD itself should serve HTTP internally.
Where to run
Run on ms-1.
Command
kubectl -n argocd patch configmap argocd-cmd-params-cm --type merge -p '{"data":{"server.insecure":"true"}}'
What it does
This updates the ConfigMap used by Argo CD’s server process and sets:
server.insecure: "true"
Restart the Argo CD server
kubectl -n argocd rollout restart deployment argocd-server
kubectl -n argocd rollout status deployment argocd-server
kubectl -n argocd get cm argocd-cmd-params-cm -o yaml | grep server.insecure
Good result
The output includes:
server.insecure: "true"
The user confirmed this worked successfully.
Step 6: Reuse the Existing Ingress Pattern
Purpose
Before creating the Argo CD ingress, inspect an existing app ingress to match its structure.
Existing notebook ingress pattern
The existing notebook app ingress used:
ingressClassName: traefikcert-manager.io/cluster-issuer: letsencrypt-prod-dns01kubernetes.io/ingress.class: traefiktraefik.ingress.kubernetes.io/router.entrypoints: websecuretraefik.ingress.kubernetes.io/router.tls: "true"- a host-specific TLS secret
Example inspection command
kubectl -n apps-prod get ingress notebook-app -o yaml
Decision made
Argo CD should follow the same pattern.
Step 7: Create the Argo CD Ingress
Purpose
Expose Argo CD’s web interface publicly using the same Traefik + cert-manager Ingress model already used in the cluster.
Where to run
Run on ms-1.
Manifest
Create the file:
cat > /root/deployment/argocd-ingress.yaml <<'EOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd-server
namespace: argocd
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod-dns01
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
rules:
- host: argocd.kakde.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
number: 80
tls:
- hosts:
- argocd.kakde.eu
secretName: argocd-kakde-eu-tls
EOF
Apply it:
kubectl apply -f /root/deployment/argocd-ingress.yaml
kubectl -n argocd get ingress
kubectl -n argocd describe ingress argocd-server
Line-by-line explanation
kind: Ingress
Defines a Kubernetes ingress resource.
namespace: argocd
Places the ingress in the Argo CD namespace.
cert-manager.io/cluster-issuer: letsencrypt-prod-dns01
Tells cert-manager which issuer to use for generating the TLS certificate.
kubernetes.io/ingress.class: traefik
Legacy-style annotation indicating the ingress controller.
traefik.ingress.kubernetes.io/router.entrypoints: websecure
Tells Traefik to expose this route on the secure HTTPS entrypoint.
traefik.ingress.kubernetes.io/router.tls: "true"
Tells Traefik to use TLS for this route.
ingressClassName: traefik
The modern Kubernetes field that explicitly binds the Ingress to the Traefik controller.
host: argocd.kakde.eu
The public hostname for the Argo CD UI.
service.name: argocd-server
The internal service receiving the traffic.
port.number: 80
Traffic is sent to service port 80 because Argo CD internal TLS was disabled.
secretName: argocd-kakde-eu-tls
The Kubernetes secret where cert-manager stores the generated TLS certificate.
Step 8: Verify Certificate and DNS
Purpose
Confirm that DNS is correct and cert-manager successfully issued the certificate.
DNS check
Run from a machine that can resolve public DNS, such as ms-1 or a local workstation.
dig +short argocd.kakde.eu
Good result
The output should resolve to the public edge IP.
The user confirmed DNS was correct.
Certificate verification
kubectl -n argocd get certificate,certificaterequest,order,challenge
kubectl -n argocd get secret argocd-kakde-eu-tls
Good result
- certificate resources appear healthy
- the TLS secret exists
The user confirmed all of this was healthy.
Step 9: Access the Web UI
Purpose
Confirm the Argo CD web interface is reachable over HTTPS.
From a client machine
For example, from a Mac or Linux workstation:
curl -Ik https://argocd.kakde.eu
Good result
Expected HTTP codes include:
200301302
Any of these usually indicates that Traefik and Argo CD are responding correctly.
Then open in a browser:
https://argocd.kakde.eu
The user confirmed this verification passed.
Step 10: Get the Initial Admin Password
Purpose
Argo CD creates an initial admin password stored in a Kubernetes secret.
Where to run
Run on ms-1 or any machine with kubectl.
Command
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo
What it does
- fetches the secret
- extracts the
passwordfield - decodes it from base64
- prints it as plain text
Login credentials
- username:
admin - password: output of the command above
After first login, the password should be changed.
Step 11: CLI Access
Purpose
Access Argo CD from the command line.
Important note
The Argo CD CLI may need special handling because Argo CD uses gRPC in addition to HTTP.
Option 1: Try --grpc-web
Run from the machine where the Argo CD CLI is installed:
argocd login argocd.kakde.eu --grpc-web
Option 2: Use port-forward
This is often the most reliable option for homelabs.
Run on ms-1:
kubectl -n argocd port-forward svc/argocd-server 8080:80
Then on the same machine or another shell:
argocd login localhost:8080 --insecure
Good result
The CLI authenticates successfully.
Quick Reinstall Reference
If you need to reinstall Argo CD from scratch, the scripted path is:
bash k8s-cluster/platform/argocd/install-argocd.sh
bash k8s-cluster/platform/argocd/configure-argocd.sh
Those two scripts cover everything the step-by-step section above explains manually: installation, node pinning, internal TLS disable, ingress, and Application objects.
Validation Checklist
A beginner can use this checklist to confirm the deployment is truly complete.
Argo CD installation
argocdnamespace exists- deployments and statefulset exist
- services exist
Scheduling
wk-2has labelworkload=argocd- Argo CD workloads contain
nodeSelector - Argo CD pods are running on
wk-2
Server configuration
argocd-cmd-params-cmcontainsserver.insecure: "true"argocd-serverrestarted successfully
Ingress
- Argo CD ingress exists in namespace
argocd - ingress uses
ingressClassName: traefik - host is
argocd.kakde.eu
Certificate and DNS
- DNS resolves correctly
- cert-manager objects are healthy
- TLS secret exists
curl -Ik https://argocd.kakde.eusucceeds
Access
- browser opens Argo CD UI
- initial admin password can be retrieved
- CLI works through
--grpc-webor port-forward
Troubleshooting
Problem: kubectl patch --all failed
Symptom
An attempted patch command returned:
error: unknown flag: --all
Cause
This kubectl environment did not support the command form being used.
Fix
Patch each deployment and statefulset individually.
Problem: Argo CD pods are healthy but run on wk-1
Cause
Argo CD was installed before scheduling rules were applied.
Fix
- label
wk-2 - add
nodeSelector - restart workloads
Check
kubectl -n argocd get pods -o wide
Problem: Pod stays Pending after adding nodeSelector
Possible causes
- label not present on
wk-2 - not enough resources on
wk-2 - taints on
wk-2
Commands
kubectl get nodes --show-labels | grep wk-2
kubectl describe node wk-2
kubectl -n argocd describe pod <pod-name>
Problem: Argo CD UI does not load over HTTPS
Possible causes
- ingress not created correctly
- DNS not pointing to the public edge
- cert-manager failed to issue certificate
- Traefik not reading the ingress
- wrong service port in ingress
Checks
kubectl -n argocd get ingress
kubectl -n argocd describe ingress argocd-server
dig +short argocd.kakde.eu
kubectl -n argocd get certificate,certificaterequest,order,challenge
kubectl -n argocd get secret argocd-kakde-eu-tls
curl -Ik https://argocd.kakde.eu
Problem: Browser works but CLI has issues
Cause
Argo CD CLI may need gRPC-specific handling.
Fix options
Try:
argocd login argocd.kakde.eu --grpc-web
Or use port-forward:
kubectl -n argocd port-forward svc/argocd-server 8080:80
argocd login localhost:8080 --insecure
Mistakes and Corrections
This section is important because it teaches beginners that infrastructure work often involves small corrections.
1. Initial recommendation considered IngressRoute
At first, a Traefik-native IngressRoute approach was suggested.
Why that changed
The user clarified that notebook and portfolio already use normal Kubernetes Ingress, and they wanted similar design decisions and deployment patterns.
Final decision
Use standard Ingress with ingressClassName: traefik.
2. Initial bulk patching approach failed
The attempted use of kubectl patch --all failed.
Final fix
Patch resources individually.
This turned out to be safer and clearer anyway.
3. Argo CD workloads initially ran on wk-1
That was expected after installation because node scheduling had not yet been constrained.
Final fix
- label
wk-2 - patch workloads
- restart them
- verify movement to
wk-2
Operational and Security Notes
- Argo CD is a core platform service. The hostname
argocd.kakde.eushould remain stable. - Future app deployments should be managed through Argo CD rather than manual
kubectl apply. - If node labels are changed later, Argo CD scheduling may break.
- Public HTTPS is terminated at Traefik. Argo CD internal TLS is disabled on purpose, but only inside the cluster.
- The default
adminpassword must be changed after first login. - CLI access over port-forward is often safer for administrative use than depending only on public access.
Assumptions and Open Questions
The following items were either assumed or not fully explored in the document.
Assumptions
- Traefik is already installed and healthy
- cert-manager is already installed and healthy
letsencrypt-prod-dns01is already working- DNS for
argocd.kakde.euis already configured correctly - the Argo CD CLI is installed where needed
Open questions
- whether the user wants Argo CD to manage a single application first or a full app-of-apps structure
- whether SSO will be configured later
- whether the initial admin secret should be deleted after password rotation
- whether Argo CD should be further hardened through RBAC, repository credentials, and project restrictions
Next Steps
Now that Argo CD is healthy, the next logical steps are:
- log into the UI
- change the admin password
- connect a Git repository
- create the first Argo CD
Application - optionally create an app-of-apps bootstrap structure
- define projects, RBAC, and repo credentials
- move existing workloads such as notebook or portfolio under GitOps management if desired
A very practical next milestone would be:
- create one simple Git-managed application in Argo CD
- sync it manually once
- verify that Argo CD can reconcile it properly
Glossary
Terms specific to this Argo CD deployment. For standard Kubernetes terms, see the official glossary.
Argo CD
A GitOps continuous delivery tool for Kubernetes. It watches a Git repository and reconciles cluster state to match what is declared there.
Application (Argo CD)
A custom resource that tells Argo CD which Git repo path to watch and which cluster namespace to deploy into.
ApplicationSet
An Argo CD CRD that generates multiple Application objects from a template. Used for managing many apps with a shared pattern.
server.insecure
A configuration flag in argocd-cmd-params-cm that disables internal TLS on argocd-server. Required in this homelab because Traefik terminates TLS at the edge; double TLS would cause connection failures.
--grpc-web
A flag for the Argo CD CLI that tunnels gRPC over HTTP/1.1. Needed when connecting through a reverse proxy like Traefik that does not natively support HTTP/2 gRPC.
nodeSelector (workload: argocd)
The scheduling rule that pins all Argo CD pods to wk-2. This gives Argo CD predictable placement and keeps it off the edge node and the database worker.
Further Learning
These are good official or high-quality places to study the main tools and concepts involved:
- Kubernetes basics: https://kubernetes.io/docs/tutorials/kubernetes-basics/
- Kubernetes Ingress: https://kubernetes.io/docs/concepts/services-networking/ingress/
- Argo CD documentation: https://argo-cd.readthedocs.io/
- Argo CD getting started: https://argo-cd.readthedocs.io/en/stable/getting_started/
- Traefik Kubernetes Ingress provider: https://doc.traefik.io/traefik/providers/kubernetes-ingress/
- cert-manager documentation: https://cert-manager.io/docs/
- K3s documentation: https://docs.k3s.io/
Final State Summary
At the end of this process, the cluster had the following Argo CD setup:
- namespace:
argocd - workloads pinned to node:
wk-2 - public hostname:
argocd.kakde.eu - ingress controller: Traefik
- ingress type: standard Kubernetes
Ingress - certificate issuer:
letsencrypt-prod-dns01 - TLS secret:
argocd-kakde-eu-tls argocd-serverinternal TLS: disabled withserver.insecure: "true"- health status: verified healthy by the user
Harden Public Exposure on the Edge Node
Current note This is a supporting hardening runbook. For the main current build path, start with 06-platform-services-step-by-step.md and use 16-operations-and-recovery.md for day-2 checks.
Purpose
Reduce the public attack surface on ctb-edge-1 and verify what is really visible from outside.
Important lesson
Do not trust a self-scan from the edge node to its own public IP.
A self-scan can be misleading because the route can loop back locally.
Always verify public exposure from another host, for example ms-1.
1. Run the audit block on all nodes
TS="$(date -u +%Y%m%dT%H%M%SZ)"
BK="/root/backup-${TS}-phase5A-firewall"
mkdir -p "$BK"
echo "### HOST" | tee "$BK/00-host.txt"
hostname -f | tee -a "$BK/00-host.txt"
uname -a | tee -a "$BK/00-host.txt"
timedatectl status | sed -n '1,12p' | tee -a "$BK/00-host.txt"
echo "### NET" | tee "$BK/10-net.txt"
ip -br addr | tee "$BK/ip-br-addr.txt"
ip route show table all | tee "$BK/ip-route-all.txt"
ip rule show | tee "$BK/ip-rule.txt"
sysctl net.ipv4.ip_forward | tee "$BK/sysctl-ip_forward.txt"
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter 2>/dev/null | tee "$BK/sysctl-rp_filter.txt" || true
echo "### LISTENING PORTS" | tee "$BK/20-ports.txt"
ss -lntup | tee "$BK/ss-tcp.txt"
ss -lnup | tee "$BK/ss-udp.txt"
echo "### WIREGUARD" | tee "$BK/30-wireguard.txt"
wg show 2>/dev/null | tee "$BK/wg-show.txt" || echo "wg not present/running" | tee "$BK/wg-show.txt"
echo "### IPTABLES" | tee "$BK/40-iptables.txt"
iptables -S | tee "$BK/iptables-S.txt"
iptables-save | tee "$BK/iptables-save.txt"
echo "### NFTABLES" | tee "$BK/50-nft.txt"
nft list ruleset 2>/dev/null | tee "$BK/nft-ruleset.txt" || echo "nft not present/running" | tee "$BK/nft-ruleset.txt"
echo "### FIREWALL SERVICES" | tee "$BK/60-services.txt"
systemctl is-enabled --quiet nftables && echo "nftables: enabled" || echo "nftables: disabled" | tee -a "$BK/60-services.txt"
systemctl is-active --quiet nftables && echo "nftables: active" || echo "nftables: inactive" | tee -a "$BK/60-services.txt"
systemctl is-enabled --quiet ufw && echo "ufw: enabled" || echo "ufw: disabled" | tee -a "$BK/60-services.txt"
systemctl is-active --quiet ufw && echo "ufw: active" || echo "ufw: inactive" | tee -a "$BK/60-services.txt"
systemctl is-enabled --quiet firewalld && echo "firewalld: enabled" || echo "firewalld: disabled" | tee -a "$BK/60-services.txt"
systemctl is-active --quiet firewalld && echo "firewalld: active" || echo "firewalld: inactive" | tee -a "$BK/60-services.txt"
echo "BACKUP_DIR=$BK"
On ms-1, also check:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o wide
kubectl -n traefik get pods -o wide
2. Install Nmap and probe the public surface
Run on ctb-edge-1:
apt-get update -y
apt-get install -y nmap
nmap -Pn -sT -p 22,80,443,6443,9345,10250 198.51.100.25
nmap -Pn -sU -p 51820 198.51.100.25
nmap -Pn -sT -p 22,80,443,6443,9345,10250 203.0.113.10
nmap -Pn -sU -p 51820-51822 203.0.113.10
3. Show why self-scan is not enough
On ctb-edge-1:
ip route get 198.51.100.25
Then confirm from ms-1:
nmap -Pn -sT -p 22,80,443,10250 198.51.100.25
4. Diagnose nftables and iptables interaction
Run on ctb-edge-1:
systemctl is-active nftables || true
nft list ruleset | sed -n '1,200p'
echo "---- hook input chains ----"
nft list ruleset | grep -n "hook input" || true
echo "---- iptables (if any) ----"
iptables -S | sed -n '1,80p'
5. Apply the stronger edgeguard pre-filter
Set up rollback protection first:
apt-get update -y
apt-get install -y at nftables
systemctl enable --now atd
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RBK="/root/backup-${TS}-edgeguard"
mkdir -p "$RBK"
nft list ruleset > "$RBK/nft.before.conf" 2>/dev/null || true
echo "nft -f '$RBK/nft.before.conf' 2>/dev/null || true" | at now + 2 minutes
echo "Rollback scheduled. Keep this SSH session open."
atq
Now apply the rules:
ADMIN_V4="$(echo "${SSH_CONNECTION:-}" | awk '{print $1}')"
echo "ADMIN_V4=$ADMIN_V4"
nft delete table inet edgeguard 2>/dev/null || true
cat > /root/edgeguard.nft <<'EOEDGE'
table inet edgeguard {
chain input {
type filter hook input priority -200; policy drop;
ct state established,related accept
iif "lo" accept
ip protocol icmp accept
ip6 nexthdr ipv6-icmp accept
iifname "wg0" accept
iifname "cali*" accept
iifname "vxlan.calico" accept
iifname "cni0" accept
#__SSH_ALLOW__
udp dport 51820 accept
tcp dport { 80, 443 } accept
}
}
EOEDGE
if [ -n "$ADMIN_V4" ]; then
sed -i "s|#__SSH_ALLOW__|ip saddr ${ADMIN_V4}/32 tcp dport 22 accept|" /root/edgeguard.nft
else
sed -i "s|#__SSH_ALLOW__|tcp dport 22 accept|" /root/edgeguard.nft
fi
nft -c -f /root/edgeguard.nft
nft -f /root/edgeguard.nft
nft -a list chain inet edgeguard input | sed -n '1,200p'
Persist it:
mkdir -p /etc/nftables.d
install -m 0644 /root/edgeguard.nft /etc/nftables.d/edgeguard.nft
cat > /etc/nftables.conf <<'EONFT'
#!/usr/sbin/nft -f
include "/etc/nftables.d/*.nft"
EONFT
nft -c -f /etc/nftables.conf
systemctl enable --now nftables
Validate from ms-1:
nmap -Pn -sT -p 22,80,443,10250 198.51.100.25
Cancel rollback only after confirming a second SSH session still works:
atq
# atrm <JOBID>
Optional proof test:
On ctb-edge-1:
tcpdump -ni eth0 tcp port 10250
From ms-1:
nc -vz -w 2 198.51.100.25 10250
curl -vk --connect-timeout 2 https://198.51.100.25:10250/ 2>&1 | head -n 20
Expected result
From outside the edge host should expose only what it is meant to expose:
22/tcpfor administration80/tcpand443/tcpfor web traffic51820/udpfor WireGuard
Unwanted ports like 10250/tcp should no longer be reachable.
Data and Apps Step by Step
This guide adds the workload layer that turns the cluster into a useful platform.
In this phase, you will:
- deploy PostgreSQL as an internal data service
- deploy Keycloak as the identity service
- confirm the GitOps pattern for production apps
- understand how development and production overlays fit together
By the end, the homelab is not just hosting infrastructure. It is hosting applications with data, identity, and repeatable delivery.
What This Phase Produces
This phase turns the platform into something workloads can actually use.
You should end with:
- PostgreSQL running internally in
databases-prod - Keycloak running in
identity - production apps managed through Argo CD
- development overlays that can be rendered manually when needed
How The Workload Layer Fits Together
This phase is not just “apply some YAML.” It adds the first real service dependencies in the homelab.
PostgreSQL is the internal data layer. It is stateful, it keeps durable storage, and it is intentionally reachable only from approved namespaces inside the cluster.
Keycloak is the identity layer. It depends on PostgreSQL, it follows the same ingress and TLS pattern as the public apps, and it is one of the first workloads where Kubernetes manifests alone are not the whole story because realm configuration must also be backed up.
The notebook and portfolio apps are the application layer. They stay separate from the platform services and use a base-plus-overlays model so production remains reviewable and Git-driven while development can still be rendered manually when you need it.
If you see older notes elsewhere in the docs mentioning deploy/..., treat those as historical references. The maintained app manifests now live under k8s-cluster/apps/, but the important thing to understand first is how the layers depend on each other.
Before You Begin
Make sure the previous phase is complete:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o wide
kubectl get pods -n traefik
kubectl get pods -n cert-manager
kubectl get pods -n argocd
Continue only if:
- the cluster is healthy
- Traefik is running on the edge node
- cert-manager is ready
- Argo CD is healthy
- the app namespaces
apps-devandapps-prodalready exist
Before you add the workload layer, make sure you already understand which services are meant to stay internal, which services are meant to be public, and which ones are meant to be Git-managed.
Step 1: Install PostgreSQL
PostgreSQL is an internal-only service in this design. It should not be reachable from the internet and it should not run on the edge node.
1. Place PostgreSQL on wk-1
Apply the node label:
kubectl label node wk-1 kakde.eu/postgresql=true --overwrite
This gives the PostgreSQL StatefulSet a predictable home.
2. Label the namespaces that are allowed to use PostgreSQL
Apply these namespace labels:
kubectl label namespace apps-prod kakde.eu/postgresql-access=true --overwrite
kubectl label namespace apps-dev kakde.eu/postgresql-access=true --overwrite
kubectl label namespace identity kakde.eu/postgresql-access=true --overwrite
This matches the network policy used by the database. Namespaces without this label should not be able to reach the database service.
3. Prepare the PostgreSQL secret
Create a real secret manifest with your database passwords:
cat >/tmp/postgresql-secret.yaml <<'EOF'
apiVersion: v1
kind: Secret
metadata:
name: postgresql-auth
namespace: databases-prod
type: Opaque
stringData:
postgres-superuser-password: "CHANGE_ME_SUPERUSER_PASSWORD"
app-db-name: "appdb"
app-db-user: "appuser"
app-db-password: "CHANGE_ME_APP_PASSWORD"
EOF
Replace the placeholder passwords before applying it.
4. Apply the PostgreSQL manifests in order
kubectl apply -f k8s-cluster/platform/postgresql/1-namespace.yaml
kubectl apply -f /tmp/postgresql-secret.yaml
kubectl apply -f k8s-cluster/platform/postgresql/3-init-configmap.yaml
kubectl apply -f k8s-cluster/platform/postgresql/4-services.yaml
kubectl apply -f k8s-cluster/platform/postgresql/5-networkpolicy.yaml
kubectl apply -f k8s-cluster/platform/postgresql/6-statefulset.yaml
5. Verify PostgreSQL
kubectl get pods -n databases-prod -o wide
kubectl get pvc -n databases-prod
kubectl get svc -n databases-prod
kubectl get networkpolicy -n databases-prod
Expected result:
- namespace:
databases-prod - image:
postgres:17.9 - PVC size:
80Gi - internal service:
postgresql.databases-prod.svc.cluster.local:5432
What to remember:
- this database is internal only
- its durable state lives in the PVC
- changing the Kubernetes Secret later does not automatically rotate existing PostgreSQL passwords inside the database
Step 2: Install Keycloak
Keycloak gives the homelab an identity platform and a realistic authentication story for apps.
1. Create the namespace
kubectl apply -f k8s-cluster/apps/keycloak/1-namespace.yaml
2. Create the admin and database secrets
Create a real secrets manifest:
cat >/tmp/keycloak-secrets.yaml <<'EOF'
apiVersion: v1
kind: Secret
metadata:
name: keycloak-admin-secret
namespace: identity
type: Opaque
stringData:
username: CHANGE_ME_ADMIN_USERNAME
password: CHANGE_ME_ADMIN_PASSWORD
---
apiVersion: v1
kind: Secret
metadata:
name: keycloak-db-secret
namespace: identity
type: Opaque
stringData:
username: CHANGE_ME_DB_USERNAME
password: CHANGE_ME_DB_PASSWORD
EOF
Replace the placeholders, then apply:
kubectl apply -f /tmp/keycloak-secrets.yaml
3. Create the Keycloak database and database role in PostgreSQL
The Kubernetes manifests assume the database already exists. Run SQL like this against the PostgreSQL instance:
CREATE ROLE CHANGE_ME_KEYCLOAK_DB_USER LOGIN PASSWORD 'CHANGE_ME_KEYCLOAK_DB_PASSWORD';
CREATE DATABASE keycloak OWNER CHANGE_ME_KEYCLOAK_DB_USER;
REVOKE ALL ON DATABASE keycloak FROM PUBLIC;
GRANT ALL PRIVILEGES ON DATABASE keycloak TO CHANGE_ME_KEYCLOAK_DB_USER;
You can run that SQL through psql using your preferred internal access method, for example:
kubectl execinto the PostgreSQL podkubectl port-forwardto the PostgreSQL service- an SSH tunnel to
ms-1plus a remotekubectl port-forward
4. Apply the Keycloak manifests
kubectl apply -f k8s-cluster/apps/keycloak/3-deployment.yaml
kubectl apply -f k8s-cluster/apps/keycloak/4-service.yaml
kubectl apply -f k8s-cluster/apps/keycloak/5-ingress.yaml
5. Verify Keycloak
kubectl get pods -n identity -o wide
kubectl get svc -n identity
kubectl get ingress -n identity
kubectl logs -n identity deploy/keycloak --tail=50
Expected result:
- namespace:
identity - image:
quay.io/keycloak/keycloak:26.5.5 - host:
keycloak.kakde.eu
Important limitation:
Kubernetes manifests do not fully capture the real Keycloak configuration. Realm exports, clients, roles, redirect URIs, and identity-provider configuration still need to be backed up from Keycloak itself.
Step 2.5: Deploy whoami with OAuth2 Proxy
Before wiring authentication into real applications, it is worth proving that the entire Keycloak OIDC chain works end to end. The whoami service is a lightweight HTTP echo container that makes this easy to test.
This step deploys two ingress routes:
whoami.kakde.eu— unprotected, verifies that basic ingress and TLS workwhoami-auth.kakde.eu— protected by OAuth2 Proxy, verifies the full Keycloak OIDC redirect flow
Why test this way? If you skip this step and wire OIDC directly into a real app, a failure could be caused by the app, the proxy, Keycloak, the ingress, or the certificate. By testing with whoami first, you isolate the authentication plumbing from application logic.
1. Create a Keycloak client for OAuth2 Proxy
In the Keycloak admin console (keycloak.kakde.eu), create a new client in the kakde realm:
- Client ID:
whoami-oauth2-proxy - Client Protocol: openid-connect
- Access Type: confidential
- Valid Redirect URIs:
https://whoami-auth.kakde.eu/oauth2/callback
Save the client and note the client secret from the Credentials tab. You will need it for the OAuth2 Proxy secret below.
2. Create the OAuth2 Proxy secret
cat >/tmp/whoami-oauth2-proxy-secret.yaml <<'EOF'
apiVersion: v1
kind: Secret
metadata:
name: oauth2-proxy-secret
namespace: apps-prod
type: Opaque
stringData:
client-id: whoami-oauth2-proxy
client-secret: CHANGE_ME_CLIENT_SECRET
cookie-secret: CHANGE_ME_COOKIE_SECRET_32_BYTES
EOF
Generate the cookie secret with: openssl rand -base64 32 | head -c 32
Replace the placeholders and apply:
kubectl apply -f /tmp/whoami-oauth2-proxy-secret.yaml
3. Apply the whoami and OAuth2 Proxy manifests
kubectl apply -f k8s-cluster/apps/whoami/
4. Verify the OIDC chain
Test basic ingress (should return 200 with HTTP headers):
curl -sI https://whoami.kakde.eu
Test the authenticated route (should return 302 redirecting to Keycloak):
curl -sI https://whoami-auth.kakde.eu
If the unauthenticated whoami.kakde.eu returns 200 and whoami-auth.kakde.eu returns a 302 redirect to keycloak.kakde.eu, the full OIDC chain is working. Open https://whoami-auth.kakde.eu in a browser to complete the login flow and confirm that Keycloak issues a valid token.
Step 3: Confirm the Production App Delivery Pattern
The homelab currently uses Argo CD to manage the production overlays for:
- portfolio
- notebook
Those applications are already defined as Argo CD Application objects, so if the previous phase completed correctly, Argo CD should already be reconciling them.
Verify:
kubectl get application -n argocd
kubectl get application -n argocd note-book -o yaml | grep -n "path:"
kubectl get application -n argocd portfolio-app -o yaml | grep -n "path:"
kubectl get pods -n apps-prod
kubectl get ingress -n apps-prod
Expected production hosts:
- portfolio:
kakde.eu - notebook:
notebook.kakde.eu
Step 4: Understand the Development Overlay Pattern
The platform separates reusable app configuration from environment-specific differences.
Each app follows this layout:
base/for shared Kubernetes resourcesoverlays/dev/for development-specific changesoverlays/prod/for production-specific changes
In practice, that means:
- the base holds the common Deployment and Service
- the overlay chooses namespace, hostnames, and image tags
- Argo CD watches the production overlay
If you want to deploy a dev overlay manually, you can render and apply it yourself:
kubectl kustomize k8s-cluster/apps/portfolio/overlays/dev | kubectl apply -f -
kubectl kustomize k8s-cluster/apps/notebook/overlays/dev | kubectl apply -f -
Use that only when you intentionally want the development copy running in the cluster.
Step 5: Follow the GitOps Workflow for Production Changes
For existing production apps, the workflow is:
- update the production overlay in the repo
- commit the change
- let Argo CD reconcile it into the cluster
In the current repo layout, that usually means changing one of these files:
k8s-cluster/apps/notebook/overlays/prod/kustomization.yamlk8s-cluster/apps/portfolio/overlays/prod/kustomization.yaml
Before you rely on Argo CD, confirm the matching Application still points at the repo path you expect. If an Application object still refers to an older deploy/... path, treat that as drift and correct it before calling the GitOps flow complete.
For a brand-new app:
- copy an existing app layout such as
k8s-cluster/apps/notebook/ork8s-cluster/apps/portfolio/ - adapt the
base/resources - create
devandprodoverlays - add a matching Argo CD
Applicationunderk8s-cluster/platform/argocd/applications/ - let Argo CD manage the production overlay
This keeps production changes reviewable and repeatable instead of becoming a series of manual kubectl apply commands.
Final Verification Checklist
Before you call the platform complete, run:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get pods -n databases-prod -o wide
kubectl get pvc -n databases-prod
kubectl get networkpolicy -n databases-prod
kubectl get pods -n identity -o wide
kubectl get ingress -n identity
kubectl get application -n argocd
kubectl get pods -n apps-prod
kubectl get ingress -n apps-prod
You want to see:
- PostgreSQL healthy on
wk-1 - Keycloak healthy in
identity - production apps present in
apps-prod - Argo CD still healthy after the new workload layer was added
- ingress objects present for the public services you expect
What You Have Now
At this point the homelab includes:
- a private Kubernetes foundation
- public ingress and TLS
- GitOps
- an internal database
- an identity provider
- a repeatable application deployment pattern
That is the full platform.
Next Step
Keep 16. Operate, Verify, and Recover nearby. That guide explains how to check the platform, troubleshoot issues, and recover the important pieces cleanly.
PostgreSQL: Beginner-Friendly Deployment and Operations Guide
Current note This is a detailed historical deep dive. For the current data-service path, start with 01-platform-overview.md and 12-data-and-apps-step-by-step.md.
Table of Contents
- Overview
- What was built
- Design decisions
- Step-by-step deployment
- Manifest reference
- Validation and testing
- Troubleshooting
- Connecting from a Mac
- Backup and restore
- Further learning
Overview
This document explains how a single internal PostgreSQL database was deployed into the Homelab-0 K3s cluster, how it was debugged when startup failed, how it was verified, and how it can be accessed safely from a local Mac.
The audience for this document is a beginner. That means the guide explains not just what to run, but also why each step exists and what success looks like.
The manifest set includes a namespace, secret, bootstrap ConfigMap, services, network policies, and a StatefulSet with an 80Gi persistent volume.
What was built
A single PostgreSQL instance was deployed for internal Kubernetes use only.
The final solution has these characteristics:
- Namespace:
databases-prod - One PostgreSQL pod managed by a StatefulSet named
postgresql - Persistent storage:
80Gion thelocal-pathstorage class - Internal-only access through a
ClusterIPService namedpostgresql - No Ingress
- No NodePort
- No LoadBalancer
- Credentials stored in a Kubernetes Secret
- A bootstrap script stored in a ConfigMap to create an application database and application user on first initialization
- NetworkPolicy rules that deny general ingress and only allow traffic from approved namespaces
- Placement constrained to a node labeled
kakde.eu/postgresql=true
The final runtime behavior was successfully validated:
- the pod became healthy
- the application database
appdbwas created - the application user
appusercould connect - a write/read test succeeded
- the service and EndpointSlices pointed to the running pod
Why this design was chosen
Why a StatefulSet?
PostgreSQL is a stateful application. That means it stores important data on disk and needs stable identity and stable storage. In Kubernetes, a StatefulSet is the standard workload type for this kind of application because it provides sticky pod identity and persistent volume handling. (Kubernetes)
Why a ClusterIP Service?
A ClusterIP Service is internal to the cluster. It is the default and safest way to expose a database to other pods without exposing it outside the cluster. That fits this use case because PostgreSQL should not be internet-facing. (Kubernetes)
Why local-path storage?
K3s includes the Rancher Local Path Provisioner out of the box. It is simple and works well for a small homelab, but it uses storage local to the node that runs the pod. That means the database is tied to that node’s disk. This is acceptable for a simple, single-instance homelab database, but it increases the importance of backups. (docs.k3s.io)
Why NetworkPolicy?
NetworkPolicy is used to reduce the blast radius inside the cluster. Even though the service is internal-only, not every namespace should be allowed to talk to the database. The chosen policy denies ingress by default and explicitly allows only:
- the same namespace
databases-prod - other namespaces labeled
kakde.eu/postgresql-access=true
Kubernetes NetworkPolicies are designed for exactly this kind of traffic restriction between workloads. (Kubernetes)
Final architecture
Cluster context
It is assumed this existing cluster context:
- 4 Ubuntu 24.04 nodes
ms-1= K3s serverwk-1,wk-2,ctb-edge-1= agents- Calico VXLAN for CNI
- WireGuard mesh between nodes
- Traefik only on the edge node for public HTTP/HTTPS apps
- cert-manager already installed and working
PostgreSQL placement
The database pod is pinned to a node with label:
kakde.eu/postgresql: "true"
In practice, the chosen node was wk-1.
This keeps the database away from the public edge node and avoids mixing it with the public-facing ingress role.
Access model
There are two services:
-
postgresql-hlA headless service used by the StatefulSet -
postgresqlA regular ClusterIP service used by clients inside Kubernetes
The client connection inside the cluster uses:
- Host:
postgresql.databases-prod.svc.cluster.local - Port:
5432
Credentials
Two kinds of credentials exist:
- PostgreSQL superuser/admin account
- Application account
The final secret uses these keys:
postgres-superuser-passwordapp-db-nameapp-db-userapp-db-password
The PostgreSQL superuser username is:
postgres
That comes from POSTGRES_USER: postgres in the StatefulSet.
Files used in the deployment
The final file layout is:
1-namespace.yaml— namespace creation2-secret.yaml— credentials3-init-configmap.yaml— bootstrap script for first initialization4-services.yaml— internal service definitions5-networkpolicy.yaml— ingress restrictions6-statefulset.yaml— PostgreSQL workload and persistent volume claim template
Important decisions and lessons learned
1. The first hardening attempt was too strict
An early version of the StatefulSet included a container securityContext that dropped all Linux capabilities and also set fsGroup. That caused the PostgreSQL container to fail during first boot with errors like:
chmod: changing permissions of '/var/run/postgresql': Operation not permittedchown: changing ownership of '/var/lib/postgresql/data/pgdata': Operation not permitted
The fix was to remove:
- pod
fsGroup - pod
fsGroupChangePolicy - container
securityContextthat dropped all capabilities
The final working manifest keeps only:
securityContext:
seccompProfile:
type: RuntimeDefault
at the pod level.
2. The bootstrap script only matters on first initialization
The bootstrap ConfigMap creates the application role and application database on first initialization of the data directory. If the PVC already contains a PostgreSQL data directory, changing the ConfigMap or Secret later does not recreate the database automatically. That behavior matches the PostgreSQL container’s first-init model. The application setup in this document relied on /docker-entrypoint-initdb.d for first-run initialization.
3. Secret changes do not automatically change the live PostgreSQL password
The Secret is only a Kubernetes object. PostgreSQL stores the actual role password internally in the database. That means:
- changing
app-db-passwordin the Secret does not rotate the database user password by itself - an explicit SQL command like
ALTER ROLE ... PASSWORD ...is needed after a password rotation
4. Mac access should stay temporary and private
The database should remain internal-only. The safest temporary access method from a Mac is:
- Kubernetes
port-forward - or SSH plus a remote
kubectl port-forward
No public exposure was added.
5. A subtle command syntax issue caused confusion
This command was wrong:
kubectl -n databases-prod port-forward svc/postgresql 127.0.0.1:15432:5432
Why it was wrong: kubectl port-forward does not accept the bind address inside the port tuple. Kubernetes interpreted 127.0.0.1 as if it were a port name.
The correct form is:
kubectl -n databases-prod port-forward --address 127.0.0.1 svc/postgresql 15432:5432
6. ssh -N is not appropriate when a remote command must run
This command failed:
ssh -N -L 15432:127.0.0.1:15432 root@192.168.15.2 'kubectl -n databases-prod port-forward --address 127.0.0.1 svc/postgresql 15432:5432'
Because -N means “do not execute a remote command or shell.” In this use case, the remote command is required.
The working one-liner was:
ssh -L 15432:127.0.0.1:15432 root@192.168.15.2 \
'kubectl -n databases-prod port-forward --address 127.0.0.1 svc/postgresql 15432:5432'
Step-by-step deployment
Prerequisites
Before applying the manifests, make sure:
- the target node has enough free disk space for
80Gi - the target node is labeled for PostgreSQL
- the application namespace is labeled to allow DB access
- the secret file has real passwords, not placeholders
Where to run the commands
All Kubernetes commands below should be run on a machine that has working kubectl access to the cluster. In this document, that machine was ms-1.
Step 1: label the database node
Run on ms-1:
kubectl label node wk-1 kakde.eu/postgresql=true --overwrite
kubectl get nodes --show-labels | grep 'kakde.eu/postgresql=true'
What this does:
- adds a label to
wk-1 - allows the StatefulSet to target that node using
nodeSelector
Good looks like:
- only
wk-1showskakde.eu/postgresql=true
Step 2: label the consuming application namespace
Run on ms-1:
kubectl label namespace apps-prod kakde.eu/postgresql-access=true --overwrite
kubectl get ns --show-labels | grep apps-prod
What this does:
- marks
apps-prodas allowed by the NetworkPolicy
Good looks like:
apps-prodshowskakde.eu/postgresql-access=true
Step 3: verify the final manifest files
Run on ms-1 from the manifest directory:
cd ~/deployment/postgresql
ls -1
Good looks like:
- the directory contains
1-namespace.yamlthrough6-statefulset.yaml
Step 4: edit the secret values
Run on ms-1:
sed -n '1,120p' 2-secret.yaml
The final file contains placeholders for:
SUPER_USER_PASSWORDAPP_USER_PASSWORD
Replace them with real values before applying.
Step 5: apply the manifests
Run on ms-1:
cd ~/deployment/postgresql
kubectl apply -f 1-namespace.yaml
kubectl apply -f 2-secret.yaml
kubectl apply -f 3-init-configmap.yaml
kubectl apply -f 4-services.yaml
kubectl apply -f 5-networkpolicy.yaml
kubectl apply -f 6-statefulset.yaml
What this does:
- creates the namespace
- stores credentials
- stores the first-run bootstrap script
- creates internal services
- creates the network restrictions
- creates the PostgreSQL StatefulSet and its PVC
Good looks like:
- each resource shows
createdorconfigured
Step 6: watch the rollout
Run on ms-1:
kubectl -n databases-prod get statefulset
kubectl -n databases-prod get pods -w
Good looks like:
postgresqlStatefulSet exists- pod
postgresql-0reachesRunning - eventually the pod becomes
1/1 Ready
Step 7: verify the PVC and placement
Run on ms-1:
kubectl -n databases-prod get pvc
kubectl -n databases-prod get pod postgresql-0 -o wide
kubectl -n databases-prod describe pod postgresql-0 | egrep 'Node:|Image:|Mounts:'
What this does:
- checks that storage was provisioned
- verifies which node runs the pod
- verifies the image and mounts
Good looks like:
data-postgresql-0isBound- pod is on
wk-1 - image is
postgres:17.9
Exact manifest files
These are the final corrected manifests. The structure and values below match the manifest set under k8s-cluster/platform/postgresql/.
1-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: databases-prod
2-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: postgresql-auth
namespace: databases-prod
type: Opaque
stringData:
postgres-superuser-password: "SUPER_USER_PASSWORD"
app-db-name: "appdb"
app-db-user: "appuser"
app-db-password: "APP_USER_PASSWORD"
3-init-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: postgresql-init
namespace: databases-prod
data:
01-create-app-db.sh: |
#!/bin/sh
set -eu
export PGPASSWORD="${POSTGRES_PASSWORD}"
psql -v ON_ERROR_STOP=1 \
--username "${POSTGRES_USER}" \
--dbname postgres \
--set=app_db_name="${APP_DB_NAME}" \
--set=app_db_user="${APP_DB_USER}" \
--set=app_db_password="${APP_DB_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_db_user') THEN
EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_db_user', :'app_db_password');
ELSE
EXECUTE format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'app_db_user', :'app_db_password');
END IF;
END
$do$;
SELECT format('CREATE DATABASE %I OWNER %I', :'app_db_name', :'app_db_user')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'app_db_name') \gexec
REVOKE ALL ON DATABASE :"app_db_name" FROM PUBLIC;
GRANT ALL PRIVILEGES ON DATABASE :"app_db_name" TO :"app_db_user";
EOSQL
psql -v ON_ERROR_STOP=1 \
--username "${POSTGRES_USER}" \
--dbname "${APP_DB_NAME}" \
--set=app_db_user="${APP_DB_USER}" <<'EOSQL'
ALTER SCHEMA public OWNER TO :"app_db_user";
GRANT ALL ON SCHEMA public TO :"app_db_user";
EOSQL
4-services.yaml
apiVersion: v1
kind: Service
metadata:
name: postgresql-hl
namespace: databases-prod
labels:
app.kubernetes.io/name: postgresql
spec:
clusterIP: None
selector:
app.kubernetes.io/name: postgresql
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Service
metadata:
name: postgresql
namespace: databases-prod
labels:
app.kubernetes.io/name: postgresql
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: postgresql
ports:
- name: postgres
port: 5432
targetPort: 5432
5-networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgresql-default-deny-ingress
namespace: databases-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
policyTypes:
- Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgresql-allow-from-selected-namespaces
namespace: databases-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: databases-prod
- namespaceSelector:
matchLabels:
kakde.eu/postgresql-access: "true"
ports:
- protocol: TCP
port: 5432
6-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgresql
namespace: databases-prod
labels:
app.kubernetes.io/name: postgresql
spec:
serviceName: postgresql-hl
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: postgresql
template:
metadata:
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/component: primary
spec:
nodeSelector:
kakde.eu/postgresql: "true"
terminationGracePeriodSeconds: 120
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: postgresql
image: postgres:17.9
imagePullPolicy: IfNotPresent
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_USER
value: postgres
- name: POSTGRES_DB
value: postgres
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgresql-auth
key: postgres-superuser-password
- name: APP_DB_NAME
valueFrom:
secretKeyRef:
name: postgresql-auth
key: app-db-name
- name: APP_DB_USER
valueFrom:
secretKeyRef:
name: postgresql-auth
key: app-db-user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: postgresql-auth
key: app-db-password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2"
memory: "4Gi"
startupProbe:
exec:
command:
- sh
- -c
- pg_isready -h 127.0.0.1 -p 5432 -U "$POSTGRES_USER" -d postgres
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 60
readinessProbe:
exec:
command:
- sh
- -c
- pg_isready -h 127.0.0.1 -p 5432 -U "$POSTGRES_USER" -d postgres
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
exec:
command:
- sh
- -c
- pg_isready -h 127.0.0.1 -p 5432 -U "$POSTGRES_USER" -d postgres
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: initdb
mountPath: /docker-entrypoint-initdb.d
volumes:
- name: initdb
configMap:
name: postgresql-init
volumeClaimTemplates:
- metadata:
name: data
labels:
app.kubernetes.io/name: postgresql
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 80Gi
Validation and testing
Check all resources
Run on ms-1:
kubectl -n databases-prod get all,pvc,configmap,secret,networkpolicy
Good looks like:
- StatefulSet exists
- pod exists
- both services exist
- PVC exists and is
Bound - Secret exists
- ConfigMap exists
- NetworkPolicies exist
Check rollout status
Run on ms-1:
kubectl -n databases-prod rollout status statefulset/postgresql --timeout=300s
kubectl -n databases-prod get pod postgresql-0 -o wide
kubectl -n databases-prod get pvc
Good looks like:
- rollout succeeds
- pod is
1/1 Running - PVC is
Bound
Check logs
Run on ms-1:
kubectl -n databases-prod logs postgresql-0 | tail -n 80
Good looks like:
- no permission errors
- normal PostgreSQL startup messages
Check services and endpoints
Run on ms-1:
kubectl -n databases-prod get svc
kubectl -n databases-prod get endpointslice
Good looks like:
postgresqlis aClusterIPservice on5432- the EndpointSlice points to the PostgreSQL pod IP
Test the PostgreSQL superuser login
Run on ms-1:
kubectl -n databases-prod exec -it postgresql-0 -- sh -lc '
export PGPASSWORD="$POSTGRES_PASSWORD"
psql -v ON_ERROR_STOP=1 --username postgres --dbname postgres -c "select current_user, current_database();"
'
What this does:
- enters the pod
- authenticates as the PostgreSQL superuser
- runs a tiny SQL query
Good looks like:
current_user = postgrescurrent_database = postgres
Test the application user login
Run on ms-1:
kubectl -n databases-prod exec -it postgresql-0 -- sh -lc "
export PGPASSWORD=\"\$APP_DB_PASSWORD\"
psql -v ON_ERROR_STOP=1 -h postgresql -U \"\$APP_DB_USER\" -d \"\$APP_DB_NAME\" -c 'select current_database(), current_user;'
"
Good looks like:
current_database = appdbcurrent_user = appuser
Test writes and reads
Run on ms-1:
kubectl -n databases-prod exec -it postgresql-0 -- sh -lc "
export PGPASSWORD=\"\$APP_DB_PASSWORD\"
psql -v ON_ERROR_STOP=1 -h postgresql -U \"\$APP_DB_USER\" -d \"\$APP_DB_NAME\" <<'SQL'
CREATE TABLE IF NOT EXISTS healthcheck (
id serial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO healthcheck DEFAULT VALUES;
SELECT count(*) AS rows FROM healthcheck;
SQL
"
Good looks like:
CREATE TABLEINSERT 0 1- row count is at least
1
Troubleshooting that happened in this document
Problem 1: Pod crashlooped immediately after startup
Symptom
The pod moved through:
ContainerCreatingRunningErrorCrashLoopBackOff
Logs showed:
chmod: changing permissions of '/var/run/postgresql': Operation not permitted
chown: changing ownership of '/var/lib/postgresql/data/pgdata': Operation not permitted
Cause
The hardening settings were too strict for the PostgreSQL container’s first boot.
Fix
Patch or update the StatefulSet so that:
- container-level
securityContextis removed fsGroupis removed- pod-level
seccompProfile: RuntimeDefaultis kept
Verification
After recreating the pod:
- PostgreSQL started successfully
- logs no longer showed
Operation not permitted
Problem 2: Application user could not log in
Symptom
This failed:
FATAL: password authentication failed for user "appuser"
Cause
The live PostgreSQL role password did not match the Secret value anymore. The bootstrap script only runs on first initialization.
Fix
Repair the role and database explicitly using psql.
The document settled on a safer method:
- create SQL files on
ms-1 - pipe them into
psqlrunning inside the pod
Repair SQL used in this document
Create the file on ms-1:
cat > /tmp/pg-repair.sql <<'SQL'
SELECT current_user, current_database();
SELECT rolname, rolsuper, rolcanlogin
FROM pg_roles
WHERE rolname IN ('postgres', :'app_db_user');
SELECT datname, pg_catalog.pg_get_userbyid(datdba) AS owner
FROM pg_database
WHERE datname IN ('postgres', :'app_db_name');
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_db_user', :'app_db_password')
WHERE NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = :'app_db_user'
) \gexec
SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'app_db_user', :'app_db_password') \gexec
SELECT format('CREATE DATABASE %I OWNER %I', :'app_db_name', :'app_db_user')
WHERE NOT EXISTS (
SELECT 1 FROM pg_database WHERE datname = :'app_db_name'
) \gexec
SELECT format('ALTER DATABASE %I OWNER TO %I', :'app_db_name', :'app_db_user') \gexec
SELECT format('REVOKE ALL ON DATABASE %I FROM PUBLIC', :'app_db_name') \gexec
SELECT format('GRANT ALL PRIVILEGES ON DATABASE %I TO %I', :'app_db_name', :'app_db_user') \gexec
SQL
Run it:
kubectl -n databases-prod exec -i postgresql-0 -- sh -lc "export PGPASSWORD=\"\$POSTGRES_PASSWORD\"; psql -v ON_ERROR_STOP=1 --username postgres --dbname postgres --set=app_db_name=\"\$APP_DB_NAME\" --set=app_db_user=\"\$APP_DB_USER\" --set=app_db_password=\"\$APP_DB_PASSWORD\"" < /tmp/pg-repair.sql
Then create the schema fix file:
cat > /tmp/pg-schema-fix.sql <<'SQL'
SELECT format('ALTER SCHEMA public OWNER TO %I', :'app_db_user') \gexec
SELECT format('GRANT ALL ON SCHEMA public TO %I', :'app_db_user') \gexec
SQL
Run it:
kubectl -n databases-prod exec -i postgresql-0 -- sh -lc "export PGPASSWORD=\"\$POSTGRES_PASSWORD\"; psql -v ON_ERROR_STOP=1 --username postgres --dbname \"\$APP_DB_NAME\" --set=app_db_user=\"\$APP_DB_USER\"" < /tmp/pg-schema-fix.sql
Good looks like:
CREATE ROLEALTER ROLECREATE DATABASEALTER DATABASEREVOKEGRANTALTER SCHEMAGRANT
Problem 3: wrong kubectl port-forward syntax
Wrong command
kubectl -n databases-prod port-forward svc/postgresql 127.0.0.1:15432:5432
Error
error: Service 'postgresql' does not have a named port '127.0.0.1'
Correct command
kubectl -n databases-prod port-forward --address 127.0.0.1 svc/postgresql 15432:5432
Problem 4: SSH tunnel attempted with -N while also needing a remote command
Failing idea
ssh -N -L 15432:127.0.0.1:15432 root@192.168.15.2 'kubectl -n databases-prod port-forward --address 127.0.0.1 svc/postgresql 15432:5432'
Better command
ssh -L 15432:127.0.0.1:15432 root@192.168.15.2 \
'kubectl -n databases-prod port-forward --address 127.0.0.1 svc/postgresql 15432:5432'
Keep that terminal open, then connect from another Mac terminal.
How to connect from a Mac
There are two practical methods described in this document.
Method 1: direct kubectl port-forward from the Mac
This works only if the Mac already has working kubectl access to the cluster.
Run on the Mac:
kubectl -n databases-prod port-forward svc/postgresql 15432:5432
Then from another Mac terminal:
PGPASSWORD='YOUR_APP_DB_PASSWORD' psql -h 127.0.0.1 -p 15432 -U appuser -d appdb
Method 2: SSH + remote Kubernetes port-forward
This is useful when the Mac can SSH to ms-1 but does not have direct kubectl access.
Run on the Mac:
ssh -L 15432:127.0.0.1:15432 root@192.168.15.2 \
'kubectl -n databases-prod port-forward --address 127.0.0.1 svc/postgresql 15432:5432'
Leave that terminal open.
Then from another Mac terminal:
PGPASSWORD='YOUR_APP_DB_PASSWORD' psql -h 127.0.0.1 -p 15432 -U appuser -d appdb
Good looks like:
psqlconnects successfully- SQL commands can be executed locally on the Mac against the tunnel
Important note
This access method is temporary and preserves the security model because the database remains internal-only.
Backup and restore basics
Because the deployment uses K3s local-path storage, backups are especially important. local-path uses local storage on the node that hosts the pod. (docs.k3s.io)
Backup the application database
Run on ms-1:
kubectl -n databases-prod exec postgresql-0 -- sh -lc \
'export PGPASSWORD="$POSTGRES_PASSWORD"; pg_dump -U postgres -d "$APP_DB_NAME" -Fc' \
> appdb-$(date +%F).dump
What this does:
- runs
pg_dumpinside the pod - writes a custom-format backup file to the local machine where the command is executed
Good looks like:
- a file like
appdb-2026-03-15.dumpappears
Full logical backup
Run on ms-1:
kubectl -n databases-prod exec postgresql-0 -- sh -lc \
'export PGPASSWORD="$POSTGRES_PASSWORD"; pg_dumpall -U postgres' \
> pg-all-$(date +%F).sql
Good looks like:
- a file like
pg-all-2026-03-15.sqlappears
Restore a custom-format backup
Run on ms-1:
cat appdb-YYYY-MM-DD.dump | kubectl -n databases-prod exec -i postgresql-0 -- sh -lc \
'export PGPASSWORD="$POSTGRES_PASSWORD"; pg_restore -U postgres -d "$APP_DB_NAME" --clean --if-exists'
Restore a SQL dump
Run on ms-1:
cat pg-all-YYYY-MM-DD.sql | kubectl -n databases-prod exec -i postgresql-0 -- sh -lc \
'export PGPASSWORD="$POSTGRES_PASSWORD"; psql -U postgres -d postgres'
Full cleanup and fresh redeploy
Use this only when a total reset is intended.
Delete the PostgreSQL resources
Run on ms-1:
kubectl -n databases-prod delete statefulset postgresql --ignore-not-found
kubectl -n databases-prod delete service postgresql postgresql-hl --ignore-not-found
kubectl -n databases-prod delete configmap postgresql-init --ignore-not-found
kubectl -n databases-prod delete secret postgresql-auth --ignore-not-found
kubectl -n databases-prod delete networkpolicy postgresql-default-deny-ingress postgresql-allow-from-selected-namespaces --ignore-not-found
kubectl -n databases-prod delete pvc data-postgresql-0 --ignore-not-found
kubectl delete namespace databases-prod --ignore-not-found
Why delete the PVC too?
If the PVC is kept, PostgreSQL may reuse the old data directory and the first-run bootstrap logic may not run the way a fresh deployment expects.
Fresh redeploy
Run on ms-1:
cd ~/deployment/postgresql
kubectl label node wk-1 kakde.eu/postgresql=true --overwrite
kubectl label namespace apps-prod kakde.eu/postgresql-access=true --overwrite
kubectl apply -f 1-namespace.yaml
kubectl apply -f 2-secret.yaml
kubectl apply -f 3-init-configmap.yaml
kubectl apply -f 4-services.yaml
kubectl apply -f 5-networkpolicy.yaml
kubectl apply -f 6-statefulset.yaml
Then verify again:
kubectl -n databases-prod rollout status statefulset/postgresql --timeout=300s
kubectl -n databases-prod get all,pvc,configmap,secret,networkpolicy
kubectl -n databases-prod get endpointslice
kubectl -n databases-prod logs postgresql-0 | tail -n 80
Open questions and future improvements
It is intentionally kept the setup simple. Some future improvements were mentioned or implied:
-
Better day-to-day access from the Mac without port-forward
- likely through a private WireGuard-based admin path
- not implemented in this document
-
More resilient storage
local-pathis simple but node-local- a replicated storage system would improve resilience
-
Password rotation runbook
- the document explains the principle
- a dedicated operational script could make it easier
-
Automated backups
- backups were described manually
- scheduled jobs were not added in this document
Glossary
ClusterIP
A Kubernetes Service type that is reachable only from inside the cluster. It is commonly used for internal databases and internal APIs. (Kubernetes)
ConfigMap
A Kubernetes object used to store non-sensitive configuration data. In this deployment, it stores the first-run database bootstrap script.
EndpointSlice
A Kubernetes object that tracks which pod IPs back a Service.
Ingress
A Kubernetes API object used mainly for HTTP/HTTPS routing into services. It was intentionally not used for PostgreSQL.
K3s
A lightweight Kubernetes distribution that simplifies cluster setup and includes some batteries-included defaults. (docs.k3s.io)
Local Path Provisioner
The default K3s storage provisioner that creates persistent volumes on the local disk of the node running the pod. (docs.k3s.io)
NetworkPolicy
A Kubernetes API object that restricts which network traffic is allowed to reach selected pods. (Kubernetes)
Persistent Volume Claim (PVC)
A Kubernetes request for storage. In this setup, the StatefulSet automatically creates a PVC for PostgreSQL storage.
Pod
The basic runnable unit in Kubernetes. A pod can contain one or more containers.
PostgreSQL superuser
The main admin account in PostgreSQL. In this setup, the username is postgres.
StatefulSet
A Kubernetes workload object used for applications that need stable identity and persistent storage, such as databases. (Kubernetes)
Official learning links
These are good official or high-quality places to learn the concepts used in this guide:
- Kubernetes StatefulSet documentation for why stateful apps like databases use StatefulSets. (Kubernetes)
- Kubernetes Service documentation for how
ClusterIPworks. (Kubernetes) - Kubernetes “Expose Your App” tutorial for the difference between
ClusterIP,NodePort, and other service types. (Kubernetes) - Kubernetes NetworkPolicy documentation for how ingress restrictions work. (Kubernetes)
- K3s storage documentation for how
local-pathworks and why it is node-local. (docs.k3s.io) - Kubernetes general networking documentation for cluster networking basics. (Kubernetes)
If you want this next as a downloadable README.md or a longer runbook format with a “copy/paste commands only” appendix, I can format it that way.
Application Deployment Runbook using Kustomize
Current note This is a detailed historical deep dive. For the current Kustomize and app deployment flow, start with 01-platform-overview.md and 12-data-and-apps-step-by-step.md.
Current repo note: the maintained app manifests now live under
k8s-cluster/apps/. If you see older path names later in this document, translate them to the current tree before applying anything.
Notebook app fix, dev/prod namespace split, TLS troubleshooting, portfolio app pattern, and reusable template
Table of contents
- Overview
- Key decisions and patterns
- Notebook app deployment
- Certificate troubleshooting
- Portfolio app deployment
- Reusable app template
- Troubleshooting guide
- Lessons learned
- Further learning
Overview
The main focus of this document was:
- separating dev and prod cleanly
- resolving a prod TLS certificate issuance problem
- applying the same model to a portfolio app
- creating a generic reusable template for future apps
- preparing a clean operational path for later services like PostgreSQL
What this document accomplished
By the end of the document, the deployment model was standardized like this:
-
dev apps live in namespace
apps-dev -
prod apps live in namespace
apps-prod -
internal Kubernetes object names stay the same across environments
-
the environment is distinguished by:
- namespace
- hostname
- cert-manager issuer
- optional replica count or image override
-
no
nameSuffix: -dev -
Traefik uses Kubernetes Ingress, not Traefik CRDs
-
each Ingress in this cluster must include:
spec.ingressClassName: traefik- annotation
kubernetes.io/ingress.class: traefik - annotation
traefik.ingress.kubernetes.io/router.tls: "true"
The document also proved an important point: even though Kubernetes generally prefers ingressClassName, this particular cluster needed both the field and the older annotation for Traefik behavior to be reliable. Kubernetes documents ingressClassName as the newer mechanism, while older annotation-based behavior still exists in practice with some controllers and clusters. (Kubernetes)
Cluster context used throughout this document
The following cluster facts were treated as already true:
- 4 Ubuntu 24.04 nodes
ms-1= K3s serverwk-1,wk-2,ctb-edge-1= K3s agents- Calico VXLAN networking
- WireGuard mesh between nodes
- Traefik runs only on
ctb-edge-1 - cert-manager is installed and working
- domain =
kakde.eu
Previously confirmed cluster-specific behavior:
- local edge testing must use:
curl -k --resolve host:443:127.0.0.1 https://host/
That matters because testing Traefik against 127.0.0.1 without the correct hostname can produce misleading 404 results.
Key decisions made
1. Dev and prod must be separated by namespace
This was the biggest design correction.
Chosen pattern:
- dev namespace:
apps-dev - prod namespace:
apps-prod
2. Internal names stay stable
Examples:
- Deployment name:
notebook-app - Service name:
notebook-app - Ingress name:
notebook-app
The same naming is used in both environments.
3. No nameSuffix: -dev
This was removed because it caused confusing object name drift and backend mismatches.
4. Use Kustomize base + overlays
Chosen structure:
base/overlays/dev/overlays/prod/
5. Use hostnames and issuers to distinguish environments
For notebook:
- dev host:
dev.notebook.kakde.eu - prod host:
notebook.kakde.eu
For portfolio:
- dev host:
dev.kakde.eu - prod host:
kakde.eu
6. Prod may scale differently
Example used in this document:
- notebook prod replicas = 2
- portfolio prod replicas = 2
Why the original dev/prod approach caused trouble
At first, dev had a suffix-based naming model. That looked harmless, but it created a real problem.
A suffix like -dev can cause this type of drift:
- Deployment becomes
notebook-app-dev - Service becomes
notebook-app-dev - Ingress still points to
notebook-app
When that happens, Traefik may not find the correct backend Service, or the Ingress and Service no longer refer to the same object.
Another problem appeared after the suffix was removed:
- dev and prod now both used names like
notebook-app - if both were deployed into the same namespace, one environment would overwrite the other
That led to the final decision:
- keep names stable
- separate environments by namespace
This is much easier to reason about.
Final deployment pattern adopted in this document
For every new app:
-
base contains the Deployment and Service
-
dev overlay adds:
- namespace
apps-dev - dev hostname
- staging cert issuer
- namespace
-
prod overlay adds:
- namespace
apps-prod - prod hostname
- production cert issuer
- optional replica increase
- namespace
This is the final pattern to reuse going forward.
Notebook app: final corrected design
The notebook app became the reference implementation.
Final behavior
- internal name:
notebook-app - dev namespace:
apps-dev - prod namespace:
apps-prod - dev host:
dev.notebook.kakde.eu - prod host:
notebook.kakde.eu - dev TLS issuer:
letsencrypt-staging-dns01 - prod TLS issuer:
letsencrypt-prod-dns01
Why this design is good
It avoids:
- suffix confusion
- Service/backend mismatch
- dev/prod overwriting each other
- environment-specific internal naming chaos
Notebook app: exact file layout and manifest contents
Directory layout
notebook/
├── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
└── overlays/
├── dev/
│ ├── ingress.yaml
│ └── kustomization.yaml
└── prod/
├── ingress.yaml
└── kustomization.yaml
base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: notebook-app
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: notebook-app
template:
metadata:
labels:
app.kubernetes.io/name: notebook-app
spec:
terminationGracePeriodSeconds: 20
containers:
- name: notebook-app
image: ani2fun/note-book:5f7face64c914bb16a1f334708833336413debd6
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
securityContext:
seccompProfile:
type: RuntimeDefault
base/service.yaml
apiVersion: v1
kind: Service
metadata:
name: notebook-app
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: notebook-app
ports:
- name: http
port: 80
targetPort: 3000
protocol: TCP
base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
labels:
- pairs:
app.kubernetes.io/name: notebook-app
includeSelectors: true
includeTemplates: true
resources:
- deployment.yaml
- service.yaml
overlays/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: apps-dev
resources:
- ../../base
- ingress.yaml
images:
- name: ani2fun/note-book
newName: ani2fun/note-book
newTag: 5f7face64c914bb16a1f334708833336413debd6
overlays/dev/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: notebook-app
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging-dns01
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
rules:
- host: dev.notebook.kakde.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: notebook-app
port:
number: 80
tls:
- hosts:
- dev.notebook.kakde.eu
secretName: dev-notebook-kakde-eu-tls
overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: apps-prod
resources:
- ../../base
- ingress.yaml
patches:
- patch: |-
- op: replace
path: /spec/replicas
value: 2
target:
kind: Deployment
name: notebook-app
images:
- name: ani2fun/note-book
newName: ani2fun/note-book
newTag: 5f7face64c914bb16a1f334708833336413debd6
overlays/prod/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: notebook-app
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod-dns01
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
rules:
- host: notebook.kakde.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: notebook-app
port:
number: 80
tls:
- hosts:
- notebook.kakde.eu
secretName: notebook-kakde-eu-tls
Notebook app: cleanup from old apps namespace
Before moving to the new namespace model, old notebook resources had to be deleted from the legacy apps namespace.
Why this mattered
Old resources were still present under older names like:
notebook-app-devnotebook-app-ingress
That caused stale certificates and stale routing objects to be recreated.
Commands run on ms-1
First cleanup attempt:
kubectl -n apps delete ingress notebook-app --ignore-not-found=true
kubectl -n apps delete service notebook-app --ignore-not-found=true
kubectl -n apps delete deployment notebook-app --ignore-not-found=true
kubectl -n apps delete certificate dev-notebook-kakde-eu-tls --ignore-not-found=true
kubectl -n apps delete certificate notebook-kakde-eu-tls --ignore-not-found=true
kubectl -n apps delete secret dev-notebook-kakde-eu-tls --ignore-not-found=true
kubectl -n apps delete secret notebook-kakde-eu-tls --ignore-not-found=true
Then the remaining old objects were found and deleted:
kubectl -n apps delete ingress notebook-app-ingress --ignore-not-found=true
kubectl -n apps delete service notebook-app-dev --ignore-not-found=true
kubectl -n apps delete deployment notebook-app-dev --ignore-not-found=true
kubectl -n apps delete certificate notebook-kakde-eu-tls --ignore-not-found=true
kubectl -n apps delete secret notebook-kakde-eu-tls --ignore-not-found=true
kubectl -n apps delete secret notebook-kakde-eu-tls-wpws5 --ignore-not-found=true
Verification
kubectl -n apps get all,ingress,certificate,secret | grep notebook || echo "OK: no notebook resources left in apps"
Good looks like
The expected final output was:
OK: no notebook resources left in apps
Notebook app: create namespaces and deploy
Step 1: create the namespaces
Run on ms-1:
kubectl create namespace apps-dev --dry-run=client -o yaml | kubectl apply -f -
kubectl create namespace apps-prod --dry-run=client -o yaml | kubectl apply -f -
What these commands do
kubectl create namespace ... --dry-run=client -o yamlcreates a namespace manifest without directly applying itkubectl apply -f -applies that manifest safely
Verify
kubectl get ns apps-dev apps-prod
Good looks like
Both namespaces should appear.
Step 2: render manifests before applying
Run on ms-1 from the notebook app directory:
kubectl kustomize overlays/dev
kubectl kustomize overlays/prod
kubectl kustomize builds final manifests from a Kustomize directory. Kubernetes documents this command and Kustomize workflow officially. (Kubernetes)
Why rendering first matters
It lets the operator inspect the final YAML before it touches the cluster.
Things to check
For dev:
- all resources should show
namespace: apps-dev - host should be
dev.notebook.kakde.eu
For prod:
- all resources should show
namespace: apps-prod - host should be
notebook.kakde.eu - replicas should be
2
Step 3: apply manifests
Run on ms-1:
kubectl apply -k overlays/dev
kubectl apply -k overlays/prod
What these commands do
-kmeans apply a Kustomize directory- dev and prod are applied separately
Verify rollout
kubectl -n apps-dev rollout status deploy/notebook-app --timeout=180s
kubectl -n apps-prod rollout status deploy/notebook-app --timeout=180s
Good looks like
Each command should report successful rollout.
Notebook app: verification steps
Verify resources
Run on ms-1:
kubectl -n apps-dev get deploy,svc,ingress,certificate,secret
kubectl -n apps-prod get deploy,svc,ingress,certificate,secret
Good looks like
Dev:
- deployment exists
- service exists
- ingress exists
- certificate becomes
True - secret exists
Prod:
- deployment exists with 2 replicas
- service exists
- ingress exists
- certificate becomes
True - secret exists
Verify endpoints
kubectl -n apps-dev get endpoints notebook-app
kubectl -n apps-prod get endpoints notebook-app
Good looks like
The Service should have endpoints. If it has none, the Service selector probably does not match the Pod labels.
Verify the Ingress
kubectl -n apps-dev get ingress notebook-app -o yaml
kubectl -n apps-prod get ingress notebook-app -o yaml
Check for these fields
spec:
ingressClassName: traefik
and:
annotations:
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.tls: "true"
Traefik’s Kubernetes Ingress provider is documented officially by Traefik, and cert-manager documents the annotation-driven Ingress flow that creates Certificate resources from Ingress definitions. (doc.traefik.io)
Edge-node local verification
Run on ctb-edge-1:
Dev
curl -k --resolve dev.notebook.kakde.eu:443:127.0.0.1 https://dev.notebook.kakde.eu/ -I
curl -k --resolve dev.notebook.kakde.eu:443:127.0.0.1 https://dev.notebook.kakde.eu/
Prod
curl -k --resolve notebook.kakde.eu:443:127.0.0.1 https://notebook.kakde.eu/ -I
curl -k --resolve notebook.kakde.eu:443:127.0.0.1 https://notebook.kakde.eu/
Good looks like
- not a Traefik 404
- HTTP headers return successfully
- the page comes from the notebook application
Production certificate failure: root cause and fix
This was the most important troubleshooting event in the document.
Symptom
Dev certificate succeeded, but prod certificate stayed False.
Dev:
dev-notebook-kakde-eu-tlsbecame ready
Prod:
notebook-kakde-eu-tlsstayedFalse
Why this was significant
Because the app objects themselves were healthy:
- Deployment was healthy
- Service existed
- Ingress existed
That narrowed the problem to the certificate issuance path.
Investigation commands used on ms-1
Inspect the certificate
kubectl -n apps-prod describe certificate notebook-kakde-eu-tls
kubectl -n apps-prod get certificate notebook-kakde-eu-tls -o yaml
Inspect cert-manager pipeline objects
kubectl -n apps-prod get certificaterequest,order,challenge
kubectl -n apps-prod describe certificaterequest
kubectl -n apps-prod describe order
kubectl -n apps-prod describe challenge
Check the Ingress
kubectl -n apps-prod get ingress notebook-app -o yaml
Check the issuer
kubectl get clusterissuer letsencrypt-prod-dns01 -o wide
kubectl describe clusterissuer letsencrypt-prod-dns01
Check cert-manager logs
kubectl -n cert-manager logs deploy/cert-manager --tail=200 | egrep -i 'notebook|order|challenge|error|fail'
Root cause found
The failure was not caused by Traefik, not by the notebook manifest layout, and not by the namespace split.
The issue was a CAA-related certificate issuance failure at Let’s Encrypt finalize time.
In plain language
A CAA record is a DNS record that says which certificate authorities are allowed to issue TLS certificates for a domain. Cloudflare documents CAA records this way, and Let’s Encrypt’s ecosystem uses them during issuance checks. (Cloudflare Docs)
The fix chosen in the document was to add a CAA record in Cloudflare allowing Let’s Encrypt.
Cloudflare fix used
In the Cloudflare UI, the user did not see a raw field called issue. The correct Cloudflare mapping was:
- Tag:
Only allow specific hostnames - CA domain name:
letsencrypt.org
The Cloudflare CAA docs explain that the dashboard asks for a CAA record type, name, tag, and CA domain. (Cloudflare Docs)
Choice used
At the zone root (@), add:
- Type:
CAA - Name:
@ - Flags:
0 - Tag:
Only allow specific hostnames - CA domain name:
letsencrypt.org
Optional wildcard version:
- Type:
CAA - Name:
@ - Flags:
0 - Tag:
Only allow wildcards - CA domain name:
letsencrypt.org
Verify CAA from ms-1
apt-get update && apt-get install -y dnsutils
dig +short CAA kakde.eu @1.1.1.1
dig +short CAA notebook.kakde.eu @1.1.1.1
dig +short CAA kakde.eu @8.8.8.8
dig +short CAA notebook.kakde.eu @8.8.8.8
Good looks like
The output should include:
0 issue "letsencrypt.org"
Retry certificate issuance cleanly
kubectl -n apps-prod delete order,certificaterequest,challenge --all --ignore-not-found=true
kubectl -n apps-prod delete certificate notebook-kakde-eu-tls --ignore-not-found=true
kubectl -n apps-prod delete secret notebook-kakde-eu-tls --ignore-not-found=true
kubectl apply -k overlays/prod
Important kubectl lesson learned
This command failed:
kubectl -n apps-prod get certificate,certificaterequest,order,challenge,secret -w
because kubectl get -w watches only one resource type at a time.
Correct watch method
watch -n 2 'kubectl -n apps-prod get certificate,certificaterequest,order,challenge,secret'
Final success check
kubectl -n apps-prod get certificate,secret
kubectl -n apps-prod describe certificate notebook-kakde-eu-tls
Good looks like
- certificate is
True - secret exists
- prod HTTPS works
Edge verification
Run on ctb-edge-1:
curl -k --resolve notebook.kakde.eu:443:127.0.0.1 https://notebook.kakde.eu/ -I
curl -k --resolve notebook.kakde.eu:443:127.0.0.1 https://notebook.kakde.eu/
Safety note
The document also noted that repeated failed certificate attempts can run into Let’s Encrypt failed-validation limits. Let’s Encrypt documents validation and rate-limit behavior officially. (letsencrypt.org)
Portfolio app: cleanup and final deployment pattern
After notebook was working, the same pattern was extended to a portfolio app.
Goal
Deploy:
- dev URL:
dev.kakde.eu - prod URL:
kakde.eu
with:
- internal name
portfolio-app - no public legacy objects left in
apps - prod replicas = 2
Cleanup in apps
First inspect old resources:
kubectl -n apps get all,ingress,certificate,secret | grep -E 'portfolio|kakde-eu-tls|dev-kakde-eu-tls' || echo "OK: no portfolio resources found in apps"
This found at least one leftover:
secret/portfolio-app-tls
Delete old portfolio items only:
kubectl -n apps delete secret portfolio-app-tls --ignore-not-found=true
kubectl -n apps delete ingress portfolio-app --ignore-not-found=true
kubectl -n apps delete ingress portfolio-app-ingress --ignore-not-found=true
kubectl -n apps delete service portfolio-app --ignore-not-found=true
kubectl -n apps delete deployment portfolio-app --ignore-not-found=true
kubectl -n apps delete certificate portfolio-app-tls --ignore-not-found=true
Verify:
kubectl -n apps get all,ingress,certificate,secret | grep portfolio || echo "OK: no portfolio resources left in apps"
Final portfolio layout
portfolio-app/
├── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
└── overlays/
├── dev/
│ ├── ingress.yaml
│ └── kustomization.yaml
└── prod/
├── ingress.yaml
└── kustomization.yaml
Main differences from notebook
- dev hostname =
dev.kakde.eu - prod hostname =
kakde.eu - prod replicas = 2
Important safety note
Because kakde.eu is the root domain, the portfolio prod Ingress becomes the main site for the entire public domain. Only one app should own that hostname at a time.
Reusable golden template: dummy-app-template
At the end of the document, a generic template was created for future apps.
Directory layout
dummy-app-template/
├── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
├── overlays/
│ ├── dev/
│ │ ├── ingress.yaml
│ │ └── kustomization.yaml
│ └── prod/
│ ├── ingress.yaml
│ └── kustomization.yaml
└── scripts/
├── render.sh
├── apply.sh
├── verify.sh
└── cleanup-old-from-apps.sh
Base deployment template
apiVersion: apps/v1
kind: Deployment
metadata:
name: dummy-app-template
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: dummy-app-template
template:
metadata:
labels:
app.kubernetes.io/name: dummy-app-template
spec:
terminationGracePeriodSeconds: 20
containers:
- name: dummy-app-template
image: REPLACE_WITH_IMAGE
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: REPLACE_WITH_CONTAINER_PORT
readinessProbe:
httpGet:
path: /
port: REPLACE_WITH_CONTAINER_PORT
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
securityContext:
seccompProfile:
type: RuntimeDefault
Base service template
apiVersion: v1
kind: Service
metadata:
name: dummy-app-template
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: dummy-app-template
ports:
- name: http
port: 80
targetPort: REPLACE_WITH_CONTAINER_PORT
protocol: TCP
Base kustomization template
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
labels:
- pairs:
app.kubernetes.io/name: dummy-app-template
includeSelectors: true
includeTemplates: true
resources:
- deployment.yaml
- service.yaml
Dev overlay template
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: apps-dev
resources:
- ../../base
- ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: dummy-app-template
annotations:
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: letsencrypt-staging-dns01
spec:
ingressClassName: traefik
tls:
- hosts:
- dev.dummy-app-template.kakde.eu
secretName: dev-dummy-app-template-kakde-eu-tls
rules:
- host: dev.dummy-app-template.kakde.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: dummy-app-template
port:
number: 80
Prod overlay template
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: apps-prod
resources:
- ../../base
- ingress.yaml
patches:
- patch: |-
- op: replace
path: /spec/replicas
value: 2
target:
kind: Deployment
name: dummy-app-template
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: dummy-app-template
annotations:
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: letsencrypt-prod-dns01
spec:
ingressClassName: traefik
tls:
- hosts:
- dummy-app-template.kakde.eu
secretName: dummy-app-template-kakde-eu-tls
rules:
- host: dummy-app-template.kakde.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: dummy-app-template
port:
number: 80
Generic scripts for future app deployments
scripts/render.sh
#!/usr/bin/env bash
set -euo pipefail
echo "=== DEV RENDER ==="
kubectl kustomize overlays/dev
echo
echo "=== PROD RENDER ==="
kubectl kustomize overlays/prod
scripts/apply.sh
#!/usr/bin/env bash
set -euo pipefail
echo "=== APPLY DEV ==="
kubectl apply -k overlays/dev
echo
echo "=== APPLY PROD ==="
kubectl apply -k overlays/prod
echo
echo "=== ROLLOUT STATUS ==="
kubectl -n apps-dev rollout status deployment/dummy-app-template --timeout=180s
kubectl -n apps-prod rollout status deployment/dummy-app-template --timeout=180s
scripts/verify.sh
#!/usr/bin/env bash
set -euo pipefail
echo "=== DEV OBJECTS ==="
kubectl -n apps-dev get deploy,svc,ingress,certificate,secret | grep dummy-app-template || true
echo
echo "=== PROD OBJECTS ==="
kubectl -n apps-prod get deploy,svc,ingress,certificate,secret | grep dummy-app-template || true
echo
echo "=== DEV ENDPOINTS ==="
kubectl -n apps-dev get endpoints dummy-app-template
echo
echo "=== PROD ENDPOINTS ==="
kubectl -n apps-prod get endpoints dummy-app-template
echo
echo "=== DEV INGRESS ==="
kubectl -n apps-dev get ingress dummy-app-template -o yaml
echo
echo "=== PROD INGRESS ==="
kubectl -n apps-prod get ingress dummy-app-template -o yaml
scripts/cleanup-old-from-apps.sh
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="dummy-app-template"
kubectl -n apps delete ingress "${APP_NAME}" --ignore-not-found=true
kubectl -n apps delete ingress "${APP_NAME}-ingress" --ignore-not-found=true
kubectl -n apps delete service "${APP_NAME}" --ignore-not-found=true
kubectl -n apps delete service "${APP_NAME}-dev" --ignore-not-found=true
kubectl -n apps delete deployment "${APP_NAME}" --ignore-not-found=true
kubectl -n apps delete deployment "${APP_NAME}-dev" --ignore-not-found=true
kubectl -n apps delete certificate "${APP_NAME}-kakde-eu-tls" --ignore-not-found=true
kubectl -n apps delete certificate "dev-${APP_NAME}-kakde-eu-tls" --ignore-not-found=true
kubectl -n apps delete secret "${APP_NAME}-kakde-eu-tls" --ignore-not-found=true
kubectl -n apps delete secret "dev-${APP_NAME}-kakde-eu-tls" --ignore-not-found=true
kubectl -n apps get all,ingress,certificate,secret | grep "${APP_NAME}" || echo "OK: no ${APP_NAME} resources left in apps"
Operational troubleshooting guide
1. TLS secret is missing
Run on ms-1:
kubectl -n apps-dev get certificate,certificaterequest,order,challenge,secret
kubectl -n apps-prod get certificate,certificaterequest,order,challenge,secret
Then describe the certificate:
kubectl -n apps-dev describe certificate <tls-secret-name>
kubectl -n apps-prod describe certificate <tls-secret-name>
Check cert-manager logs:
kubectl -n cert-manager logs deploy/cert-manager --tail=200
2. Traefik returns 404
Inspect the Ingress:
kubectl -n apps-dev get ingress <app-name> -o yaml
kubectl -n apps-prod get ingress <app-name> -o yaml
Check for:
spec.ingressClassName: traefik- annotation
kubernetes.io/ingress.class: traefik - annotation
traefik.ingress.kubernetes.io/router.tls: "true"
Then test on ctb-edge-1:
curl -k --resolve host:443:127.0.0.1 https://host/ -I
curl -k --resolve host:443:127.0.0.1 https://host/
3. Service/backend mismatch
Render manifests before applying:
kubectl kustomize overlays/dev
kubectl kustomize overlays/prod
Inspect live objects:
kubectl -n apps-dev get deploy,svc,ingress,endpoints
kubectl -n apps-prod get deploy,svc,ingress,endpoints
Make sure all these match:
- Deployment name
- Pod label
- Service selector
- Ingress backend Service name
4. No Service endpoints
Check labels:
kubectl -n apps-dev get pods --show-labels
kubectl -n apps-dev get svc <app-name> -o yaml
kubectl -n apps-prod get pods --show-labels
kubectl -n apps-prod get svc <app-name> -o yaml
5. Legacy apps namespace still contains old objects
Inspect:
kubectl -n apps get all,ingress,certificate,secret | grep <app-name>
Delete only the stale objects for that app.
6. CAA or DNS problem blocks certificate issuance
Verify CAA:
dig +short CAA kakde.eu @1.1.1.1
dig +short CAA <host> @1.1.1.1
If needed, check Cloudflare CAA configuration and retry certificate issuance after the record propagates. Cloudflare documents CAA creation steps directly, and cert-manager documents Certificate and Ingress-based issuance flows. (Cloudflare Docs)
Commands reference by machine
Run on ms-1
Use ms-1 for:
kubectloperations- Kustomize rendering
- namespace creation
- app deployment
- cert-manager inspection
- DNS verification with
dig
Common commands:
kubectl kustomize overlays/dev
kubectl apply -k overlays/dev
kubectl -n apps-prod describe certificate notebook-kakde-eu-tls
dig +short CAA kakde.eu @1.1.1.1
Run on ctb-edge-1
Use ctb-edge-1 for local edge validation:
curl -k --resolve dev.notebook.kakde.eu:443:127.0.0.1 https://dev.notebook.kakde.eu/ -I
curl -k --resolve notebook.kakde.eu:443:127.0.0.1 https://notebook.kakde.eu/ -I
Important lessons learned
-
Do not use
nameSuffix: -devin this cluster’s app pattern. -
Stable internal names are good, but only if dev and prod live in different namespaces.
-
Render first, apply second.
-
Old resources in
appscan silently interfere with newer deployments. -
A broken production certificate is not always a Kubernetes problem.
-
Cloudflare CAA settings can block Let’s Encrypt issuance even when manifests look correct.
-
Testing Traefik locally requires the correct hostname, not just
127.0.0.1. -
This cluster specifically needed both Ingress class mechanisms:
spec.ingressClassNamekubernetes.io/ingress.class
Contradictions, assumptions, and unresolved gaps
Contradictions
1. Modern Kubernetes guidance vs cluster behavior
Kubernetes generally treats ingressClassName as the newer preferred mechanism, but this cluster still required the old annotation as well for Traefik routing to behave correctly. That is a real-world cluster-specific finding, not a theoretical best-practice contradiction. (Kubernetes)
2. One namespace vs two namespaces
At first, stable internal names looked like enough. Later, it became clear that dev and prod cannot share the same names in the same namespace. Namespace split resolved the conflict.
Assumptions
- Portfolio app container port was treated as needing confirmation.
- Notebook app image and port were already known from the rendered manifests.
apps-devandapps-prodare the long-term application namespaces going forward.
Unresolved gaps
- The final portfolio manifests still needed the real image and confirmed port when this document ended.
- Database deployment had not yet begun; only a prompt for the next document was prepared.
- This document covers only the current document, not the earlier project history.
Glossary
K3s A lightweight Kubernetes distribution.
Kubernetes A platform for running and managing containerized applications.
Namespace A logical partition inside Kubernetes. It is used to separate environments or teams.
Deployment A Kubernetes object that manages stateless Pods and rolling updates.
Pod The smallest deployable Kubernetes unit. Usually contains one application container.
Service A stable internal network endpoint for reaching Pods.
ClusterIP Service An internal-only Service reachable from inside the cluster.
Ingress A Kubernetes object used to route HTTP/HTTPS traffic to Services.
Traefik The Ingress controller used in this cluster to receive web traffic and send it to the correct app.
cert-manager A Kubernetes tool that automates certificate issuance and renewal.
Certificate A cert-manager resource describing the desired TLS certificate.
Secret A Kubernetes object used to store sensitive values, such as TLS material.
Kustomize
A way to build Kubernetes YAML from a common base plus environment-specific overlays. Kubernetes supports this through kubectl kustomize and kubectl apply -k. (Kubernetes)
CAA record A DNS record that declares which certificate authorities are allowed to issue certificates for a domain. Cloudflare documents this as part of its SSL/TLS and DNS guidance. (Cloudflare Docs)
Suggested next step
The next logical step prepared in the document was to deploy an internal PostgreSQL instance with:
- 80 GiB persistent storage
- no public exposure
- no Ingress
- no LoadBalancer
- no NodePort
- internal-only Kubernetes access
That work had not started yet in this document, but the prompt was already prepared for a follow-up conversation.
Official and high-quality learning links
These are useful references for the concepts used in this document:
- Kubernetes Ingress overview: official Kubernetes docs on how Ingress works. (Kubernetes)
- Kubernetes Ingress controllers and
ingressClassName: official Kubernetes docs on controller behavior and class selection. (Kubernetes) kubectl kustomize: official Kubernetes command reference. (Kubernetes)- Kustomize workflow: official Kubernetes task guide for declarative management using Kustomize. (Kubernetes)
- Traefik Kubernetes Ingress provider: official Traefik documentation. (doc.traefik.io)
- cert-manager Certificate resource: official cert-manager docs for how Certificate resources work. (cert-manager)
- cert-manager and Ingress annotations: official cert-manager documentation for Ingress-driven certificate generation. (cert-manager)
- Cloudflare CAA records: official Cloudflare docs showing how to create CAA records in the dashboard. (Cloudflare Docs)
- Cloudflare certificate authority reference: Cloudflare explanation of CA-related SSL/TLS behavior. (Cloudflare Docs)
- Let’s Encrypt rate limits: official documentation on validation and issuance limits. (letsencrypt.org)
Final summary
This document standardized the Homelab-0 application deployment model.
The final model is:
- one base
- two overlays
- dev in
apps-dev - prod in
apps-prod - no environment suffix in object names
- environment separation by namespace, hostname, and issuer
- Traefik Ingress with both class settings plus
router.tls=true - cert-manager-managed TLS
- Cloudflare CAA awareness for certificate troubleshooting
This is now the reference pattern for future internet-exposed apps in the cluster.
GitOps Deployment Guide
Current note This is a detailed historical deep dive. For the current GitOps and app flow, start with 01-platform-overview.md and 12-data-and-apps-step-by-step.md.
Current repo note: the maintained overlays now live under
k8s-cluster/apps/, and the Argo CD application manifests live underk8s-cluster/platform/argocd/applications/. Treat any laterdeploy/...examples in this document as historical path names.
Table of contents
- Overview
- GitOps flow and architecture
- Design decisions
- Repository structure
- Step-by-step implementation
- Validation and verification
- Troubleshooting
- Design trade-offs
- Next steps
- Further learning
Overview
This document explains how to complete a simple GitOps deployment flow for the note-book application using:
- GitHub Actions
- Docker Hub
- an
infrarepository - Argo CD
- Kubernetes
- Kustomize overlays
- the namespaces
apps-devandapps-prod
What this setup is trying to achieve
The final system should do this automatically:
- Code is pushed to the
note-bookrepository. - GitHub Actions builds a Docker image.
- GitHub Actions pushes that image to Docker Hub.
- GitHub Actions updates the image tag inside the
infrarepository. - Argo CD watches the
infrarepository and notices that change. - Argo CD deploys the new image into Kubernetes automatically.
This is a classic GitOps model.
what GitOps means
GitOps means Git becomes the source of truth for deployment state.
That means:
- application code can live in one repository
- infrastructure and Kubernetes deployment configuration live in another repository
- the cluster should follow what is written in the infrastructure repository
- Argo CD acts like a “Git watcher” for Kubernetes
Instead of a developer running kubectl apply manually after every change, the desired state is committed to Git, and Argo CD makes the cluster match Git.
Current architecture and important facts
From the document, these are the agreed current facts.
Repositories
Application repository
https://github.com/ani2fun/note-book/
Infrastructure repository
https://github.com/ani2fun/infra
Cluster and platform facts
-
K3s cluster is already running
-
Argo CD is already installed and healthy
-
Argo CD is exposed with Traefik
-
Argo CD is reachable at
argocd.kakde.eu -
Argo CD workloads are pinned to
wk-2 -
the cluster already uses standard Kubernetes manifests and Kustomize-style layout
-
the preferred style is simple and explicit Kubernetes YAML
-
the app namespaces are:
apps-devapps-prod
Important namespace correction
Earlier in the discussion, homelab was used in examples.
That is no longer correct for application deployment.
The corrected namespace model is:
apps-devfor development workloadsapps-prodfor production workloads
Infra repository layout already present
The infra repo already contains a useful Kustomize-style structure:
deploy/
note-book/
base/
overlays/
dev/
prod/
This is important because it means the final solution should use the existing structure instead of inventing a new one.
Key design decisions made in this document
Several important decisions were made and refined during the discussion.
1. The infra repo remains the source of truth
The application repo is only responsible for:
- building the Docker image
- pushing the image
- updating the deployment reference in the
infrarepo
The actual desired deployment state stays in infra.
2. Argo CD watches the infra repo, not the app repo
This is a central GitOps decision.
Why:
- deployments should be controlled from one place
- Argo CD should reconcile against infrastructure definitions
- changes to app code become deployable only after infra is updated
3. Keep Kustomize and the existing repo layout
There was no strong reason to move to Helm or a more complex pattern.
The chosen model is:
base/holds shared manifestsoverlays/devcustomizes development deploymentoverlays/prodcustomizes production deployment
4. Do not hardcode namespace in base
This was a major correction.
The right pattern is:
basestays reusable- namespace belongs in overlays
overlays/devsetsapps-devoverlays/prodsetsapps-prod
5. Do not use sed to edit full Kubernetes deployment YAML
The original workflow used a sed replacement against a deployment file.
That was rejected as the preferred solution because it is brittle.
The safer approach chosen in the document is:
- keep the image tag in
deploy/note-book/overlays/prod/kustomization.yaml - update only that one field
- use a YAML-aware update step in GitHub Actions
6. Keep the solution simple for a homelab
The discussion explicitly chose to avoid extra moving parts such as:
- Helm, unless needed later
- Argo CD Image Updater
- PR-based promotion flow, for now
- overly fancy templating systems
How the final GitOps flow works
This is the final flow, step by step.
Step 1: code changes in the note-book repo
A developer pushes a commit to the main branch of ani2fun/note-book.
Step 2: GitHub Actions builds the Docker image
The workflow builds the Docker image and tags it with:
- the commit SHA, for example
ani2fun/note-book:<sha> latest, for convenience
Step 3: the image is pushed to Docker Hub
Docker Hub becomes the place where the cluster can pull the new image from.
Step 4: the workflow checks out the infra repo
The workflow then clones the infrastructure repository into the runner.
Step 5: the workflow updates the prod overlay image tag
It updates the image tag in:
deploy/note-book/overlays/prod/kustomization.yaml
That file is the clean place to hold the production image version.
Step 6: the workflow commits the change back to infra
This creates a normal Git commit in the infra repo that says, in effect:
Production should now run this exact image tag.
Step 7: Argo CD notices the Git change
Because Argo CD watches the production overlay path, it sees that infra changed.
Step 8: Argo CD syncs Kubernetes to match Git
Argo CD applies the updated manifests to the cluster and Kubernetes rolls out the new version in apps-prod.
What was wrong with the old workflow
The original workflow was moving in the right direction, but it had multiple issues.
It built the image twice
It had one job that did a plain docker build and then another job that built again and pushed.
That wastes CI time.
It edited a stale file path
The workflow referenced:
deploy/note-book/notebook-app-deployment.yaml
But the actual repo structure now uses:
deploy/note-book/base/...deploy/note-book/overlays/dev/...deploy/note-book/overlays/prod/...
So the old path no longer matched the real layout.
It used sed on a full manifest
That is fragile because:
- formatting changes can break it
- multiple image lines can cause accidental replacements
- it is difficult to maintain
It assumed the wrong namespace model
Earlier examples used homelab, but the corrected environment now uses:
apps-devapps-prod
It did not clearly minimize permissions
The improved workflow explicitly limits default permissions and uses a dedicated token for writing to the infra repo.
Final repository structure
This is the agreed structure at a high level.
infra/
├── README.md
├── _docs/
├── argocd/
│ └── apps/
│ └── note-book.yaml
└── deploy/
├── dummy-app-template/
└── note-book/
├── base/
└── overlays/
├── dev/
└── prod/
The important point is that deploy/note-book/ already exists and should be kept.
Why this structure is good
It is easy to understand:
base/= common resourcesoverlays/dev/= development-specific settingsoverlays/prod/= production-specific settingsargocd/apps/= Argo CD application definitions
This is a good balance between organization and simplicity.
Files that matter and what each one does
This section explains the important files without reprinting the deploy/note-book/ YAML.
In the infra repo
deploy/note-book/base/*
These files contain the shared Kubernetes resources for the notebook app.
Important rule from the document:
- no hardcoded app namespace in base
deploy/note-book/overlays/dev/kustomization.yaml
This overlay should set:
- namespace to
apps-dev - any dev-specific image or routing settings
deploy/note-book/overlays/prod/kustomization.yaml
This overlay should set:
- namespace to
apps-prod - the production image tag under the
images:section
This is the file that GitHub Actions updates during promotion.
deploy/note-book/overlays/dev/ingress.yaml
This should describe how development traffic reaches the dev app, if dev is exposed.
deploy/note-book/overlays/prod/ingress.yaml
This should describe how production traffic reaches the prod app.
argocd/apps/note-book.yaml
This file defines the Argo CD Application resource.
It tells Argo CD:
- which repo to watch
- which path to watch
- which cluster to deploy to
- which namespace the target app belongs to
In the note-book repo
.github/workflows/build-push-promote.yml
This workflow:
- builds the app image
- pushes it to Docker Hub
- updates the prod image tag in
infra - pushes the infra commit
Prerequisites
Before starting, the operator should have:
On the local machine
gitkubectl- access to both repositories
- permission to create or update GitHub secrets
- a valid kubeconfig that can reach the cluster
In GitHub
The note-book repository needs secrets for:
- Docker Hub login
- Git author identity
- pushing to
ani2fun/infra
In Kubernetes
The cluster must already have:
-
Argo CD installed
-
the
argocdnamespace -
Traefik
-
cert-manager, if TLS ingress is being used
-
namespaces:
apps-devapps-prod
Step-by-step implementation
Step 1: confirm the namespaces exist
The first thing is to make sure the application namespaces exist.
Where to run: on any machine that already has kubectl access to the cluster.
Command:
kubectl get ns apps-dev apps-prod
What it does:
- checks whether the two namespaces exist
What success looks like:
- both namespaces are listed
If one or both do not exist, create them.
Command:
kubectl create namespace apps-dev
kubectl create namespace apps-prod
What success looks like:
namespace/apps-dev creatednamespace/apps-prod created
If they already exist, Kubernetes will return an error saying they already exist. That is fine.
Step 2: confirm the notebook overlay files follow the agreed model
The operator already has the deploy/note-book/ YAML, so this guide does not print those files again. Instead, it explains what must be true.
The production overlay must point to apps-prod
Where to run: local machine, inside the infra repo.
Open the file:
cd infra
nano deploy/note-book/overlays/prod/kustomization.yaml
What to confirm:
- it contains
namespace: apps-prod - it has an
images:section - the
images:section includesani2fun/note-book - the image tag is controlled here
The development overlay must point to apps-dev
Where to run: local machine, inside the infra repo.
Command:
nano deploy/note-book/overlays/dev/kustomization.yaml
What to confirm:
- it contains
namespace: apps-dev
The base manifests must not hardcode namespace
Where to run: local machine, inside the infra repo.
Commands:
grep -R "namespace:" deploy/note-book/base
What it does:
- searches the base folder for hardcoded namespace lines
What success looks like:
- ideally, it returns no app namespace lines for base resources
If base still contains namespace: homelab or any app namespace, remove it manually.
Step 3: create or update the Argo CD Application manifest
This file is not under deploy/note-book/, so it is safe to include fully.
Where to run: local machine inside the infra repo.
Create the folder if needed:
mkdir -p argocd/apps
Create the file:
cat > argocd/apps/note-book.yaml <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: note-book
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/ani2fun/infra.git
targetRevision: main
path: deploy/note-book/overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: apps-prod
syncPolicy:
automated:
prune: true
selfHeal: true
EOF
What it does:
- creates an Argo CD application definition
- tells Argo CD to watch the production notebook overlay
- tells Argo CD to deploy it into
apps-prod
What success looks like:
- the file exists
cat argocd/apps/note-book.yamlshows the manifest
Why this manifest matters
This file is how Argo CD knows:
- where the desired state lives
- what path to render
- where to deploy it
Without this Application object, Argo CD does not know it should track this notebook deployment.
Step 4: render the production overlay locally before applying anything
Rendering is a very important beginner habit.
It means: “show me the final Kubernetes YAML after Kustomize combines base + overlay.”
Where to run: local machine inside the infra repo.
Command:
kubectl kustomize deploy/note-book/overlays/prod
What it does:
- renders the final production manifests
What success looks like:
- YAML output is printed
- resources include Deployment, Service, and Ingress
- rendered namespace should be
apps-prod
A useful verification command:
kubectl kustomize deploy/note-book/overlays/prod | grep '^ namespace:\|^namespace:'
What success looks like:
- the output shows
apps-prod
If rendering fails, do not continue yet. Fix the overlay first.
Step 5: commit and push the infra repo changes
Where to run: local machine inside the infra repo.
Commands:
git status
git add argocd/apps/note-book.yaml deploy/note-book
git commit -m "feat(note-book): finalize GitOps deployment for apps-prod"
git push origin main
What they do:
- show pending changes
- stage the Argo CD app and notebook deployment updates
- create a Git commit
- push it to GitHub
What success looks like:
- commit succeeds
- push succeeds
Step 6: apply the Argo CD Application to the cluster
Even though the app manifest is stored in Git, it still needs to be created in the cluster once.
Where to run: any machine with kubectl access and the file available.
Command:
kubectl apply -n argocd -f argocd/apps/note-book.yaml
What it does:
- creates or updates the Argo CD Application resource in the cluster
What success looks like:
application.argoproj.io/note-book created- or
application.argoproj.io/note-book configured
Then verify:
kubectl get application -n argocd note-book
kubectl describe application -n argocd note-book
What success looks like:
- the Application exists
- source path is
deploy/note-book/overlays/prod - destination namespace is
apps-prod
Step 7: update the GitHub Actions workflow in the note-book repo
This workflow was fully designed in the document and is included here because it is not part of deploy/note-book/.
Where to run: local machine.
Clone the repo if needed:
git clone https://github.com/ani2fun/note-book.git
cd note-book
mkdir -p .github/workflows
Create the workflow file:
cat > .github/workflows/build-push-promote.yml <<'EOF'
name: Build, Push, and Promote Note-Book
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: note-book-main
cancel-in-progress: true
permissions:
contents: read
env:
IMAGE_REPO: ani2fun/note-book
INFRA_REPO: ani2fun/infra
INFRA_KUSTOMIZATION_FILE: deploy/note-book/overlays/prod/kustomization.yaml
jobs:
build-push-promote:
name: Build image and update infra repo
runs-on: ubuntu-latest
steps:
- name: Checkout application repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_REPO }}:${{ github.sha }}
${{ env.IMAGE_REPO }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Checkout infra repository
uses: actions/checkout@v4
with:
repository: ${{ env.INFRA_REPO }}
ref: main
token: ${{ secrets.INFRA_REPO_TOKEN }}
path: infra
- name: Install PyYAML
run: python3 -m pip install --user pyyaml
- name: Update production image tag in infra repo
env:
IMAGE_TAG: ${{ github.sha }}
KUSTOMIZATION_PATH: infra/${{ env.INFRA_KUSTOMIZATION_FILE }}
run: |
python3 - <<'PY'
import os
from pathlib import Path
import yaml
path = Path(os.environ["KUSTOMIZATION_PATH"])
data = yaml.safe_load(path.read_text())
images = data.setdefault("images", [])
found = False
for image in images:
if image.get("name") == "ani2fun/note-book":
image["newName"] = "ani2fun/note-book"
image["newTag"] = os.environ["IMAGE_TAG"]
found = True
break
if not found:
images.append({
"name": "ani2fun/note-book",
"newName": "ani2fun/note-book",
"newTag": os.environ["IMAGE_TAG"],
})
path.write_text(yaml.safe_dump(data, sort_keys=False))
PY
- name: Configure Git identity
run: |
git -C infra config user.name "${{ secrets.INFRA_GIT_USER_NAME }}"
git -C infra config user.email "${{ secrets.INFRA_GIT_USER_EMAIL }}"
- name: Commit and push infra change
env:
IMAGE_TAG: ${{ github.sha }}
run: |
git -C infra add "${{ env.INFRA_KUSTOMIZATION_FILE }}"
if git -C infra diff --cached --quiet; then
echo "No infra changes to commit."
exit 0
fi
git -C infra commit -m "chore(note-book): promote image to ${IMAGE_TAG}"
git -C infra pull --rebase origin main
git -C infra push origin HEAD:main
EOF
Commit and push it:
git add .github/workflows/build-push-promote.yml
git commit -m "ci(note-book): build image and promote prod image tag"
git push origin main
What this workflow does
- builds and pushes the Docker image only once
- updates the production overlay image tag in the infra repo
- commits the promotion to Git
- avoids brittle
sed - uses a safer YAML-aware Python update
Why the Python step was chosen
It updates the images: section in a structured way.
That is safer than:
- doing string replacement across a large YAML file
- guessing where the image line is
- depending on layout formatting
Step 8: create the required GitHub secrets
Where to do this: in the GitHub UI for the ani2fun/note-book repository, or with GitHub CLI.
Required secrets:
DOCKERHUB_USERNAMEDOCKERHUB_TOKENINFRA_REPO_TOKENINFRA_GIT_USER_NAMEINFRA_GIT_USER_EMAIL
What each secret is for
DOCKERHUB_USERNAME The Docker Hub username.
DOCKERHUB_TOKEN A Docker Hub access token used for pushing the image.
INFRA_REPO_TOKEN
A GitHub token that can write to ani2fun/infra.
INFRA_GIT_USER_NAME The Git author name used by automation.
INFRA_GIT_USER_EMAIL The Git author email used by automation.
Recommended permissions for INFRA_REPO_TOKEN
Use a fine-grained PAT with access only to:
ani2fun/infra
And permissions:
- Contents: Read and write
- Metadata: Read
Optional GitHub CLI commands
Where to run: local machine with gh installed and authenticated.
gh secret set DOCKERHUB_USERNAME --repo ani2fun/note-book --body "ani2fun"
gh secret set DOCKERHUB_TOKEN --repo ani2fun/note-book --body "<dockerhub-token>"
gh secret set INFRA_REPO_TOKEN --repo ani2fun/note-book --body "<github-token>"
gh secret set INFRA_GIT_USER_NAME --repo ani2fun/note-book --body "ani2fun-bot"
gh secret set INFRA_GIT_USER_EMAIL --repo ani2fun/note-book --body "you@example.com"
What success looks like:
- no error output
- the secrets appear in the repository settings
Step 9: trigger the pipeline
There are two easy ways.
Option 1: push a real code change
Where to run: local machine inside the note-book repo.
git add .
git commit -m "feat: update note-book app"
git push origin main
Option 2: trigger with an empty commit for testing
This is useful when testing the CI/CD pipeline.
Where to run: local machine inside the note-book repo.
git commit --allow-empty -m "chore: trigger notebook GitOps pipeline"
git push origin main
What success looks like:
- the workflow starts in GitHub Actions
- the workflow completes successfully
Exact commands to run
This section is a compact runbook version.
Check namespaces
Run on a machine with cluster access:
kubectl get ns apps-dev apps-prod
If missing:
kubectl create namespace apps-dev
kubectl create namespace apps-prod
Check that base has no hardcoded namespace
Run inside the infra repo:
grep -R "namespace:" deploy/note-book/base
Open overlay files for review
Run inside the infra repo:
nano deploy/note-book/overlays/dev/kustomization.yaml
nano deploy/note-book/overlays/prod/kustomization.yaml
Create Argo CD app manifest
Run inside the infra repo:
mkdir -p argocd/apps
cat > argocd/apps/note-book.yaml <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: note-book
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/ani2fun/infra.git
targetRevision: main
path: deploy/note-book/overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: apps-prod
syncPolicy:
automated:
prune: true
selfHeal: true
EOF
Render production manifests locally
Run inside the infra repo:
kubectl kustomize deploy/note-book/overlays/prod
kubectl kustomize deploy/note-book/overlays/prod | grep '^ namespace:\|^namespace:'
Commit infra changes
Run inside the infra repo:
git add argocd/apps/note-book.yaml deploy/note-book
git commit -m "feat(note-book): finalize GitOps deployment for apps-prod"
git push origin main
Apply the Argo CD Application
Run on a machine with cluster access:
kubectl apply -n argocd -f argocd/apps/note-book.yaml
kubectl get application -n argocd note-book
kubectl describe application -n argocd note-book
Create the GitHub Actions workflow
Run inside the note-book repo:
mkdir -p .github/workflows
nano .github/workflows/build-push-promote.yml
Paste the workflow content from the earlier section, then save.
Commit the workflow
Run inside the note-book repo:
git add .github/workflows/build-push-promote.yml
git commit -m "ci(note-book): build image and promote prod image tag"
git push origin main
Trigger the workflow manually by commit
Run inside the note-book repo:
git commit --allow-empty -m "chore: trigger notebook GitOps pipeline"
git push origin main
Validation and verification
Validation is extremely important. A beginner should always verify each layer.
1. Verify Argo CD knows about the notebook app
Run on a machine with cluster access:
kubectl get application -n argocd note-book
kubectl describe application -n argocd note-book
What success looks like:
- the
note-bookApplication exists - it points to
deploy/note-book/overlays/prod - it targets
apps-prod
2. Verify the workflow ran successfully
Where to check: GitHub Actions UI in the note-book repo.
What success looks like:
- Docker login step succeeds
- image build step succeeds
- push step succeeds
- checkout of infra repo succeeds
- infra commit and push succeeds
3. Verify the infra repo actually changed
Run on a local machine:
git clone https://github.com/ani2fun/infra.git /tmp/infra-check
cd /tmp/infra-check
git log --oneline -n 5
grep -n "newTag:" deploy/note-book/overlays/prod/kustomization.yaml
What success looks like:
- a recent promotion commit exists
- the production overlay now contains the new image tag
4. Verify Argo CD synchronized
Run on a machine with cluster access:
kubectl get application -n argocd note-book
What success looks like:
- sync status becomes
Synced - health becomes
Healthy
5. Verify notebook is deployed in apps-prod
Run on a machine with cluster access:
kubectl -n apps-prod get deployment,service,ingress
kubectl -n apps-prod get deployment notebook-app-first -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
kubectl -n apps-prod rollout status deployment/notebook-app-first
What success looks like:
- the Deployment exists
- the Service exists
- the Ingress exists
- the image is the new SHA tag
- rollout completes successfully
6. Verify the app is reachable
Run from a machine that can reach the public hostname:
curl -I https://notebook.kakde.eu
What success looks like:
- an HTTP response comes back
- common successful responses are
200,301, or302
7. Check for leftovers in old namespaces
Because earlier examples used homelab, there may be old resources left somewhere else.
Run on a machine with cluster access:
kubectl get deployment,service,ingress -A | grep notebook-app-first
What success looks like:
- only the intended
apps-proddeployment is active
If old resources still exist and the new deployment is confirmed healthy, they can be deleted carefully.
Example:
kubectl delete deployment,service,ingress -n <old-namespace> -l app.kubernetes.io/name=notebook-app-first
Be careful to replace <old-namespace> correctly.
Troubleshooting guide
Problem: apps-dev or apps-prod does not exist
Symptom
Argo CD or Kubernetes fails because the target namespace is missing.
Fix
Run:
kubectl create namespace apps-dev
kubectl create namespace apps-prod
Problem: Argo CD Application does not exist
Symptom
kubectl get application -n argocd note-book shows nothing.
Fix
Apply the manifest again:
kubectl apply -n argocd -f argocd/apps/note-book.yaml
Then verify:
kubectl get application -n argocd note-book
Problem: Argo CD says the app is OutOfSync
Symptom
The app exists but is not synced.
Common causes
- a Git change is not yet applied
- the render output is different from live state
- someone changed the live resource manually
- the overlay path is wrong
Helpful command
kubectl describe application -n argocd note-book
This usually explains the reason.
Problem: Kustomize render fails
Symptom
This command fails:
kubectl kustomize deploy/note-book/overlays/prod
Common causes
- invalid YAML
- bad relative path
- namespace problem
- wrong image structure under
images:
Fix
Open the overlay file and fix the syntax before continuing.
Problem: GitHub Actions cannot push to infra
Symptom
The workflow fails during the push to ani2fun/infra.
Common causes
INFRA_REPO_TOKENis missing- token has wrong permissions
- token does not include the
infrarepository
Fix
Recreate the token with:
- repository access limited to
ani2fun/infra - Contents: Read and write
- Metadata: Read
Problem: Workflow runs, but no change is deployed
Symptom
The workflow is green, but the cluster still runs the old image.
Check 1: did the infra repo really change?
grep -n "newTag:" deploy/note-book/overlays/prod/kustomization.yaml
Check 2: what image is running now?
kubectl -n apps-prod get deployment notebook-app-first -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
Check 3: what does Argo CD think?
kubectl describe application -n argocd note-book
Problem: Pods are crashing after rollout
Symptom
The Deployment exists, but pods fail or restart.
Commands
kubectl -n apps-prod get pods
kubectl -n apps-prod describe pod <pod-name>
kubectl -n apps-prod logs <pod-name>
Common causes
- bad application startup
- wrong port
- missing environment variables
- readiness probe failure
Problem: Old resources still exist in the previous namespace
Symptom
The notebook app appears in more than one namespace.
Check
kubectl get deployment,service,ingress -A | grep notebook-app-first
Fix
After confirming the new apps-prod version is working, delete the old resources carefully:
kubectl delete deployment,service,ingress -n <old-namespace> -l app.kubernetes.io/name=notebook-app-first
Design trade-offs and why this approach was chosen
This section explains not only the final answer, but why it was picked over other options.
Why keep the infra repo as the source of truth
Because this is the cleanest GitOps model:
- app repo builds artifacts
- infra repo declares deployment state
- Argo CD syncs from infra
That keeps responsibilities clear.
Why keep Kustomize
Because the repo already uses it well.
Kustomize is a good fit because it allows:
- a reusable base
- environment-specific overlays
- simple image overrides
- direct support from both
kubectland Argo CD
Why not put namespace in base
Because base should stay reusable.
If base hardcodes apps-prod, it cannot cleanly be reused for development.
Why not keep sed
Because sed works on text, not on YAML structure.
That makes it easier to break the deployment accidentally.
Why the Python YAML update is a good compromise
It is:
- simple
- explicit
- safer than raw text replacement
- easy to understand for a beginner
Why not use Argo CD Image Updater
Because it adds extra moving parts, and the chosen workflow already solves the problem well enough for a homelab.
Why not use Helm
Because there was no strong need. The existing Kustomize layout already solves the problem in a simpler way.
Contradictions, assumptions, and unresolved gaps
This section is important because the document history had a few evolving decisions.
Contradictions that were resolved
homelab namespace vs apps-dev / apps-prod
Earlier examples used homelab.
That was corrected later.
Final decision:
- use
apps-dev - use
apps-prod
Editing a deployment file directly vs overlay-based image update
Earlier logic edited a deployment manifest path directly with sed.
Final decision:
- update the production overlay image tag instead
Assumptions currently being made
- the production notebook deployment still uses the resource name
notebook-app-first - the production overlay already contains a valid
images:block or can be updated to include one - Argo CD is already able to access the
infrarepo - the notebook application is expected to be publicly reachable at
notebook.kakde.eu - cert-manager and Traefik are already working for other apps
Unresolved gaps
A few things were not fully specified in the document and should be double-checked by the operator:
- whether the dev overlay should be public or internal-only
- the exact hostname used for dev, if any
- whether old notebook resources still exist in another namespace
- whether the notebook app requires extra secrets or environment variables beyond what was discussed
- whether branch protection should later be added to
infra/main
Glossary
Argo CD A GitOps controller for Kubernetes. It watches Git and makes the cluster match the desired state.
GitOps A deployment approach where Git stores the desired system state, and automation applies that state.
Kustomize A way to organize Kubernetes configuration using a common base and environment-specific overlays.
Base Shared Kubernetes configuration that should work across multiple environments.
Overlay An environment-specific layer that changes or adds settings on top of the base.
Namespace A way to logically separate workloads inside a Kubernetes cluster.
Deployment A Kubernetes object that manages application pods and rolling updates.
Ingress A Kubernetes object that routes HTTP or HTTPS traffic to services.
Service A Kubernetes object that exposes a stable network endpoint for pods.
Immutable image tag An image tag tied to a unique version, often the Git commit SHA.
Docker Hub A container registry where Docker images are stored and pulled from.
PAT A Personal Access Token, often used to let GitHub Actions authenticate with GitHub.
Next steps
The operator can use this as the clean execution order:
- Confirm
apps-devandapps-prodexist. - Confirm the notebook Kustomize overlays use those namespaces.
- Confirm
basehas no hardcoded app namespace. - Create or update
argocd/apps/note-book.yaml. - Render the prod overlay locally with
kubectl kustomize. - Commit and push the infra changes.
- Apply the Argo CD Application manifest.
- Create or update the GitHub Actions workflow in
note-book. - Add the required GitHub secrets.
- Trigger the workflow.
- Verify image push, infra commit, Argo CD sync, and deployment rollout.
- Clean up any old notebook resources in previous namespaces if needed.
Official and high-quality learning links
Argo CD documentation https://argo-cd.readthedocs.io/
Argo CD Application specification https://argo-cd.readthedocs.io/en/latest/user-guide/application-specification/
Argo CD declarative setup https://argo-cd.readthedocs.io/en/stable/operator-manual/declarative-setup/
Argo CD Kustomize support https://argo-cd.readthedocs.io/en/stable/user-guide/kustomize/
Kubernetes Kustomize documentation https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/
GitHub Actions workflow syntax https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions
GitHub secrets in Actions https://docs.github.com/actions/security-guides/using-secrets-in-github-actions
Docker build-push action https://github.com/docker/build-push-action
GitHub fine-grained personal access tokens https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
GitHub Actions checkout https://github.com/actions/checkout
Operate, Verify, and Recover
This guide is the practical companion to the build tutorial.
Use it after the homelab is running to answer four questions:
- is the cluster healthy right now
- is the public edge exposing only what it should
- what should I do first when something breaks
- which data must be backed up outside Kubernetes
Before You Change Anything
When the platform is already running, slow down before making changes.
Use this short routine first:
- confirm the current health state with
kubectl get nodes -o wideandkubectl get pods -A - identify which layer is actually failing before editing manifests
- snapshot the live state with
bash k8s-cluster/live-capture/collect-live-state.shif you are about to make structural changes - change one layer at a time so recovery stays understandable
This keeps normal troubleshooting from turning into accidental drift.
1. Start With a Simple Health Check
Run these checks first from a machine with cluster-admin access:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o wide
kubectl get pods -A
kubectl get ingress -A
kubectl get application -n argocd
kubectl get certificate,challenge,order -A
Pay attention to:
- nodes that are not
Ready - pods stuck in
Pending,CrashLoopBackOff, orImagePullBackOff - missing ingresses or missing TLS secrets
- Argo CD applications that are not healthy or not synced
If the cluster looks unhealthy here, do not start changing manifests blindly. First identify whether the problem is:
- network and node health
- ingress and TLS
- GitOps
- data or identity services
2. Check the Public Exposure Model
The public edge should stay tightly controlled.
On vm-1, inspect the listening ports:
sudo ss -lntup
You should expect to see:
80/tcp443/tcp51820/udp22/tcpfor administration
The important detail is not only what is listening locally, but what is reachable from outside.
From ms-1 or another external machine, verify the edge public IP:
nmap -Pn -sT -p 22,80,443,10250 198.51.100.25
nmap -Pn -sU -p 51820 198.51.100.25
Remember:
- Traefik may still bind its dashboard/API locally on
8080 - the firewall guardrail is what prevents that port from being publicly reachable
- a self-scan from the edge node to its own public IP is not trustworthy enough for exposure testing
3. Recover the Platform in the Right Order
When the homelab is in trouble, recover the layers from the bottom up:
- WireGuard connectivity
- K3s node health
- Calico
- Traefik and the edge firewall guardrail
- cert-manager and TLS
- Argo CD
- PostgreSQL
- Keycloak
- application workloads
This order matters. For example, there is no point debugging an app ingress before the edge, DNS, and cluster networking are healthy.
Fast Triage By Layer
When you already know roughly which layer is failing, start with the shortest useful commands:
| Layer | First commands | Typical failures |
|---|---|---|
| WireGuard / host network | wg show, ip -br addr, ip route, systemctl status wg-quick@wg0 | missing tunnel, bad peer config, routing drift |
| K3s control plane / agents | kubectl get nodes -o wide, systemctl status k3s, systemctl status k3s-agent | node not ready, agent not joined, kubelet issues |
| Calico | kubectl get pods -n calico-system, kubectl logs -n calico-system ds/calico-node --tail=50 | CNI not ready, pod networking broken |
| Traefik / edge host | kubectl get pods -n traefik -o wide, sudo ss -lntup, sudo systemctl status edge-guardrail.service | ingress pod down, wrong listeners, firewall inactive |
| cert-manager | kubectl get certificate,challenge,order -A, kubectl logs -n cert-manager deploy/cert-manager --tail=100 | challenge stuck, issuer missing, bad DNS token |
| Argo CD | kubectl get application -n argocd, kubectl get pods -n argocd -o wide | app out of sync, missing CRD, pod crash |
| PostgreSQL | kubectl get pods,pvc,svc -n databases-prod, kubectl logs -n databases-prod statefulset/postgresql | pod restart, storage issue, auth mismatch |
| Keycloak | kubectl get pods,svc,ingress -n identity, kubectl logs -n identity deploy/keycloak --tail=100 | DB login failure, ingress/TLS issue, bad secret |
4. Argo CD Recovery
If Argo CD looks unhealthy, start with:
kubectl get pods -n argocd -o wide
kubectl get application -n argocd
kubectl get crd applicationsets.argoproj.io
If argocd-applicationset-controller is crashing because ApplicationSet is missing, rerun the pinned install and the homelab-specific configuration:
bash k8s-cluster/platform/argocd/install-argocd.sh
bash k8s-cluster/platform/argocd/configure-argocd.sh
kubectl get pods -n argocd -o wide
That is the quickest clean recovery for the known missing-CRD failure.
5. PostgreSQL Recovery Basics
Start with these checks:
kubectl get pods -n databases-prod -o wide
kubectl get pvc -n databases-prod
kubectl get svc -n databases-prod
kubectl get networkpolicy -n databases-prod
kubectl logs -n databases-prod statefulset/postgresql
Important things to remember:
- the PVC holds the durable database data
- deleting the StatefulSet is not the same as deleting the data
- changing the Kubernetes Secret does not automatically rotate passwords that already exist inside PostgreSQL
If the pod is gone but the PVC still exists, be careful. That usually means the data may still be recoverable by restoring the workload cleanly.
6. Keycloak Recovery Basics
Start with:
kubectl get pods -n identity -o wide
kubectl get svc -n identity
kubectl get ingress -n identity
kubectl logs -n identity deploy/keycloak --tail=100
Check these common failure points:
- database credentials in
keycloak-db-secret - PostgreSQL connectivity to
postgresql.databases-prod.svc.cluster.local - ingress and TLS for
keycloak.kakde.eu
The biggest operational limitation is still this:
Kubernetes manifests do not fully describe the real Keycloak state. Realm exports, clients, redirect URIs, and identity-provider settings must be backed up separately.
7. Refresh the Live State Capture
When the running environment has changed and you want a fresh snapshot, use:
bash k8s-cluster/live-capture/collect-live-state.sh
This is especially useful after:
- changing firewall rules
- adding or removing platform components
- making manual changes on a host
- repairing a live service and wanting the repo to catch up with reality
8. Backups That Matter Outside Git
Git is important, but it is not enough on its own.
The following backups still matter:
- PostgreSQL logical dumps
- Keycloak realm exports
- Cloudflare token and DNS records
- real secret values
- WireGuard private keys
If you lose those, the manifests alone will not fully recreate the live platform.
Store those backups outside the cluster and outside this repository. A Git checkout is a rebuild aid, not a full disaster-recovery system.
9. Known Cleanup Targets
The live cluster still contains a few leftovers that are not part of the clean rebuild path:
- namespace
apps— created during the original test deployment (see doc 08) before theapps-dev/apps-prodnamespace split was adopted. Contains an olderwhoamideployment. - namespace
defaultwith a legacydev.notebook.kakde.euingress — left over from early notebook development before the app was moved to its proper namespace. - namespace
nettest— a debugging namespace created during initial Calico network validation. Safe to remove once you are confident the CNI is healthy.
Treat those as cleanup candidates, not as part of the intended architecture.
Maintenance Routine
For a small homelab, a simple repeatable routine is better than a complex ops process.
After important changes:
- confirm nodes, platform namespaces, and ingresses are healthy
- refresh the live-state capture if the running environment changed materially
- verify the edge public exposure model from another machine
- take or refresh backups if you changed PostgreSQL, Keycloak, secrets, or WireGuard
Periodically:
- check certificate expiry and cert-manager health
- review Argo CD sync status and unexpected drift
- clean up legacy namespaces or ingresses that are no longer part of the target architecture
10. Troubleshooting Rule of Thumb
When something looks wrong, use sources in this order:
- current
kubectloutput - version-controlled manifests and scripts in
k8s-cluster/ - the main tutorial documents in
_docs/k8s-homelab/ - older deep-dive historical notes
That keeps you anchored to the current platform instead of old assumptions.
Computer Science Notes
🚧 Work in progress
Table of content
Commands
-
Remove specific Dir and it’s sub-dir
find . -type d -name <NAME-OF-DIR> -exec rm -rf {} \; find . -type d -name build -exec rm -rf {} \; find . -type d -name .gradle -exec rm -rf {} \; -
Change All File Extensions in a Directory via the Command Line
for file in *.java; do mv "$file" "${file%.java}.kt"; done -
Squash all commits and reset
git reset $(git commit-tree HEAD^{tree} -m "Initial Commit") -
Upgrade all the casks installed via Homebrew Cask
-
To upgrade ALL apps that are outdated:
brew outdated --cask --greedy --verbose | grep -v '(latest)' | awk '{print $1}' | xargs brew reinstall --cask -
Reinstall casks and upgrade them if upgrades are available via –greedy flag.
brew upgrade --cask --greedy -
Get outdated cask info:
brew outdated --cask --greedy --verbose
-
Docker
Docker is a leading platform-as-a-service (PaaS) tool that revolutionized the way software is developed, tested, and deployed.
By using OS-level virtualization, Docker allows developers to package applications and their dependencies into lightweight, portable containers.
These containers can run consistently across different environments, from a developer’s local machine to cloud servers, making Docker a cornerstone in modern DevOps practices.
- Project Homepage: Home - Docker
- Documentation: Docker Documentation | Docker Documentation
- Tutorial-Playground: https://training.play-with-docker.com/
Installation
One click installation script:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
Run docker as non root user:
sudo groupadd docker
sudo usermod -aG docker $USER
Install Docker Engine : Docker Engine
Build Images
Docker CLI
Run Containers
| COMMAND | DESCRIPTION |
|---|---|
docker run IMAGE | Start a new container |
docker run --name CONTAINER IMAGE | Start a new container and set a name |
docker run -p HOSTPORT:CONTAINERPORT IMAGE | Start a new container with mapped ports |
docker run -P IMAGE | Start a new container and map all ports |
Container Management:
| COMMAND | DESCRIPTION |
|---|---|
docker create IMAGE | Create a new container |
docker start CONTAINER | Start a container |
docker stop CONTAINER | Graceful stop a container |
docker kill CONTAINER | Kill (SIGKILL) a container |
docker restart CONTAINER | Graceful stop and restart a container |
docker pause CONTAINER | Suspend a container |
docker unpause CONTAINER | Resume a container |
docker rm CONTAINER | Destroy a container |
Container Bulk Management
| COMMAND | DESCRIPTION |
|---|---|
docker stop $(docker ps -q) | To stop all the running containers |
docker stop $(docker ps -a -q) | To stop all the stopped and running containers |
docker kill $(docker ps -q) | To kill all the running containers |
docker kill $(docker ps -a -q) | To kill all the stopped and running containers |
docker restart $(docker ps -q) | To restart all running containers |
docker restart $(docker ps -a -q) | To restart all the stopped and running containers |
docker rm $(docker ps -q) | To destroy all running containers |
docker rm $(docker ps -a -q) | To destroy all the stopped and running containers |
docker pause $(docker ps -q) | To pause all running containers |
docker pause $(docker ps -a -q) | To pause all the stopped and running containers |
docker start $(docker ps -q) | To start all running containers |
docker start $(docker ps -a -q) | To start all the stopped and running containers |
docker rm -vf $(docker ps -a -q) | To delete all containers including its volumes use |
docker rmi -f $(docker images -a -q) | To delete all the images |
docker system prune | To delete all dangling and unused images, containers, cache and volumes |
docker system prune -a | To delete all used and unused images |
docker system prune --volumes | To delete all docker volumes |
Inspect Containers:
| COMMAND | DESCRIPTION |
|---|---|
docker ps | List running containers |
docker ps -a | List all containers, including stopped |
docker logs CONTAINER | Show a container output |
docker logs -f CONTAINER | Follow a container output |
docker top CONTAINER | List the processes running in a container |
docker diff | Show the differences with the image (modified files) |
docker inspect | Show information of a container (json formatted) |
Run Commands:
| COMMAND | DESCRIPTION |
|---|---|
docker attach CONTAINER | Attach to a container |
docker cp CONTAINER:PATH HOSTPATH | Copy files from the container |
docker cp HOSTPATH CONTAINER:PATH | Copy files into the container |
docker export CONTAINER | Export the content of the container (tar archive) |
docker exec CONTAINER | Run a command inside a container |
docker exec -it CONTAINER /bin/bash | Open an interactive shell inside a container (there is no bash in some images, use /bin/sh) |
docker wait CONTAINER | Wait until the container terminates and return the exit code |
Images:
| COMMAND | DESCRIPTION |
|---|---|
docker images | List all local images |
docker history IMAGE | Show the image history |
docker inspect IMAGE | Show information (json formatted) |
docker tag IMAGE TAG | Tag an image |
docker commit CONTAINER IMAGE | Create an image (from a container) |
docker import URL | Create an image (from a tarball) |
docker rmi IMAGE | Delete images |
docker pull REPO:[TAG] | pull an image/repo from a registry |
docker push REPO:[TAG] | push and image/repo to a registry |
docker search TEXT | Search an image on the official registry |
docker login | Login to a registry |
docker logout | Logout from a registry |
docker save REPO:[TAG] | Export an image/repo as a tarball |
docker load | Load images from a tarball |
Volumes:
| COMMAND | DESCRIPTION |
|---|---|
docker volume ls | List all vol1umes |
docker volume create VOLUME | Create a volume |
docker volume inspect VOLUME | Show information (json formatted) |
docker volume rm VOLUME | Destroy a volume |
docker volume ls --filter="dangling=true" | List all dangling volumes (not referenced by any container) |
docker volume prune | Delete all volumes (not referenced by any container) |
Backup a container
Backup docker data from inside container volumes and package it in a tarball archive.
docker run --rm --volumes-from CONTAINER -v $(pwd):/backup busybox tar cvfz /backup/backup.tar CONTAINERPATH
An automated backup can be done also by this Ansible playbook. The output is also a (compressed) tar. The playbook can also manage the backup retention. So older backups will get deleted automatically.
To also create and backup the container configuration itself, you can use docker-replayfor that. If you lose
the entire container, you can recreate it with the export from docker-replay.
A more detailed tutorial on how to use docker-replay can be
found here.
Restore container from backup
Restore the volume with a tarball archive.
docker run --rm --volumes-from CONTAINER -v $(pwd):/backup busybox sh -c "cd CONTAINERPATH && tar xvf /backup/backup.tar --strip 1"
Networks
Troubleshooting
Networking
docker run --name netshoot --rm -it nicolaka/netshoot /bin/bash
Docker-Compose
…
Networking
By default Docker-Compose will create a new network for the given compose file. You can change the behavior by defining custom networks in your compose file.
Create and assign custom network
… Example:
networks:
custom-network:
services:
app:
networks:
- custom-network
Use existing networks
If you want to use an existing Docker network for your compose files, you can add the external: true parameter in your
compose file
Example:
networks:
existing-network:
external: true
Volumes
Volumes allow Docker containers to use persistent storage. In a compose file, you can create and map volumes like this:
volumes:
my-volume:
services:
app:
volumes:
- my-volume:/path-in-container
These volumes are stored in /var/lib/docker/volumes.
HTTP Status Codes
Categories
- 1XX status codes: Informational Requests
- 2XX status codes: Successful Requests
- 3XX status codes: Redirects
- 4XX status codes: Client Errors
- 5XX status codes: Server Errors
Complete List
| Code | Name | Description |
|---|---|---|
| 100 | Continue | Everything so far is OK and that the client should continue with the request or ignore it if it is already finished. |
| 101 | Switching Protocols | The client has asked the server to change protocols and the server has agreed to do so. |
| 102 | Processing | The server has received and is processing the request, but that it does not have a final response yet. |
| 103 | Early Hints | Used to return some response headers before final HTTP message. |
| 200 | OK | Successful request. |
| 201 | Created | The server acknowledged the created resource. |
| 202 | Accepted | The client’s request has been received but the server is still processing it. |
| 203 | Non-Authoritative Information | The response that the server sent to the client is not the same as it was when the server sent it. |
| 204 | No Content | There is no content to send for this request |
| 205 | Reset Content | Tells the user agent to reset the document which sent this request. |
| 206 | Partial Content | This response code is used when the range-header is sent from the client to request only part of a resource. |
| 207 | Multi-Status | Conveys information about multiple resources, for situations where multiple status codes might be appropriate. |
| 208 | Already Reported | The members of a DAV binding have already been enumerated in a preceding part of the multi-status response. |
| 226 | IM Used | IM is a specific extension of the HTTP protocol. The extension allows a HTTP server to send diffs (changes) of resources to clients. |
| 300 | Multiple Choices | The request has more than one possible response. The user agent should choose one. |
| 301 | Moved Permanently | The URL of the requested resource has been changed permanently. The new URL is given in the response. |
| 302 | Found | This response code means that the URI of requested resource has been changed temporarily |
| 303 | See Other | The server sent this response to direct the client to get the requested resource at another URI with a GET request. |
| 304 | Not Modified | It tells the client that the response has not been modified, so the client can continue to use the same cached version of the response. |
| 305 | Use Proxy | Defined in a previous version of the HTTP specification to indicate that a requested response must be accessed by a proxy. (discontinued) |
| 307 | Temporary Redirect | The server sends this response to direct the client to get the requested resource at another URI with same method that was used in the prior request. |
| 308 | Permanent Redirect | This means that the resource is now permanently located at another URI, specified by the Location: HTTP Response header. |
| 400 | Bad Request | The server could not understand the request |
| 401 | Unauthorized | The client didn’t authenticate himself. |
| 402 | Payment Required | This response code is reserved for future use. The initial aim for creating this code was using it for digital payment systems, however this status code is used very rarely and no standard convention exists. |
| 403 | Forbidden | The client does not have access rights to the content |
| 404 | Not Found | The server can not find the requested resource |
| 405 | Method Not Allowed | The request method is known by the server but is not supported by the target resource |
| 406 | Not Acceptable | The reponse doens’t conforms to the creteria given by the client |
| 407 | Proxy Authentication Required | This is similar to 401 Unauthorized but authentication is needed to be done by a proxy. |
| 408 | Request Timeout | This response is sent on an idle connection by some servers, even without any previous request by the client. |
| 409 | Conflict | This response is sent when a request conflicts with the current state of the server. |
| 410 | Gone | This response is sent when the requested content has been permanently deleted from server, with no forwarding address. |
| 411 | Length Required | Server rejected the request because the Content-Length header field is not defined and the server requires it. |
| 412 | Precondition Failed | Access to the target resource has been denied. |
| 413 | Payload Too Large | Request entity is larger than limits defined by server. |
| 414 | Request-URI Too Long | The URI requested by the client is longer than the server is willing to interpret. |
| 415 | Unsupported Media Type | The media format is not supported by the server. |
| 416 | Requested Range Not Satisfiable | The range specified by the Range header field in the request cannot be fulfilled. |
| 417 | Expectation Failed | the expectation indicated by the Expect request header field cannot be met by the server. |
| 418 | I’m a teapot | The server refuses the attempt to brew coffee with a teapot. |
| 421 | Misdirected Request | The request was directed at a server that is not able to produce a response. |
| 422 | Unprocessable Entity | The request was well-formed but was unable to be followed due to semantic errors. |
| 423 | Locked | The resource that is being accessed is locked. |
| 424 | Failed Dependency | The request failed due to failure of a previous request. |
| 426 | Upgrade Required | The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. |
| 428 | Precondition Required | his response is intended to prevent the ‘lost update’ problem, where a client GETs a resource’s state, modifies it and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict. |
| 429 | Too Many Requests | The user has sent too many requests in a given amount of time |
| 431 | Request Header Fields Too Large | The server is can’t process the request because its header fields are too large. |
| 444 | Connection Closed Without Response | The connection opened, but no data was written. |
| 451 | Unavailable For Legal Reasons | The user agent requested a resource that cannot legally be provided (such as a web page censored by a government) |
| 499 | Client Closed Request | The client closed the connection, despite the server was processing the request already. |
| 500 | Internal Server Error | The server has encountered a situation it does not know how to handle. |
| 501 | Not Implemented | The request method is not supported by the server and cannot be handled. |
| 502 | Bad Gateway | This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response. |
| 503 | Service Unavailable | The server is not ready to handle the request. |
| 504 | Gateway Timeout | This error response is given when the server is acting as a gateway and cannot get a response in time. |
| 505 | HTTP Version Not Supported | The HTTP version used in the request is not supported by the server. |
| 506 | Variant Also Negotiates | the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process. |
| 507 | Insufficient Storage | The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request. |
| 508 | Loop Detected | The server detected an infinite loop while processing the request. |
| 510 | Not Extended | Further extensions to the request are required for the server to fulfill it. |
| 511 | Network Authentication Required | Indicates that the client needs to authenticate to gain network access. |
| 599 | Network Connect Timeout Error | The connection timed out due to a overloaded server, a hardware error or a infrastructure error. |
Regular Expressions (Regex) Tutorial
Regular expressions, or regex, are powerful tools for pattern matching in text. They are widely used in programming, text processing, and data extraction.
1. Basics of Regular Expressions
-
A regular expression defines a pattern that can be used to match strings in text. It is often used for validation, search, and substitution tasks.
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"apple").unwrap(); let text = "I like apple pies."; assert!(re.is_match(text)); println!("Match found for 'apple': {}", re.is_match(text)); }
2. Literals
-
Literals are the simplest form of regex, used to match exact characters.
-
Example:
- Regex:
apple - Matches: “apple”, “I like apple pies.”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"apple").unwrap(); let text = "I like apple pies."; println!("Match found: {}", re.is_match(text)); }
3. Character Sets [ ]
Character sets are enclosed in square brackets and match any single character within them.
-
Example:
- Regex:
gr[aeiou]y - Matches: “gray”, “grey”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"gr[aeiou]y").unwrap(); let text = "The sky is gray."; println!("Match found: {}", re.is_match(text)); }
4. Wildcards .
The wildcard period (.) matches any single character (except a newline).
-
Example:
- Regex:
c.t - Matches: “cat”, “cot”, “cut”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"c.t").unwrap(); let text = "cat, cot, and cut are matched."; println!("Match found: {}", re.is_match(text)); }
5. Alternation |
The pipe (|) allows matching either of two sub-expressions.
-
Example:
- Regex:
apple|orange - Matches: “apple”, “orange”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"apple|orange").unwrap(); let text = "I like apple and orange juice."; println!("Match found: {}", re.is_match(text)); }
6. Ranges [A-Z]
Ranges define a span of characters and can be used inside character sets.
-
Examples:
[A-Z]: Any uppercase letter[0-9]: Any digit[a-zA-Z]: Any letter, upper or lowercase
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"[A-Z][0-9]").unwrap(); let text = "A3 is a valid match."; println!("Match found: {}", re.is_match(text)); }
7. Shorthand Character Classes
Shorthand classes simplify common patterns:
-
\d: Matches any digit (equivalent to[0-9]) -
\w: Matches any word character (equivalent to[A-Za-z0-9_]) -
\s: Matches any whitespace (spaces, tabs, etc.) -
Code:
use regex::Regex; fn main() { let re = Regex::new(r"\w+@\w+\.\w{2,3}").unwrap(); // Email example let text = "Send an email to test@example.com."; println!("Match found: {}", re.is_match(text)); }
8. Anchors ^ and $
Anchors are used to ensure that a match happens at the start (^) or the end ($) of a string.
-
Example:
- Regex:
^hello - Matches: “hello world” but not “world hello”
- Regex:
world$ - Matches: “hello world” but not “world hello”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"^hello").unwrap(); // Start anchor example let text = "hello world"; println!("Match found: {}", re.is_match(text)); }
9. Quantifiers
Quantifiers specify how many times the preceding character or group must appear.
-
Fixed Quantifiers
{n,m}: Match betweennandmoccurrences.-
Example:
- Regex:
a{2,4} - Matches: “aa”, “aaa”, “aaaa”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"a{2,4}").unwrap(); let text = "I like aaa."; println!("Match found: {}", re.is_match(text)); }
-
-
Kleene Star
*: Match 0 or more occurrences.- Example:
- Regex:
ba* - Matches: “b”, “ba”, “baaaa”
- Regex:
- Code:
use regex::Regex; fn main() { let re = Regex::new(r"ba*").unwrap(); let text = "I saw baaaa!"; println!("Match found: {}", re.is_match(text)); }
- Example:
-
Kleene Plus
+: Match 1 or more occurrences.-
Example:
- Regex:
ba+ - Matches: “ba”, “baaaa”, but not “b”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"ba+").unwrap(); let text = "I saw ba!"; println!("Match found: {}", re.is_match(text)); }
-
-
Optional Quantifier
?: Matches 0 or 1 occurrence.-
Example:
- Regex:
colou?r - Matches: “color”, “colour”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"colou?r").unwrap(); let text = "I like both color and colour."; println!("Match found: {}", re.is_match(text)); }
-
10. Grouping and Capturing ( )
Grouping with parentheses (()) groups parts of the regex together. It’s also useful for applying quantifiers to
sub-expressions or capturing matched text.
-
Example:
- Regex:
(puppy|kitten)s - Matches: “puppies”, “kittens”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"(puppy|kitten)s").unwrap(); let text = "I love kittens and puppies."; println!("Match found: {}", re.is_match(text)); }
11. Escaping Special Characters
To match characters that have special meanings in regex (such as ., *, ?, +), use a backslash (\) to escape
them.
-
Example:
- Regex:
\. - Matches: “.”
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"\.").unwrap(); let text = "This is a dot."; println!("Match found: {}", re.is_match(text)); }
12. Examples of Common Regex Patterns
-
Email Validation:
\w+@\w+\.\w{2,3}- Matches: “user@example.com”
- Code:
use regex::Regex; fn main() { let re = Regex::new(r"\w+@\w+\.\w{2,3}").unwrap(); let text = "My email is user@example.com."; println!("Match found: {}", re.is_match(text)); }
-
Phone Number Validation:
\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}- Matches: “(123) 456-7890”, “123-456-7890”
- Code:
use regex::Regex; fn main() { let re = Regex::new(r"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}").unwrap(); let text = "(123) 456-7890 is a valid phone number."; println!("Match found: {}", re.is_match(text)); }
-
Date Matching (MM/DD/YYYY):
\d{2}/\d{2}/\d{4}- Matches: “12/31/2020”
- Code:
use regex::Regex; fn main() { let re = Regex::new(r"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}").unwrap(); let text = "(123) 456-7890 is a valid phone number."; println!("Match found: {}", re.is_match(text)); }
13. Practice Challenges
-
Challenge 1: Write a regex to match any word that starts with “a” and ends with “t”.
- Solution:
a\w+t
- Solution:
-
Challenge 2: Write a regex to match a US-style phone number (with or without parentheses or hyphens).
- Solution:
\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}
- Solution:
-
Challenge 3: Write a regex to match an email address.
- Solution:
\w+@\w+\.\w+
- Solution:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"a\w+t").unwrap(); let text = "The word is ant."; println!("Match found: {}", re.is_match(text)); }
Certainly! Below is the complete document with both explanations and the corresponding Rust code examples for each section.
14. Lookaheads and Lookbehinds (Assertions)
Lookaheads and lookbehinds are zero-width assertions, meaning they match a pattern but don’t consume any characters in the string. These assertions allow you to match text based on what comes before or after the main pattern, without including that text in the match itself.
-
Positive Lookahead
(?=...): Matches a group of characters only if it is followed by another specific pattern.- Example:
- Regex:
\d(?= dollars) - Matches: “5 dollars” but will only capture “5”
- Regex:
- Example:
-
Negative Lookahead
(?!...): Matches a group of characters only if it is not followed by another specific pattern.- Example:
- Regex:
\d(?! dollars) - Matches: “5 pounds” but will not match “5 dollars”
- Regex:
- Example:
-
Positive Lookbehind
(?<=...): Matches a group of characters only if it is preceded by another specific pattern.- Example:
- Regex:
(?<=\$)\d+ - Matches: “$500”, capturing “500” but not “$”
- Regex:
- Example:
-
Negative Lookbehind
(?<!...): Matches a group of characters only if it is not preceded by another specific pattern.- Example:
- Regex:
(?<!\$)\d+ - Matches: “500” but not “$500”
- Regex:
- Example:
-
Code:
Rust’s regex crate does not support lookaheads or lookbehinds. Run in your local by adding crate
[dependencies]
fancy-regex = "0.6.1"
Code run disabled:
use fancy_regex::Regex;
fn main() {
// Positive Lookahead
let re_pos_lookahead = Regex::new(r"\d(?= dollars)").unwrap();
let text_pos_lookahead = "I have 5 dollars.";
println!("Positive Lookahead Match: {}", re_pos_lookahead.is_match(text_pos_lookahead).unwrap());
// Negative Lookahead
let re_neg_lookahead = Regex::new(r"\d(?! dollars)").unwrap();
let text_neg_lookahead = "I have 5 pounds.";
println!("Negative Lookahead Match: {}", re_neg_lookahead.is_match(text_neg_lookahead).unwrap());
// Positive Lookbehind
let re_pos_lookbehind = Regex::new(r"(?<=\$)\d+").unwrap();
let text_pos_lookbehind = "The cost is $500.";
println!("Positive Lookbehind Match: {}", re_pos_lookbehind.is_match(text_pos_lookbehind).unwrap());
// Negative Lookbehind
let re_neg_lookbehind = Regex::new(r"(?<!\$)\d+").unwrap();
let text_neg_lookbehind = "The number is 500.";
println!("Negative Lookbehind Match: {}", re_neg_lookbehind.is_match(text_neg_lookbehind).unwrap());
}
15. Non-Capturing Groups (?:...)
While regular parentheses () capture the matched group for further processing, non-capturing groups allow grouping
without capturing the match.
-
Example:
- Regex:
(?:apple|orange)s - Matches: “apples”, “oranges” but won’t capture the match for later use.
- Regex:
-
Code:
use regex::Regex; fn main() { let re = Regex::new(r"(?:apple|orange)s").unwrap(); let text = "I bought apples and oranges."; println!("Match found: {}", re.is_match(text)); }
16. Greedy vs Lazy Matching
Quantifiers like *, +, and {} are greedy by default, meaning they match as much text as possible. By
adding a ?, you can make them lazy, meaning they match as little text as possible.
-
Greedy Quantifier:
.*- Matches as much text as possible.
- Example:
"a <tag> this is text </tag>"will match the entire string.
-
Lazy Quantifier:
.*?- Matches as little text as possible.
- Example:
"a <tag> this is text </tag>"will match only “”
-
Code:
use regex::Regex; fn main() { // Greedy Match let re_greedy = Regex::new(r"<.*>").unwrap(); let text_greedy = "a <tag> this is text </tag>"; println!("Greedy match: {}", re_greedy.is_match(text_greedy)); // Lazy Match let re_lazy = Regex::new(r"<.*?>").unwrap(); let text_lazy = "a <tag> this is text </tag>"; println!("Lazy match: {}", re_lazy.is_match(text_lazy)); }
17. Unicode and Special Characters
Unicode characters or special symbols (like accented letters) can be tricky in regex. You can use Unicode escape sequences to match these characters.
-
Example:
\u00E9matches “é” -
Regex engines that support Unicode can also have specific flags (like
/uin JavaScript) to handle these cases. -
Code:
use regex::Regex; fn main() { let re = Regex::new(r"\u00E9").unwrap(); let text = "Café"; println!("Unicode match found: {}", re.is_match(text)); }
18. Case Insensitivity and Multiline Flags
Regex engines typically support flags that modify how a regex behaves:
-
Case Insensitivity: Add a flag like
i(depends on the language).- Regex:
/abc/iwill match “abc”, “ABC”, “aBc”, etc.
- Regex:
-
Multiline Matching:
^and$normally match the start and end of a string, respectively. The **multiline flag ** (m) makes them match the start and end of each line within a string.- Example:
^hellomatches “hello” at the beginning of each line in multiline text.
- Example:
-
Code:
use regex::Regex; fn main() { // Case Insensitive Match let re_case_insensitive = Regex::new(r"(?i)abc").unwrap(); let text_case = "ABC"; println!("Case Insensitive Match: {}", re_case_insensitive.is_match(text_case)); // Multiline Match let re_multiline = Regex::new(r"(?m)^hello").unwrap(); let text_multiline = "hello\nworld\nhello again"; println!("Multiline Match: {}", re_multiline.is_match(text_multiline)); }
19. Performance Considerations
When working with large texts or complex patterns, performance can become an issue:
-
Avoid Backtracking: Using patterns that create unnecessary backtracking can slow down matching. For example, avoid nested quantifiers like
.*+. -
Atomic Grouping: Some regex engines support atomic grouping
(?>...), which prevents backtracking in specific sections of the regex, making it more efficient. -
Code:
use regex::Regex; fn main() { // A more simplified regex without atomic grouping let re_no_backtrack = Regex::new(r"\d+[a-zA-Z]+").unwrap(); let text = "123abc"; println!("Match found: {}", re_no_backtrack.is_match(text)); }
20. Regex Testing Tools
To fully master regular expressions, it’s helpful to practice in regex testing environments. Many websites, such as regex101.com or regexr.com, allow you to input a regex pattern and see how it matches test strings in real-time.
- Code:
This point is about using online tools, so there is no direct Rust code for this. You can test the patterns provided above on tools like:
Download Debian distribution: https://www.debian.org/distrib/
Usually torrents are faster to download instead of HTTP.
Download it using torrents: https://www.debian.org/CD/torrent-cd/
For Macbook Pro M series (M1/M2… etc): Download torrent available in the category “DVD/USB” -> arm64.
Installation on VMWare Fusion:
Open VMWare Fusion App then, Click “File” -> “New” -> Double click “Install from disc or image” -> “Use another disc or disc image”.
- File -> New:
- Double click “Install from disc or image”:
- Use another disc or disc image:
-
To make The virtual machine appears as an additional computer on the physical Ethernet network “Wi-Fi” you need to update “Network Adapter” settings.
-
Hence Click on “Settings Icon”, it looks something like this: 🔧.
Network Adapter:
Select Bridged network wifi:
After that start with Normal Installation process. Below screenshots are reference only. Select Configuration as per your need.
-
Use Graphical Install