Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Start with memory and arrays — understand how data actually lives in RAM before touching any abstraction.
  2. Move to linear structures — linked lists and queues that flex where arrays are rigid.
  3. Build recursion intuition — train your brain to think in sub-problems.
  4. Master sorting — the proving ground for algorithmic thinking.
  5. 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.


Linked Lists

Flexible structures where elements can live anywhere in memory, connected by pointers.


Recursion

The art of solving a problem by solving a smaller version of itself.


Sorting

Rearranging data is the most studied problem in computer science — and for good reason.

  • Overview
  • Insertion SortSimple, intuitive, and surprisingly fast on small inputs.
  • Merge SortDivide, conquer, and merge — reliably O(n log n).
  • Quick SortThe algorithm powering most real-world sort implementations.
  • Bucket SortWhen you know something about your data, you can beat O(n log n).

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.


Backtracking

Explore every possibility systematically — and prune dead ends early.

  • Overview
  • Tree MazeNavigate a maze by trying every path and retreating when stuck.
  • SubsetsGenerate every subset of a set systematically.
  • CombinationsChoose k items from n — without repetition.
  • PermutationsEvery 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.


Hashing

The secret behind Python’s dict and set — O(1) average lookup via math.


Graphs

The most general structure — social networks, maps, and the internet are all graphs.


Dynamic Programming

Solve complex problems by caching the results of overlapping sub-problems.

  • Overview
  • 1-Dimension DPSingle-variable state: climbing stairs, coin change.
  • 2-Dimension DPGrid problems and string comparisons.
  • 0 / 1 KnapsackPick items with weights and values — each item used at most once.
  • Unbounded KnapsackSame as knapsack but items can be reused unlimited times.
  • LCSFind the longest sequence common to two strings.
  • PalindromesDetect and build palindromic substrings with DP.

Bit Manipulation

Use binary operations directly — the lowest level of computation, and often the fastest.

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:

  1. Correctness — it produces the right answer for every valid input.
  2. Finiteness — it terminates in a finite number of steps.
  3. 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:

QuestionWhat 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 dict and set — 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

  1. What is Algorithm Analysis?
  2. Time Complexity
  3. Space Complexity
  4. Time vs Space Trade-offs
  5. 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 constants c > 0 and n₀ such that f(n) ≤ c · g(n) for all n ≥ 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:

NotationMeaningUsed for
O(f(n))Upper bound — at most this fastWorst case guarantee
Ω(f(n))Lower bound — at least this fastBest case guarantee
Θ(f(n))Tight bound — exactly this fastAverage / 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:

  1. Drop constantsO(3n)O(n). Constants don’t describe growth behaviour.
  2. Drop lower-order termsO(n² + n)O(n²). The dominant term wins at large n.
  3. Nested loops multiply — a loop inside a loop is O(n × n) = O(n²).
  4. 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:

ComplexityNameOperationsVerdict
O(1)Constant1Instant
O(log n)Logarithmic~10Instant
O(n)Linear1,000Fast
O(n log n)Linearithmic~10,000Fast
O(n²)Quadratic1,000,000Slow
O(2ⁿ)Exponential2¹⁰⁰⁰ (astronomical)Impossible
O(n!)Factoriallarger than aboveImpossible

Real-world examples for each class:

ComplexityWhere 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:

CaseWhen it happensComplexity
BestPivot always splits the array perfectly in halfO(n log n)
AverageRandom pivot, typical distributionO(n log n)
WorstPivot 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:

KitchenTuring Machine
Bag of ingredients you were givenInput tape
Bowls, cutting boards, and tools you usedWork tape
Plate you serve to the guestOutput 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:

  1. Is this variable the input? → Input tape. Do not count it.
  2. Is this variable what I return? → Output tape. Do not count it.
  3. 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:

VersionWork tape contentsSpace
Iterativelo, hi, mid — always 3 integersO(1)
Recursivecall stack grows log₂(n) frames deepO(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:

TechniqueTime savedSpace cost
Memoization (top-down DP)Exponential → polynomialO(n) to O(n²) extra
Hash map for lookupsO(n) search → O(1) searchO(n) for the hash map
Precomputed prefix sumsO(n) range sum → O(1) range sumO(n) for prefix array
Rolling window DPSame time, much less spaceO(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

ComplexityNameTypical causeReal-world example
O(1)ConstantDirect accessArray index, dict lookup
O(log n)LogarithmicHalving the inputBinary search, database index
O(n)LinearSingle passFind max, count occurrences
O(n log n)LinearithmicDivide and mergeMerge sort, Python’s sorted()
O(n²)QuadraticNested loopsBubble sort, comparing all pairs
O(2ⁿ)ExponentialAll subsetsNaive recursive Fibonacci
O(n!)FactorialAll permutationsBrute-force Travelling Salesman

Space Complexity Reference

ComplexityNameTypical causeReal-world example
O(1)ConstantOnly scalar variablesIterative binary search, two-pointer
O(log n)LogarithmicRecursive call stackRecursive binary search, recursive DFS
O(n)LinearArray or hash map as scratchFrequency table, BFS queue
O(n²)Quadratic2D matrix as scratchEdit 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:

ChapterTopicThe big idea
RAMMemory and addressesWhy index access costs O(1)
Static ArraysFixed-size arraysFast reads, expensive inserts
Dynamic ArraysResizable arraysHow Python lists grow automatically
StacksLIFO structureOne of the most useful tools in CS
ProblemsPractice problemsApply 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:

IndexCalculationAddress
02000 + 0 × 42000
12000 + 1 × 42004
22000 + 2 × 42008
32000 + 3 × 42012

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

OperationTime complexityWhy
Read by indexO(1)Direct address calculation
Update by indexO(1)Same — just write to that address
Traverse allO(n)Must visit every element
Insert at middle/frontO(n)All later elements must shift right
Delete from middle/frontO(n)All later elements must shift left
Insert at end (if space)O(1)No shifting needed
Delete from endO(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 n elements

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

LanguageDynamic array typeNotes
PythonlistOverallocates by ~12.5% beyond capacity
JavaScriptArrayEngine-dependent, usually similar doubling
JavaArrayListDefault capacity 10, grows by 50%
C++std::vectorDoubles on resize
C#List<T>Doubles on resize

Operation complexity summary

OperationTime complexityNotes
Read / write by indexO(1)Direct address calculation
Append (pushback)O(1) amortizedOccasional O(n) resize, but rare
Pop from endO(1)No shifting
Insert at middleO(n)Must shift elements right
Delete from middleO(n)Must shift elements left
ResizeO(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:

OperationDescriptionCost
push(value)Add an item to the topO(1) amortized
pop()Remove and return the top itemO(1)
peek()Look at the top item without removing itO(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

OperationTime complexityNotes
pushO(1) amortizedBacked by a dynamic array
popO(1)Remove last element of the backing array
peekO(1)Read last element of the backing array
is_emptyO(1)Check length
sizeO(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

ApproachTimeSpaceNotes
Brute forceO(n²)O(1)Check all subarrays
Kadane’sO(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

ApproachTimeSpaceNotes
Recompute each windowO(n·k)O(1)Nested loop — slow for large k
Sliding windowO(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:

  1. Expand right — move right forward to grow the window and add more to the sum.
  2. Shrink left — once the window satisfies the condition, try to make it smaller by moving left forward.
  3. 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

ProblemTimeSpaceNotes
Min subarray sum ≥ targetO(n)O(1)Each pointer moves at most n steps
Longest substring no repeatsO(n)O(1)Set holds at most 26 chars
Longest k-distinct substringO(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

ProblemTimeSpaceNotes
Two Sum (sorted)O(n)O(1)Converging pointers
Remove duplicates in-placeO(n)O(1)Same-direction fast/slow pointers
Container with most waterO(n)O(1)Always move the shorter wall
Three SumO(n²)O(1)Fix + two-pointer inner loop
Palindrome checkO(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

ProblemBuildQuerySpace
Range sum queryO(n)O(1)O(n)
Subarrays with sum = k (hashmap)O(n)O(n)
Product except selfO(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.

#ProblemDifficulty
1Max Consecutive OnesEasy
2Remove ElementEasy

Max Consecutive Ones

Difficulty: Easy Source: NeetCode

Problem

Given a binary array nums, return the maximum number of consecutive 1s in the array.

Example 1: Input: nums = [1, 1, 0, 1, 1, 1] Output: 3

Example 2: Input: nums = [1, 0, 1, 1, 0, 1] Output: 2

Constraints:

  • 1 <= nums.length <= 10^5
  • nums[i] is either 0 or 1

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

  1. Initialize res = 0 to track the maximum consecutive ones.
  2. For each starting index i:
    • Initialize a counter cnt = 0.
    • Scan forward from i while the current element is 1, incrementing cnt.
    • Stop when encountering a 0 or reaching the end.
    • Update res = max(res, cnt).
  3. 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

  1. Initialize res = 0 and cnt = 0.
  2. Iterate through each element in the array:
    • If the element is 0: update res = max(res, cnt), then reset cnt = 0.
    • If the element is 1: increment cnt.
  3. 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

  1. Initialize res = 0 and cnt = 0.
  2. For each element in the array:
    • If the element is 1: increment cnt.
    • Otherwise: set cnt = 0.
    • Update res = max(res, cnt).
  3. 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 nums and an integer val, remove all occurrences of val in nums in-place. The order of the elements may be changed. Return k — the number of elements in nums that are not equal to val.

The first k elements of nums must contain the elements which are not equal to val. The remaining elements and the size of the array do not matter.

Example 1: Input: nums = [3, 2, 2, 3], val = 3 Output: 2, nums = [2, 2, _, _]

Example 2: Input: nums = [0, 1, 2, 2, 3, 0, 4, 2], val = 2 Output: 5, nums = [0, 1, 3, 0, 4, _, _, _]

Constraints:

  • 0 <= nums.length <= 100
  • 0 <= nums[i] <= 50
  • 0 <= 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

  1. Set k = len(nums) as the logical length.
  2. Set i = 0. While i < k:
    • If nums[i] == val, shift all elements from i+1 to k-1 one position left, then decrement k.
    • Otherwise, increment i.
  3. 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

  1. Initialize k = 0 as the write pointer.
  2. For each element num in nums:
    • If num != val: write num to nums[k], then increment k.
  3. 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

  1. Initialize i = 0 as the current position and n = len(nums) as the effective length.
  2. While i < n:
    • If nums[i] == val: overwrite it with nums[n - 1] and decrement n. Do not increment i — the swapped-in element still needs to be checked.
    • Otherwise: increment i.
  3. 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 val is rare, this approach writes far fewer elements. If the array has 10 000 elements and only 2 match val, 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 nums of length n, return an array ans of length 2n where ans[i] = nums[i] and ans[i + n] = nums[i] for 0 <= i < n.

In other words, ans is the concatenation of two copies of nums.

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 <= 1000
  • 1 <= 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

  1. Create ans as a list of zeros with length 2 * n.
  2. Loop i from 0 to n - 1:
    • Set ans[i] = nums[i]
    • Set ans[i + n] = nums[i]
  3. 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 length 2n

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

  1. 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 length 2n

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, return true if any value appears at least twice in the array, and return false if every element is distinct.

Example 1: Input: nums = [1, 2, 3, 1] Output: true

Example 2: Input: nums = [1, 2, 3, 4] Output: false

Example 3: Input: nums = [1, 1, 1, 3, 3, 4, 3, 2, 4, 2] Output: true

Constraints:

  • 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

  1. For each index i from 0 to n - 1:
    • For each index j from i + 1 to n - 1:
      • If nums[i] == nums[j], return true.
  2. 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

  1. Sort nums.
  2. For each index i from 1 to n - 1:
    • If nums[i] == nums[i - 1], return true.
  3. 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

  1. Create an empty set seen.
  2. For each num in nums:
    • If num is in seen, return true.
    • Otherwise, add num to seen.
  3. 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 s and t, return true if t is an anagram of s, and false otherwise.

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: true

Example 2: Input: s = "rat", t = "car" Output: false

Constraints:

  • 1 <= s.length, t.length <= 5 * 10^4
  • s and t consist 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

  1. If len(s) != len(t), return false.
  2. Sort both strings.
  3. 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, where n = 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

  1. If len(s) != len(t), return false.
  2. Create a hash map count (default 0).
  3. For each character c in s, increment count[c].
  4. For each character c in t, decrement count[c].
  5. Return true if all values in count are 0.
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 length n
  • 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

  1. If len(s) != len(t), return false.
  2. Create count = [0] * 26.
  3. For each character c in s, increment count[ord(c) - ord('a')].
  4. For each character c in t, decrement count[ord(c) - ord('a')].
  5. Return true if all entries in count are 0.

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 nums and an integer target, return the indices of the two numbers such that they add up to target.

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 = 9 Output: [0, 1]

Example 2: Input: nums = [3, 2, 4], target = 6 Output: [1, 2]

Example 3: Input: nums = [3, 3], target = 6 Output: [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, then b = 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

  1. For each index i from 0 to n - 1:
    • For each index j from i + 1 to n - 1:
      • If nums[i] + nums[j] == target, return [i, j].
  2. 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 complementtarget - 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

  1. Create an empty hash map seen mapping value → index.
  2. For each index i and value num in nums:
    • Compute complement = target - num.
    • If complement is in seen, return [seen[complement], i].
    • Otherwise, store seen[num] = i.
  3. 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 most n entries

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 <= 200
  • 0 <= strs[i].length <= 200
  • strs[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

  1. If strs is empty, return "".
  2. Set prefix = strs[0].
  3. For each string s in strs[1:]:
    • While s does not start with prefix, remove the last character from prefix.
    • If prefix becomes empty, return "".
  4. 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) where S is 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

  1. Sort strs lexicographically.
  2. Compare the first string lo = strs[0] and the last string hi = strs[-1] character by character.
  3. Find the length of their common prefix.
  4. 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, plus O(m) for the comparison where m is 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^4
  • 0 <= strs[i].length <= 100
  • strs[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

  1. Create a hash map groups where key → list of strings.
  2. For each string s in strs:
    • Compute key = "".join(sorted(s)).
    • Append s to groups[key].
  3. 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) where n is the number of strings and k is 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

  1. Create a hash map groups where key → list of strings.
  2. For each string s in strs:
    • Create a count array of 26 zeros.
    • For each character c in s, increment count[ord(c) - ord('a')].
    • Convert count to a tuple (tuples are hashable; lists are not).
    • Append s to groups[tuple(count)].
  3. 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 of n strings, 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 nums of size n, 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: 3

Example 2: Input: nums = [2, 2, 1, 1, 1, 2, 2] Output: 2

Constraints:

  • n == nums.length
  • 1 <= 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

  1. Build a frequency map count over nums.
  2. For each element num and its freq in count:
    • If freq > n // 2, return num.
  3. (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 to n distinct 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

  1. Sort nums.
  2. 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

  1. Set candidate = None, count = 0.
  2. For each num in nums:
    • If count == 0, set candidate = num.
    • If num == candidate, increment count.
    • Otherwise, decrement count.
  3. 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 MyHashSet class:

  • add(key) — Inserts the value key into the HashSet.
  • remove(key) — Removes the value key from the HashSet. If key does not exist, do nothing.
  • contains(key) — Returns true if key exists, false otherwise.

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^4 calls will be made to add, remove, and contains.

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) where N = 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__: Create NUM_BUCKETS empty lists.
  • _hash(key): Return key % NUM_BUCKETS to pick the bucket.
  • add(key): If key not already in the bucket, append it.
  • remove(key): Filter key out of the bucket list.
  • contains(key): Return True if key is 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, where n is the number of keys and k is the number of buckets. With a good hash and reasonable load, this approaches O(1).
  • Space: O(n + k)k bucket lists plus space for n stored 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 MyHashMap class:

  • put(key, value) — Inserts or updates the mapping key → value. If key already exists, update its value.
  • get(key) — Returns the value mapped to key, or -1 if it does not exist.
  • remove(key) — Removes key and its associated value. If key does 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^4 calls will be made to put, get, and remove.

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__: Create data = [-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) where N = 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__: Create NUM_BUCKETS empty lists.
  • _hash(key): Return key % 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 key from 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, where n is the number of stored pairs and k is 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

  1. For each pass i from 0 to n - 1:
    • For each index j from 0 to n - i - 2:
      • If nums[j] > nums[j + 1], swap them.
  2. 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

  1. Base case: If the array has 0 or 1 elements, it is already sorted — return it.
  2. Split: Find the midpoint mid = len(nums) // 2. Recursively sort left = nums[:mid] and right = nums[mid:].
  3. Merge: Compare elements from the front of left and right, appending the smaller one to the result.
  4. Drain any remaining elements from either half.
  5. 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 n levels of recursion, each doing O(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 nums with n objects 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, and 2 to 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.length
  • 1 <= n <= 300
  • nums[i] is either 0, 1, or 2.

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

  1. Count occurrences of 0, 1, 2.
  2. Overwrite nums: first count[0] positions get 0, next count[1] positions get 1, rest get 2.

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 of low is 0
  • mid — the current element being examined
  • high — everything right of high is 2
  • Elements between low and high (exclusive) are either 1 or unprocessed

We scan mid from left to right. Depending on the value at mid:

  • If it is 0, swap it to the low zone and advance both low and mid.
  • If it is 1, it is already in the middle — just advance mid.
  • If it is 2, swap it to the high zone and retreat high. Do not advance mid yet because the swapped-in element has not been examined.

This sorts the array in a single pass with no extra memory.

Algorithm

  1. Initialize low = 0, mid = 0, high = n - 1.
  2. While mid <= high:
    • If nums[mid] == 0: swap nums[mid] and nums[low], increment low and mid.
    • If nums[mid] == 1: increment mid.
    • If nums[mid] == 2: swap nums[mid] and nums[high], decrement high.
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, mid advances or high retreats 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 nums and an integer k, return the k most frequent elements. You may return the answer in any order.

Example 1: Input: nums = [1, 1, 1, 2, 2, 3], k = 2 Output: [1, 2]

Example 2: Input: nums = [1], k = 1 Output: [1]

Constraints:

  • 1 <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4
  • k is 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

  1. Build a frequency map count.
  2. Sort the unique keys by frequency descending.
  3. Return the first k keys.

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 to n unique 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

  1. Build frequency map count.
  2. Create buckets of size n + 1, where buckets[f] is a list.
  3. For each (num, freq) in count, append num to buckets[freq].
  4. Scan buckets from index n down to 1, collecting elements into the result until we have k.
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 most n entries

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 encode and decode functions:

  • 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 <= 200
  • 0 <= strs[i].length <= 200
  • strs[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/index operations
  • 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:

  1. For each string s, append f"{len(s)}#{s}" to the result.
  2. Return the concatenated result.

Decode:

  1. Start with pointer i = 0.
  2. While i < len(s):
    • Find the next # starting from i. Its index is j.
    • Read the length: length = int(s[i:j]).
    • Read the string: s[j+1 : j+1+length].
    • Advance i to j + 1 + length.
  3. 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, where n is 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 of matrix inside the rectangle defined by its upper left corner (row1, col1) and lower right corner (row2, col2).

Implement the NumMatrix class with an efficient sumRegion method.

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)8 sumRegion(1,1,2,2)11 sumRegion(1,2,2,4)12

Constraints:

  • m == matrix.length, n == matrix[i].length
  • 1 <= m, n <= 200
  • -10^4 <= matrix[i][j] <= 10^4
  • 0 <= row1 <= row2 < m
  • 0 <= col1 <= col2 < n
  • At most 10^4 calls to sumRegion.

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: Iterate row from row1 to row2, col from col1 to col2, summing matrix[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__:

  1. Create prefix of size (m+1) × (n+1) filled with zeros.
  2. For each r from 1 to m, for each c from 1 to n:
    • 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):

  1. 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) per sumRegion query
  • 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 array answer such that answer[i] is equal to the product of all the elements of nums except nums[i].

The product of any prefix or suffix of nums is 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

  1. For each index i:
    • Set product = 1.
    • For each index j, if j != i, multiply product *= nums[j].
    • Set answer[i] = product.
  2. 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 of i)

We can compute these two separately:

  • Left pass: Build a prefix array where prefix[i] = product of nums[0..i-1].
  • Right pass: Build a suffix array where suffix[i] = product of nums[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

  1. Initialise answer = [1] * n.
  2. Left pass: Maintain a running prefix = 1. For each i, set answer[i] = prefix, then update prefix *= nums[i].
  3. Right pass: Maintain a running suffix = 1. For each i from right to left, multiply answer[i] *= suffix, then update suffix *= nums[i].
  4. 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 × 9 Sudoku board is valid. Only the filled cells need to be validated according to the following rules:

  1. Each row must contain the digits 1–9 with no repetition.
  2. Each column must contain the digits 1–9 with no repetition.
  3. Each of the nine 3 × 3 sub-boxes must contain the digits 1–9 with 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: true

Example 2: Input: a board with 8 appearing twice in the top row Output: false

Constraints:

  • board.length == 9
  • board[i].length == 9
  • board[i][j] is a digit 1–9 or '.'.

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

  1. For each of 9 rows: collect non-'.' cells into a list; return false if any duplicates.
  2. For each of 9 columns: same check.
  3. For each of 9 boxes (indexed by box_row 0–2, box_col 0–2): collect the 9 cells in each 3×3 block; return false if any duplicates.
  4. 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 a rows set
  • Column check: store (col, digit) in a cols set
  • Box check: store (row // 3, col // 3, digit) in a boxes set

If any of these tuples is already in its respective set, return false immediately.

Algorithm

  1. Create three empty sets: rows, cols, boxes.
  2. For each cell (r, c) with value val:
    • Skip if val == '.'.
    • If (r, val) in rows or (c, val) in cols or (r//3, c//3, val) in boxes: return false.
    • Add the tuples to each set.
  3. 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: 9

Constraints:

  • 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

  1. Handle edge case: if nums is empty, return 0.
  2. Sort nums.
  3. Track current_streak = 1 and best = 1.
  4. For each index i from 1 to n - 1:
    • If nums[i] == nums[i - 1]: skip (duplicate).
    • If nums[i] == nums[i - 1] + 1: increment current_streak, update best.
    • Else: reset current_streak = 1.
  5. 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

  1. Build num_set = set(nums).
  2. Initialize best = 0.
  3. For each num in num_set:
    • If num - 1 is NOT in num_set (i.e., num is a sequence start):
      • Count streak: while num + streak is in num_set, increment streak.
      • Update best = max(best, streak).
  4. 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 inner while loop (only when it is part of a sequence being counted). Total inner loop iterations across all starting points is bounded by n.
  • 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 prices where prices[i] is the price of a given stock on the i-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: 7 Explanation: 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: 4 Explanation: Buy on day 1, sell on day 5. Profit=4.

Example 3: Input: prices = [7, 6, 4, 3, 1] Output: 0 Explanation: No profitable transaction possible.

Constraints:

  • 1 <= prices.length <= 3 * 10^4
  • 0 <= 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

  1. Initialize profit = 0, i = 0.
  2. While i < n - 1:
    • Find valley: while prices[i] >= prices[i + 1], increment i.
    • Find peak: while prices[i] <= prices[i + 1], increment i.
    • Add prices[peak] - prices[valley] to profit.
  3. 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

  1. Initialize profit = 0.
  2. For each index i from 0 to n - 2:
    • If prices[i + 1] > prices[i]: add prices[i + 1] - prices[i] to profit.
  3. 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

  1. Build a frequency map count.
  2. Threshold = n // 3.
  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, increment cnt1.
  • Else if it matches c2, increment cnt2.
  • Else if cnt1 == 0, replace c1 with the current number.
  • Else if cnt2 == 0, replace c2 with 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

  1. Phase 1: Find two candidates using the extended voting algorithm.
  2. Phase 2: Count actual occurrences of c1 and c2; include those exceeding n // 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 is 2, 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 (the nums.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 nums and an integer k, return the total number of subarrays whose sum equals k.

A subarray is a contiguous non-empty sequence of elements within an array.

Example 1: Input: nums = [1, 1, 1], k = 2 Output: 2

Example 2: Input: nums = [1, 2, 3], k = 3 Output: 2

Constraints:

  • 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 subarray i+1..j sums to k

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

  1. Initialize count = 0.
  2. For each i from 0 to n - 1:
    • Set total = 0.
    • For each j from i to n - 1:
      • Add nums[j] to total.
      • If total == k, increment count.
  3. 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

  1. Initialize prefix_counts = {0: 1}, prefix = 0, count = 0.
  2. For each num in nums:
    • Update prefix += num.
    • Add prefix_counts.get(prefix - k, 0) to count.
    • Increment prefix_counts[prefix] by 1.
  3. 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 most n + 1 distinct 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 uses O(1) auxiliary space.

Example 1: Input: nums = [1, 2, 0] Output: 3

Example 2: Input: nums = [3, 4, -1, 1] Output: 2

Example 3: Input: nums = [7, 8, 9, 11, 12] Output: 1

Constraints:

  • 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

  1. Convert nums to a set seen.
  2. For i = 1, 2, 3, ...:
    • If i is not in seen, return i.

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:

  1. 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.
  2. Mark: For each value v = abs(nums[i]) in [1, n], negate nums[v - 1] if it is positive (to avoid double-negation).
  3. Scan: The first index i with nums[i] > 0 means i + 1 is missing. If all are negative, return n + 1.

Algorithm

  1. Replace all values <= 0 or > n with n + 1.
  2. For each i in range n:
    • Let v = abs(nums[i]).
    • If 1 <= v <= n and nums[v - 1] > 0: negate nums[v - 1].
  3. For each i in range n:
    • If nums[i] > 0: return i + 1.
  4. 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] < 0 means value i + 1 WAS present in the original array.
  • nums[i] > 0 means value i + 1 was 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 prices where prices[i] is the price of a given stock on the ith 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, return 0.

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

  1. For each index i (buy day), iterate over every j > i (sell day).
  2. Compute profit as prices[j] - prices[i].
  3. 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

  1. Initialize left = 0, right = 1, max_profit = 0.
  2. While right < len(prices):
    • If prices[right] < prices[left], move left = right (found cheaper buy).
    • Otherwise, compute profit = prices[right] - prices[left] and update max_profit.
    • Advance right.
  3. 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: 6 Explanation: The subarray [4, -1, 2, 1] has sum 6.

Example 2: Input: nums = [1] Output: 1

Example 3: Input: nums = [5, 4, -1, 7, 8] Output: 23

Constraints:

  • 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 basicsdp[i] in terms of dp[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

  1. Initialise max_sum = -infinity.
  2. For each i from 0 to n-1:
    • running = 0
    • For each j from i to n-1:
      • running += nums[j]
      • max_sum = max(max_sum, running)
  3. 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

  1. Initialise current = nums[0], max_sum = nums[0].
  2. For each num in nums[1:]:
    • current = max(num, current + num) — start fresh or extend.
    • max_sum = max(max_sum, current).
  3. 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 of nums.

Example 1: Input: nums = [1, -2, 3, -2] Output: 3

Example 2: Input: nums = [5, -3, 5] Output: 10

Example 3: Input: nums = [-3, -2, -3] Output: -2

Constraints:

  • 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

  1. Double the array: doubled = nums + nums.
  2. For each start i from 0 to n-1:
    • Sum all subarrays doubled[i:i+k] for k from 1 to n.
    • Track the maximum.
  3. 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:

  1. Does not wrap around — this is just the regular max subarray (Kadane’s).
  2. 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

  1. Compute total = sum(nums).
  2. Run Kadane’s for max_sub.
  3. Run Kadane’s (minimising) for min_sub.
  4. If max_sub < 0, return max_sub (all negatives edge case).
  5. 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 either arr[i] > arr[i+1] < arr[i+2] or arr[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: 5 Explanation: [4, 2, 10, 7, 8] is turbulent.

Example 2: Input: nums = [4, 8, 12, 16] Output: 2

Example 3: Input: nums = [100] Output: 1

Constraints:

  • 1 <= nums.length <= 4 * 10^4
  • 0 <= 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

  1. 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 - i as the length.
  2. 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 are O(n²), inner check is O(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

  1. Initialise max_len = 1, cur_len = 1.
  2. For i from 1 to n-1:
    • Compute cmp: -1 if nums[i] < nums[i-1], 1 if greater, 0 if equal.
    • If cmp == 0: reset cur_len = 1.
    • Elif i == 1 or cmp == 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.
  3. 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 nums containing n + 1 integers where each integer is in the range [1, n] inclusive. There is only one repeated number in nums, return this repeated number. You must solve the problem without modifying the array nums and 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

  1. Initialize an empty set seen.
  2. For each number n in nums:
    • If n is already in seen, return n.
    • Otherwise, add n to seen.

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:

  1. Find where slow and fast pointers meet (inside the cycle).
  2. Reset one pointer to the start.
  3. 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

  1. Phase 1 — Find meeting point inside cycle:
    • slow = nums[0], fast = nums[0]
    • Move slow = nums[slow] and fast = nums[nums[fast]]
    • Repeat until slow == fast
  2. Phase 2 — Find cycle entry:
    • Reset slow2 = nums[0] (start of the “linked list”)
    • Move slow = nums[slow] and slow2 = nums[slow2] (both one step)
    • Repeat until slow == slow2
  3. 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:

  1. The value (the actual data)
  2. 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 prev pointers
  • 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

PageWhat You’ll Learn
Singly Linked ListsNodes that point forward only; append, prepend, delete, search
Doubly Linked ListsNodes that know both neighbours; O(1) delete, bidirectional traversal
QueuesFIFO 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

OperationTimeWhy
prependO(1)Rewire head only
appendO(1)Tail pointer available
deleteO(n)Must find predecessor
searchO(n)Sequential scan
traverseO(n)Visit every node
Access by indexO(n)No direct addressing

Without a tail pointer, append would require walking the entire list each time — making n appends cost O(n²) total. Always keep a tail.

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

OperationTimeWhy
prependO(1)Rewire head + new node’s next
appendO(1)Tail pointer available
delete_headO(1)head.next becomes new head
delete_tailO(1)tail.prev becomes new tail
delete_node(node)O(1)Node already knows its neighbours
searchO(n)Still a sequential scan
Traverse forwardO(n)Visit every node
Traverse backwardO(n)Visit every node via prev

The extra prev pointer 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.

StructureEnqueueDequeue
Python listO(1) amortisedO(n) — items shift
Singly linked listO(1)O(1)
Doubly linked listO(1)O(1)
collections.dequeO(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 CaseWhat Gets QueuedWhy FIFO Matters
Print spoolerPrint jobsDocuments print in submission order
CPU task schedulingOS processesFair allocation of CPU time
Message queues (Kafka, RabbitMQ)Events / messagesConsumers see events in order produced
BFS graph traversalNodes to visitShortest path guarantee requires level-order processing
Web server request handlingHTTP requestsRequests handled in arrival order
Keyboard input bufferKeystrokesCharacters appear in the order they were typed

Time Complexity Summary

OperationTimeNotes
enqueueO(1)Append to tail
dequeueO(1)Remove from head
peekO(1)Read head value
is_emptyO(1)Check length
Access by indexO(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:

SituationWhat happens
List has a cycleFast laps slow — they meet inside the cycle
List has no cycleFast reaches None — exits cleanly
Finding the middleWhen 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 None after 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

ProblemTimeSpaceKey insight
Detect cycleO(n)O(1)Fast always catches slow inside a cycle
Find cycle startO(n)O(1)F = C - k mathematical proof
Find middleO(n)O(1)Fast ends when slow is at centre
Even/odd lengthO(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_middle does; 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 .next references 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

  1. Walk the original list and collect all node values into a Python list.
  2. Reverse the Python list.
  3. 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

  1. Initialize prev = None and curr = head.
  2. While curr is not None: a. Save next_node = curr.next. b. Point curr.next = prev (reverse the arrow). c. Move prev = curr. d. Move curr = next_node.
  3. Return prev as 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

  1. Base case: if head is None or head.next is None, return head.
  2. Recurse on head.next — this reverses the rest and returns the new head.
  3. Set head.next.next = head (make the node after head point back to head).
  4. Set head.next = None (detach head from the old chain).
  5. 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 list1 and list2. 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

  1. Walk list1 and collect all values.
  2. Walk list2 and collect all values.
  3. Sort the combined values.
  4. 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

  1. Create a dummy sentinel node and set cur = dummy.
  2. While both list1 and list2 are not None:
    • If list1.val <= list2.val, attach list1 and advance list1.
    • Otherwise, attach list2 and advance list2.
    • Advance cur.
  3. Attach whichever list still has remaining nodes.
  4. 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 the next pointer. Internally, pos is used to denote the index of the node that tail’s next pointer is connected to (0-indexed). Note that pos is not passed as a parameter. Return true if there is a cycle in the linked list, otherwise return false.

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
  • pos is -1 or a valid index in the linked list

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Linked List Traversal — following .next pointers 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

  1. Initialize an empty set visited.
  2. Walk the list with pointer cur.
  3. If cur is already in visited, return True.
  4. Add cur to visited and advance to cur.next.
  5. If the loop ends (we hit None), return False.

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

  1. Initialize slow = head and fast = head.
  2. While fast and fast.next are not None:
    • Move slow one step: slow = slow.next.
    • Move fast two steps: fast = fast.next.next.
    • If slow == fast, return True.
  3. 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

  1. Collect all nodes into a Python list nodes.
  2. Use left = 0 and right = len(nodes) - 1 pointers.
  3. 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 right backward.
  4. 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:

  1. Find the middle using slow/fast pointers.
  2. Reverse the second half in-place.
  3. 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

  1. Find the middle node using slow/fast pointers.
  2. Split the list: mid.next = None terminates the first half.
  3. Reverse the second half.
  4. 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 nth node 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

  1. Walk the list to get its length L.
  2. The target node is at index L - n (0-indexed from head).
  3. Walk to index L - n - 1 (the node just before the target).
  4. Skip the target: prev.next = prev.next.next.
  5. 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 fast pointer n steps ahead.
  • Then move both slow and fast together until fast.next is None.
  • At that point, slow is 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

  1. Create a dummy node pointing to head. Set slow = dummy and fast = dummy.
  2. Advance fast exactly n steps forward.
  3. Move both slow and fast one step at a time until fast.next is None.
  4. Remove slow.next: slow.next = slow.next.next.
  5. 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 n is given such that each node contains an additional random pointer, which could point to any node in the list, or null. Construct a deep copy of the list. The deep copy should consist of exactly n brand new nodes, where each new node has its value set to the value of its corresponding original node. Both the next and random pointer 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 .next and .random for 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

  1. Edge case: if head is None, return None.
  2. Create a dictionary old_to_new = {}.
  3. First pass — create all copy nodes: for each node in the original list, create new_node = Node(node.val) and store old_to_new[node] = new_node.
  4. 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)
  5. 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:

  1. Interleave: For each original node, insert copy right after it: A -> A' -> B -> B' -> C -> C'
  2. Wire random: node.next.random = node.random.next (copy’s random = copy of original’s random)
  3. Separate: Unweave the two lists back apart.

Algorithm

  1. Walk the list and insert a copy of each node right after it.
  2. Walk again: for each original node cur, if cur.random exists, set cur.next.random = cur.random.next.
  3. 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

  1. Create a dummy head and a cur pointer.
  2. Initialize carry = 0.
  3. While l1 or l2 or carry is 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 l1 and l2 if they’re not exhausted.
  4. 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 most max(m, n) + 1 nodes

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

  1. Walk l1 and reconstruct the integer (least significant digit at index 0 means multiply by 10^i).
  2. Same for l2.
  3. Compute total = num1 + num2.
  4. Build the result list from total by repeatedly taking total % 10 and 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 left and right where left <= right, reverse the nodes of the list from position left to position right, 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

  1. Walk the list and collect all node values into a Python list.
  2. Reverse the slice values[left-1:right] in-place.
  3. Walk the original list again and overwrite each node’s .val with 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 position left (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

  1. Create a dummy node and set dummy.next = head.
  2. Walk left - 1 steps from dummy to find prev_left (the node before position left).
  3. Set left_node = prev_left.next (the node at position left).
  4. Reverse the sublist from position left to right using standard iterative reversal. Keep count.
  5. Re-attach: prev_left.next = right_node (the new head of the reversed segment) and left_node.next = after_right.
  6. 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 find prev_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 MyCircularQueue class:

  • MyCircularQueue(k) — Initializes the object with the size of the queue k.
  • enQueue(value) — Inserts an element into the circular queue. Returns True if the operation is successful.
  • deQueue() — Deletes an element from the circular queue. Returns True if the operation is successful.
  • Front() — Gets the front item from the queue. Returns -1 if the queue is empty.
  • Rear() — Gets the last item from the queue. Returns -1 if 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 with k slots)
  • self.head — index of the front element
  • self.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 size k, set head = 0, count = 0, k = k.
  • enQueue(value): If full, return False. Write to (head + count) % k. Increment count. Return True.
  • deQueue(): If empty, return False. Advance head = (head + 1) % k. Decrement count. Return True.
  • Front(): If empty, return -1. Return queue[head].
  • Rear(): If empty, return -1. Return queue[(head + count - 1) % k].
  • isEmpty(): Return count == 0.
  • isFull(): Return count == 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 LRUCache class:

  • LRUCache(capacity) — Initialize the LRU cache with positive size capacity.
  • get(key) — Return the value of the key if the key exists, otherwise return -1.
  • put(key, value) — Update the value of the key if the key exists. Otherwise, add the key-value pair to the cache. If the number of keys exceeds the capacity from this operation, evict the least recently used key.

Both get and put must each run in O(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:

  1. O(1) access by key → hash map: key → node
  2. 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_head is 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 most capacity nodes 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 LFUCache class:

  • LFUCache(capacity) — Initialize the LFU cache with the given capacity.
  • get(key) — Return the value of key if 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 get and put must run in O(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 → value
  • key_to_freq — maps key → current frequency
  • freq_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):

  1. Return -1 if not found.
  2. Increment key_to_freq[key].
  3. Move the key from its old frequency bucket to the new one.
  4. If the old bucket was min_freq and is now empty, increment min_freq.
  5. Return the value.

On put(key, value):

  1. If key already exists, update its value and call the same “increment frequency” logic as get.
  2. If key is new, check capacity. If full, evict the LRU from freq_to_keys[min_freq].
  3. 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 from freq_to_keys[old_freq]. If old_freq == min_freq and bucket is now empty, min_freq += 1. Add key to freq_to_keys[new_freq] (at the end = MRU within that bucket).
  • get(key): If not found return -1. Else _increment_freq(key), return key_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 both get and put — 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 k linked-lists lists, 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

  1. Walk all k lists and collect all values into one Python list.
  2. Sort the values.
  3. 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

  1. Initialize a min-heap with (node.val, i, node) for each non-empty list head.
  2. While the heap is not empty:
    • Pop the smallest (val, i, node).
    • Attach node to the result list.
    • If node.next exists, push (node.next.val, i, node.next) onto the heap.
  3. 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 k at a time, and return the modified list. k is 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 of k then 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

  1. Check if at least k nodes exist starting from head. If not, return head.
  2. Reverse the first k nodes, keeping track of the k-th node and the node after it.
  3. The original head becomes the tail of the reversed group. Connect it to reverseKGroup(k+1th node, k).
  4. 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:

  1. Find the k-th node from group_prev. If it doesn’t exist, stop.
  2. Save group_next = kth_node.next, then reverse the group.
  3. Re-attach: group_prev.next = kth_node (new head), original_head.next = group_next.
  4. 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

  1. Create dummy node, set group_prev = dummy.
  2. 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. Advance group_prev = group_start.
  3. 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:

PartWhat it doesWhat happens without it
Base caseStops the recursionInfinite loop → stack overflow
Recursive caseBreaks the problem into a smaller version of itselfNothing 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:

  1. Make sure the base case exists and is reachable.
  2. Switch to an iterative solution for very deep inputs.
  3. Use memoization so repeated calls do not re-enter the stack.

The Two-Question Test

Before writing any recursive function, answer these two questions:

  1. What is the smallest version of this problem I can solve directly? → That is your base case.
  2. 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}")
RecursiveIterative
ReadabilityMirrors the math definitionRequires mental translation
Stack usageO(n) framesO(1) — no stack growth
RiskStack overflow for large nNone
When to preferTeaching, prototypingProduction 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.")
ApproachTime complexitySpace complexityStack depth
Naive recursiveO(2^n)O(n) stackO(n)
Memoized recursiveO(n)O(n) cache + O(n) stackO(n)
IterativeO(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

AlgorithmBest CaseAverage CaseWorst CaseSpaceStable?
Insertion SortO(n)O(n²)O(n²)O(1)Yes
Merge SortO(n log n)O(n log n)O(n log n)O(n)Yes
Quick SortO(n log n)O(n log n)O(n²)O(log n)No
Bucket SortO(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

CaseWhen it happensOperations
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

CaseTimeWhy
BestO(n log n)Always splits and merges the same way
AverageO(n log n)Same structure regardless of input
WorstO(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() and list.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

CaseTimeWhen it happens
BestO(n log n)Pivot always splits array in half
AverageO(n log n)Random data, random pivot
WorstO(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 BY clause 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
CaseTimeSpace
BestO(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:

  1. Look at the middle element.
  2. If it equals the target, you’re done.
  3. If the target is smaller, throw away the right half.
  4. If the target is larger, throw away the left half.
  5. 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:

StepElements remaining
0n
1n / 2
2n / 4
kn / 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

ChapterTopic
Search ArrayClassic binary search on a sorted array — iterative and recursive, with edge cases
Search RangeFinding 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

MetricValueWhy
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

ProblemTimeSpace
find_firstO(log n)O(1)
find_lastO(log n)O(1)
search_rangeO(log n)O(1)
find_sqrtO(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 modulebisect.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 nums which is sorted in ascending order, and an integer target, write a function to search target in nums. If target exists, 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 = 9 Output: 4

Example 2: Input: nums = [-1, 0, 3, 5, 9, 12], target = 2 Output: -1

Constraints:

  • 1 <= nums.length <= 10^4
  • -10^4 < nums[i], target < 10^4
  • All integers in nums are unique
  • nums is 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

  1. For each index i from 0 to len(nums) - 1:
    • If nums[i] == target, return i.
  2. Return -1 if 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)

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

  1. Set lo = 0 and hi = len(nums) - 1.
  2. While lo <= hi:
    • Compute mid = lo + (hi - lo) // 2.
    • If nums[mid] == target, return mid.
    • If nums[mid] < target, set lo = mid + 1.
    • Else set hi = mid - 1.
  3. 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 nums and 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 = 5 Output: 2

Example 2: Input: nums = [1, 3, 5, 6], target = 2 Output: 1

Example 3: Input: nums = [1, 3, 5, 6], target = 7 Output: 4

Constraints:

  • 1 <= nums.length <= 10^4
  • -10^4 <= nums[i] <= 10^4
  • nums contains 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 lo represents 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

  1. For each index i from 0 to len(nums) - 1:
    • If nums[i] >= target, return i.
  2. 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

  1. Set lo = 0, hi = len(nums) - 1.
  2. While lo <= hi:
    • Compute mid = lo + (hi - lo) // 2.
    • If nums[mid] == target, return mid.
    • If nums[mid] < target, set lo = mid + 1.
    • Else set hi = mid - 1.
  3. 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 1 to n. 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 = 6 Output: 6

Example 2: Input: n = 1, pick = 1 Output: 1

Example 3: Input: n = 2, pick = 1 Output: 1

Constraints:

  • 1 <= n <= 2^31 - 1
  • 1 <= 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

  1. Set lo = 1, hi = n.
  2. While lo <= hi:
    • Compute mid = lo + (hi - lo) // 2.
    • Call result = guess(mid).
    • If result == 0, return mid — that’s the picked number.
    • If result == 1, the pick is higher, so set lo = mid + 1.
    • If result == -1, the pick is lower, so set hi = mid - 1.
  3. The loop always terminates because pick is 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 of x rounded 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) or x ** 0.5.

Example 1: Input: x = 4 Output: 2

Example 2: Input: x = 8 Output: 2 Explanation: 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

  1. Handle x == 0 separately, returning 0.
  2. For m from 1 upward:
    • If m * m > x, return m - 1.

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

  1. Set lo = 0, hi = x, ans = 0.
  2. While lo <= hi:
    • Compute mid = lo + (hi - lo) // 2.
    • If mid * mid <= x: set ans = mid, then lo = mid + 1 (try larger).
    • Else: set hi = mid - 1 (too big, go smaller).
  3. 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 n integer matrix matrix with 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, return true if target is in matrix or false otherwise.

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 = 3 Output: true

Example 2: Input: matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13 Output: false

Constraints:

  • m == matrix.length, n == matrix[i].length
  • 1 <= 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 i into (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

  1. For each row r in matrix:
    • For each element val in row r:
      • If val == target, return True.
  2. 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

  1. Binary search rows: find the last row where row[0] <= target.
  2. Binary search that row for target.
  3. 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

  1. Set lo = 0, hi = m * n - 1.
  2. While lo <= hi:
    • Compute mid = lo + (hi - lo) // 2.
    • Map to 2D: r = mid // n, c = mid % n.
    • Compare matrix[r][c] with target and update bounds.
  3. Return True on a match, False if 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 n piles of bananas, the i-th pile has piles[i] bananas. The guards have gone and will come back in h hours.

Koko can decide her bananas-per-hour eating speed of k. Each hour, she chooses some pile of bananas and eats k bananas from that pile. If the pile has less than k bananas, 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 k such that she can eat all the bananas within h hours.

Example 1: Input: piles = [3, 6, 7, 11], h = 8 Output: 4

Example 2: Input: piles = [30, 11, 23, 4, 20], h = 5 Output: 30

Constraints:

  • 1 <= piles.length <= 10^4
  • piles.length <= h <= 10^9
  • 1 <= 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) // k or math.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

  1. For k from 1 to max(piles):
    • Compute hours = sum(ceil(pile / k) for pile in piles).
    • If hours <= h, return k.

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

  1. Set lo = 1, hi = max(piles), ans = hi.
  2. While lo <= hi:
    • Compute mid = lo + (hi - lo) // 2.
    • Compute hours = sum(ceil(pile / mid) for pile in piles).
    • If hours <= h: record ans = mid, then search left (hi = mid - 1) for a smaller valid speed.
    • Else: search right (lo = mid + 1) — too slow.
  3. 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 days days.

The i-th package on the conveyor belt has a weight of weights[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 days days.

Example 1: Input: weights = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], days = 5 Output: 15

Example 2: Input: weights = [3, 2, 2, 4, 1, 4], days = 3 Output: 6

Constraints:

  • 1 <= days <= weights.length <= 500
  • 1 <= 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

  1. For cap from max(weights) to sum(weights):
    • Simulate loading: greedily fill each day’s load without exceeding cap.
    • Count the number of days used.
    • If days used <= days, return cap.

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

  1. Define canShip(cap): greedily fill each day; start a new day when the next package would exceed cap. Return whether the day count <= days.
  2. Set lo = max(weights), hi = sum(weights), ans = hi.
  3. While lo <= hi:
    • mid = lo + (hi - lo) // 2.
    • If canShip(mid): ans = mid, hi = mid - 1.
    • Else: lo = mid + 1.
  4. 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 n sorted in ascending order is rotated between 1 and n times. 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 nums of 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: 1

Example 2: Input: nums = [4, 5, 6, 7, 0, 1, 2] Output: 0

Example 3: Input: nums = [11, 13, 15, 17] Output: 11

Constraints:

  • n == nums.length
  • 1 <= n <= 5000
  • -5000 <= nums[i] <= 5000
  • All integers in nums are unique
  • nums is sorted and rotated between 1 and n times

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

  1. 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

  1. Set lo = 0, hi = len(nums) - 1, ans = nums[0].
  2. 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.
  3. 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 nums sorted in ascending order (with distinct values).

Prior to being passed to your function, nums is possibly rotated at an unknown pivot index k such that the resulting array is [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]].

Given the array nums after the possible rotation and an integer target, return the index of target if it is in nums, or -1 if it is not in nums.

You must write an algorithm with O(log n) runtime complexity.

Example 1: Input: nums = [4, 5, 6, 7, 0, 1, 2], target = 0 Output: 4

Example 2: Input: nums = [4, 5, 6, 7, 0, 1, 2], target = 3 Output: -1

Constraints:

  • 1 <= nums.length <= 5000
  • -10^4 <= nums[i] <= 10^4
  • All values of nums are unique
  • nums is 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

  1. For each index i, if nums[i] == target, return i.
  2. 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

  1. Set lo = 0, hi = len(nums) - 1.
  2. While lo <= hi:
    • mid = lo + (hi - lo) // 2.
    • If nums[mid] == target, return mid.
    • 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.
    • Right half is sorted (otherwise):
      • If nums[mid] < target <= nums[hi]: search right → lo = mid + 1.
      • Else: search left → hi = mid - 1.
  3. 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 nums sorted in non-decreasing order (not necessarily with distinct values).

Before being passed to your function, nums is rotated at an unknown pivot index k. Given the array nums after the possible rotation and an integer target, return true if target is in nums, or false if it is not in nums.

You must decrease the overall operation steps as much as possible.

Example 1: Input: nums = [2, 5, 6, 0, 0, 1, 2], target = 0 Output: true

Example 2: Input: nums = [2, 5, 6, 0, 0, 1, 2], target = 3 Output: false

Constraints:

  • 1 <= nums.length <= 5000
  • -10^4 <= nums[i] <= 10^4
  • nums is 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

  1. 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

  1. Set lo = 0, hi = len(nums) - 1.
  2. While lo <= hi:
    • mid = lo + (hi - lo) // 2.
    • If nums[mid] == target, return True.
    • 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.
    • Right half sorted:
      • If nums[mid] < target <= nums[hi]: lo = mid + 1.
      • Else: hi = mid - 1.
  3. 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 TimeMap class:

  • 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 that set was called previously with timestamp_prev <= timestamp. If there are multiple such values, it returns the value associated with the largest timestamp_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 <= 100
  • key and value consist of lowercase English letters and digits
  • 1 <= timestamp <= 10^7
  • All calls to set are made with strictly increasing timestamp
  • At most 2 * 10^5 calls will be made to set and get

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Hash Maps — mapping keys to lists of values
  • Binary Search — finding the largest timestamp <= query in 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

  1. set(key, value, timestamp): append (timestamp, value) to store[key].
  2. get(key, timestamp): iterate store[key], track the best (timestamp_prev, value) where timestamp_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

  1. set(key, value, timestamp): append (timestamp, value) to store[key].
  2. get(key, timestamp):
    • If key doesn’t exist, return "".
    • Binary search store[key] for the largest index where ts <= timestamp.
    • Set lo = 0, hi = len(pairs) - 1, ans = "".
    • While lo <= hi: compute mid, if pairs[mid][0] <= timestamp record ans = pairs[mid][1] and go right (lo = mid + 1); else go left (hi = mid - 1).
    • Return ans.
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 nums and an integer k, split nums into k non-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 = 2 Output: 18 Explanation: 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 = 2 Output: 9 Explanation: Split into [1,2,3,4] and [5]. Largest sum is 9.

Constraints:

  • 1 <= nums.length <= 1000
  • 0 <= nums[i] <= 10^6
  • 1 <= 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

  1. Define dp(i, pieces): minimum possible largest sum when splitting nums[i:] into pieces parts.
  2. Base case: pieces == 1 → return sum(nums[i:]).
  3. For each j from i to n - pieces: try putting nums[i:j+1] in the current part. Recurse on dp(j+1, pieces-1). Track the minimum of max(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

  1. canSplit(cap): greedily fill subarrays; count how many are needed. Return count <= k.
  2. Binary search [max(nums), sum(nums)] for the smallest cap where canSplit returns True.
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 nums1 and nums2 of size m and n respectively, 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.0 Explanation: merged array = [1, 2, 3], median is 2.

Example 2: Input: nums1 = [1, 2], nums2 = [3, 4] Output: 2.5 Explanation: merged array = [1, 2, 3, 4], median is (2 + 3) / 2 = 2.5.

Constraints:

  • m == nums1.length, n == nums2.length
  • 0 <= m, n <= 1000
  • 1 <= 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

  1. Merge nums1 and nums2 into a single sorted array merged.
  2. If total length is odd, return the middle element.
  3. 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:

  1. The total number of elements on the left equals (m + n) // 2 (or // 2 + 1 for odd total).
  2. 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

  1. Ensure nums1 is the shorter array (swap if needed).
  2. Set lo = 0, hi = len(nums1).
  3. While lo <= hi:
    • i = (lo + hi) // 2 — cut in nums1 (0..m elements on left).
    • j = half - i — cut in nums2.
    • Let A_left = nums1[i-1] (or -inf), A_right = nums1[i] (or +inf), similarly for B.
    • If A_left <= B_right and B_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.
    • If A_left > B_right: i is too large → hi = i - 1.
    • Else: i is 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 arr is a mountain array if and only if:

  • arr.length >= 3
  • There exists some index i (0-indexed) with 0 < i < arr.length - 1 such that:
    • arr[0] < arr[1] < ... < arr[i]
    • arr[i] > arr[i+1] > ... > arr[arr.length - 1]

Given a mountain array mountainArr and an integer target, return the minimum index such that mountainArr.get(index) == target. If such an index does not exist, return -1.

You can only access the array via the MountainArray interface:

  • MountainArray.get(index) returns the element at index index
  • MountainArray.length() returns the length of the array

Submissions that call get more than 100 times will be judged as Wrong Answer.

Example 1: Input: mountainArr = [1, 2, 3, 4, 5, 3, 1], target = 3 Output: 2

Example 2: Input: mountainArr = [0, 1, 2, 4, 2, 1], target = 3 Output: -1

Constraints:

  • 3 <= mountainArr.length() <= 10^4
  • 0 <= target <= 10^9
  • 0 <= 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:

  1. Find the peak index — binary search for the point where the array switches from increasing to decreasing. At mid, if get(mid) < get(mid+1) the peak is to the right; otherwise it’s at mid or to the left.

  2. Search the ascending left half — standard binary search in [0, peak]. Return the index if found.

  3. 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:

  1. lo = 0, hi = n - 2 (peak can’t be the last index).
  2. While lo < hi: mid = (lo + hi) // 2. If get(mid) < get(mid+1): lo = mid + 1. Else: hi = mid.
  3. peak = lo.

Phase 2 — Ascending binary search [0, peak]:

  1. Standard binary search. If get(mid) == target, return mid. If get(mid) < target, lo = mid+1. Else hi = mid-1.

Phase 3 — Descending binary search [peak+1, n-1]:

  1. Same structure but flip: if get(mid) > target, lo = mid+1. If get(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, each O(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 integers k and x, return the k closest integers to x in the array. The result should also be sorted in ascending order. An integer a is closer to x than an integer b if |a - x| < |b - x|, or |a - x| == |b - x| and a < 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

  1. Sort arr by the key (abs(a - x), a) — distance first, value as tiebreaker.
  2. Take the first k elements.
  3. Sort those k elements 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

  1. Binary search for the left boundary:
    • lo = 0, hi = len(arr) - k
    • While lo < hi, compute mid = (lo + hi) // 2.
    • If x - arr[mid] > arr[mid + k] - x, set lo = mid + 1 (slide right).
    • Else set hi = mid (slide left or stay).
  2. 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

TermMeaning
RootThe single top-level node — has no parent
LeafA node with no children
HeightNumber of edges on the longest path from root to a leaf
DepthNumber of edges from the root to a given node
SubtreeA node plus all of its descendants — itself a valid tree
LevelAll 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:

  1. Binary Tree — understand the node/root/leaf vocabulary, build trees in code, and think about the three ways to walk one.
  2. Binary Search Tree — exploit the left < node < right ordering rule to search in O(log n) time instead of O(n).
  3. BST Insert and Remove — keep a BST sorted through insertions and the tricky three-case deletion.
  4. Depth-First Search — dive deep before backtracking; produce sorted output, copy a tree, evaluate expressions.
  5. Breadth-First Search — sweep level by level with a queue; find shortest paths and serialize trees.
  6. 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
ConceptDefinitionIn the diagram above
RootThe top node — has no parentNode 1
LeafA node with no childrenNodes 4, 5, 6
HeightLongest root-to-leaf edge count2 (root → 2 → 4)
DepthEdge count from root to this nodeNode 6 is at depth 2
SubtreeAny node + all its descendantsNode 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:

OrderVisit sequenceMemory trick
Pre-orderRoot → Left → RightPre = root comes before children
In-orderLeft → Root → RightRoot is in the middle
Post-orderLeft → Right → RootRoot 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:

  1. Visit 2 (leaf) → value is 2
  2. Visit 3 (leaf) → value is 3
  3. Visit 4 (leaf) → value is 4
  4. Visit * → compute 3 * 4 = 12
  5. 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:

OrderRuleMnemonic
Pre-orderVisit node, then left subtree, then right subtreePre = current node comes first
In-orderVisit left subtree, then node, then right subtreeNode goes in the middle
Post-orderVisit left subtree, then right subtree, then nodePost = 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:

  1. Enqueue the root.
  2. Dequeue the front node, record it, enqueue its children.
  3. 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

QuestionBest choiceWhy
Does a path from root to leaf exist?EitherBoth find all reachable nodes
What is the shortest path from root to a target?BFSBFS finds the nearest match first
Is there any path at all in a deep tree?DFSLess memory for very deep, narrow trees
Visit nodes in level order / by distanceBFSThat is exactly what BFS does
Copy, delete, or evaluate a treeDFSRecursive structure fits naturally
Find all ancestors of a nodeDFSFollow 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())
FeatureHash dict (dict)BST map (BSTMap)
Average lookupO(1)O(log n)
Sorted iterationNot supportedO(n) — free via in-order
Range queryNot supportedO(log n + k)
Min / Max keyNot supportedO(log n)
Worst-case lookupO(n) — hash collisionO(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:

  1. A map from character → child node
  2. 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')}")  # []

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
OperationTrieHash MapSorted Array
InsertO(m)O(m)O(n)
SearchO(m)O(m)O(m log n)
Prefix checkO(m)O(n·m)O(m log n)
AutocompleteO(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

OperationTimeSpace
Insert word of length mO(m)O(m) worst case (all new nodes)
Search word of length mO(m)O(1)
Prefix check of length mO(m)O(1)
Autocomplete prefix m, k result charsO(m + k)O(k) for output
Total space for n words, avg length mO(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 autocomplete function 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 Trie class:

  • Trie() Initializes the trie object.
  • void insert(String word) Inserts the string word into the trie.
  • boolean search(String word) Returns true if the string word is in the trie (i.e., was inserted before), and false otherwise.
  • boolean startsWith(String prefix) Returns true if there is a previously inserted string that has the prefix prefix, and false otherwise.

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 <= 2000
  • word and prefix consist only of lowercase English letters
  • At most 3 * 10^4 calls total to insert, search, and startsWith

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

  1. Keep a set of inserted words.
  2. insert(word): add word to the set.
  3. search(word): return word in self.words.
  4. startsWith(prefix): iterate through all stored words and return True if any starts with prefix.

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

  1. Define TrieNode with children = {} and is_end = False.
  2. insert(word): start at root, for each char create a child node if missing, then move to it. Mark is_end = True on the last node.
  3. search(word): walk the trie for each char; if a char is missing return False. After the last char, return node.is_end.
  4. startsWith(prefix): same walk but return True regardless of is_end if 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 WordDictionary class:

  • WordDictionary() Initializes the object.
  • void addWord(word) Adds word to the data structure.
  • bool search(word) Returns true if there is any string in the data structure that matches word or false otherwise. word may 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 <= 25
  • word in addWord consists of lowercase English letters
  • word in search consists of '.' or lowercase English letters
  • At most 10^4 calls to addWord and search

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

  1. Keep a list of added words.
  2. addWord(word): append to the list.
  3. 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

  1. Build a trie with TrieNode(children={}, is_end=False).
  2. addWord(word): standard trie insert.
  3. search(word): define a recursive helper dfs(node, index):
    • If index == len(word), return node.is_end.
    • Let ch = word[index].
    • If ch == '.': call dfs(child, index+1) for every child; return True if any succeeds.
    • Otherwise: if ch not in node.children return False; else recurse on node.children[ch].

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 s and a dictionary of words dictionary. You have to break s into one or more non-overlapping substrings such that each substring is present in dictionary. There may be some extra characters in s which are not present in any substring.

Return the minimum number of extra characters left over if you break up s optimally.

Example 1: Input: s = "leetscode", dictionary = ["leet","code","leetcode"] Output: 1

Example 2: Input: s = "sayhelloworld", dictionary = ["hello","world"] Output: 3

Constraints:

  • 1 <= s.length <= 50
  • 1 <= dictionary.length <= 50
  • 1 <= dictionary[i].length <= 50
  • s and dictionary[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

  1. Build a set from dictionary for O(1) lookup.
  2. Create dp array of size n+1, initialize to 0. Set dp[n] = 0.
  3. For i from n-1 down to 0:
    • Option 1: skip s[i], so dp[i] = 1 + dp[i+1].
    • Option 2: for each j from i+1 to n+1, if s[i:j] in dict, consider dp[j].
    • Take the minimum.
  4. 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

  1. Build a trie from all words in dictionary.
  2. Fill dp from right to left exactly as before.
  3. For position i, walk the trie starting at root, following s[i], s[i+1], ... until a character isn’t found. At each position j where trie_node.is_end is True, update dp[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 n board of characters and a list of strings words, 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].length
  • 1 <= m, n <= 12
  • board[i][j] is a lowercase English letter
  • 1 <= words.length <= 3 * 10^4
  • 1 <= words[i].length <= 10
  • All words in words are 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

  1. For each word in words:
    • For each cell (r, c) on the board, run DFS to check if word can be formed starting here.
    • If yes, add word to results.
  2. 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

  1. Build a trie from words. Each node also stores the word string at is_end nodes (easier than reconstructing).
  2. DFS over every board cell (r, c) as a potential starting point.
  3. In the DFS, given node (current trie node) and position (r, c):
    • If board[r][c] not in node.children, return.
    • Move to next_node = node.children[board[r][c]].
    • If next_node.word, add it to results and clear next_node.word (dedup).
    • Mark cell visited, recurse into 4 neighbors, unmark on backtrack.
  4. 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 does x belong to? Returns a “representative” (root) for the group.
  • union(x, y) — merge the groups containing x and y into 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.

Implementationfindunion
Naive (no optimisation)O(n) worst caseO(n) worst case
Union by rank onlyO(log n)O(log n)
Path compression onlyO(log n) amortisedO(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:

ApproachQuery timeUpdate time
Loop over the rangeO(n)O(1)
Prefix sum arrayO(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):

RelationshipFormula
Left child of node i2 * i
Right child of node i2 * i + 1
Parent of node ii // 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

OperationTimeSpace
BuildO(n)O(n)
Range queryO(log n)O(log n) stack
Point updateO(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 DFSIterative DFS
TimeO(n)O(n)
Space (balanced tree)O(log n) call stackO(log n) explicit stack
Space (skewed tree)O(n) — may crashO(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 traversalos.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 root of 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

  1. If the current node is null, return
  2. Recurse on the left child
  3. Append the current node’s value to the result
  4. 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

  1. Initialize an empty stack and set curr = root
  2. While curr is not null or the stack is not empty:
    • Push curr and go left until null
    • Pop from stack, record its value
    • Move curr to the popped node’s 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_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 root of 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

  1. If the current node is null, return
  2. Append the current node’s value to the result
  3. Recurse on the left child
  4. 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

  1. Initialize a stack with the root node
  2. 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 root of 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

  1. If the current node is null, return
  2. Recurse on the left child
  3. Recurse on the right child
  4. 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

  1. Use a stack starting with root
  2. 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
  3. 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 root of 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

  1. If the current node is null, return null
  2. Swap node.left and node.right
  3. Recursively invert node.left
  4. Recursively invert node.right
  5. 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

  1. Initialize a queue with the root
  2. While the queue is not empty:
    • Dequeue a node
    • Swap its left and right children
    • Enqueue non-null children
  3. 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 root of 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

  1. If the node is null, return 0
  2. Recursively get the depth of the left subtree
  3. Recursively get the depth of the right subtree
  4. 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

  1. If root is null, return 0
  2. Initialize a queue with root and a depth counter at 0
  3. For each level:
    • Increment depth
    • Process all nodes at this level, enqueuing their children
  4. 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 root of 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

  1. Initialize max_diameter = 0
  2. Define a DFS function that returns the height of the subtree rooted at node:
    • Base case: null node returns height 0
    • Compute left_height and right_height recursively
    • Update max_diameter = max(max_diameter, left_height + right_height)
    • Return 1 + max(left_height, right_height) — the height of this subtree
  3. Call DFS on root
  4. 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

  1. Define height(node) that returns the max depth of a subtree
  2. 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

  1. 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)
  2. 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 p and q, 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

  1. If both nodes are null → return true (both trees “end” here)
  2. If exactly one is null → return false (structural mismatch)
  3. If p.val != q.val → return false (value mismatch)
  4. 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 root and subRoot, return true if there is a subtree of root with the same structure and node values of subRoot and false otherwise. 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 root tree is in the range [1, 2000]
  • The number of nodes in the subRoot tree 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

  1. If root is null, return false (subRoot can’t match nothing)
  2. If is_same_tree(root, subRoot), return true
  3. Otherwise, check is_subtree(root.left, subRoot) or is_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

  1. Serialize root and subRoot to strings via preorder DFS
  2. 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.val are unique
  • p != q
  • p and q will 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

  1. If both p.val and q.val are less than root.val, recurse left
  2. If both are greater than root.val, recurse right
  3. Otherwise, root is 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 root node of a binary search tree (BST) and a value to 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.val are unique
  • -10^8 <= val <= 10^8
  • It’s guaranteed that val does 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

  1. If root is null, create and return a new node with val
  2. If val < root.val, recurse left and assign the result to root.left
  3. If val > root.val, recurse right and assign the result to root.right
  4. 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

  1. Handle empty tree edge case
  2. Walk with curr pointer, comparing val to navigate left or right
  3. When curr is null, we’ve found the insertion spot — attach to parent

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
  • root is 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

  1. If root is null, return null (key not found)
  2. If key < root.val, recurse left: root.left = delete(root.left, key)
  3. If key > root.val, recurse right: root.right = delete(root.right, key)
  4. Otherwise, root is 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
  5. 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 root of 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

  1. If root is null, return []
  2. Initialize a queue with root, and an empty result list
  3. While the queue is not empty:
    • Snapshot level_size = len(queue)
    • Process exactly level_size nodes, collecting their values into current_level
    • Enqueue each node’s left and right children (if they exist)
    • Append current_level to result
  4. 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 root of 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

  1. Use BFS with a queue
  2. For each level, process all nodes in the queue snapshot
  3. Record the last node’s value of each level
  4. 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

  1. DFS with depth parameter, starting at 0
  2. If depth == len(result), this is the first (rightmost) node at this depth — add it
  3. 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 * n matrix grid of 0s and 1s only, we want to represent grid with a Quad-Tree. Return the root of the Quad-Tree representing grid.

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) and isLeaf (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].length
  • n == 2^x where 0 <= x <= 6
  • 1 <= n <= 64
  • grid[i][j] is either 0 or 1

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

  1. Check if all values in the current (row, col, size) region are the same
  2. If uniform → create a leaf node with that value
  3. 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

  1. Define DFS with parameters (node, max_so_far)
  2. If node is null, return 0
  3. Count this node as good if node.val >= max_so_far
  4. Update new_max = max(max_so_far, node.val)
  5. 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 root of 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

  1. Perform inorder traversal to get values in order
  2. 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

  1. Define dfs(node, min_val, max_val) returning bool
  2. Base case: null node is valid
  3. If node.val <= min_val or node.val >= max_val, return false
  4. Recurse: left with (min_val, node.val), right with (node.val, max_val)
  5. 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 root of a binary search tree, and an integer k, return the kth 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 n
  • 1 <= k <= n <= 10^4
  • 0 <= 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

  1. Do inorder traversal, collect all values into a list
  2. 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

  1. Use a stack and curr = root
  2. Drill left as far as possible, pushing nodes onto the stack
  3. Pop a node — this is the next smallest
  4. Decrement k; if k == 0, return this node’s value
  5. 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 preorder and inorder where preorder is the preorder traversal of a binary tree and inorder is 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 <= 3000
  • inorder.length == preorder.length
  • -3000 <= preorder[i], inorder[i] <= 3000
  • preorder and inorder consist of unique values
  • Each value of inorder also appears in preorder
  • preorder is guaranteed to be the preorder traversal of the tree
  • inorder is 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

  1. Build a {value: index} map for the inorder array
  2. Use a pointer into preorder (or index) to track the next root
  3. 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)
  4. 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 the root of 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 node
  • skip = 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

  1. Define dfs(node)(rob, skip) tuple
  2. Base case: null node returns (0, 0)
  3. 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)
  4. Return (rob, skip)
  5. 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 root and an integer target, delete all the leaf nodes with value target. Note that once you delete a leaf node with value target, if its parent node becomes a leaf node and has the value of target, 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

  1. DFS in post-order: recurse left, recurse right, then process current node
  2. Assign node.left = dfs(node.left) and node.right = dfs(node.right)
  3. If the current node is now a leaf (no left, no right) and node.val == target, return null (delete it)
  4. 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 root of 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:

  1. What’s the maximum path sum that passes through this node (using both subtrees)? This updates the global answer.
  2. 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

  1. Define dfs(node) returning the max gain this node can contribute upward
  2. left_gain = max(dfs(node.left), 0) — ignore negative subtrees
  3. right_gain = max(dfs(node.right), 0) — ignore negative subtrees
  4. Update global_max = max(global_max, node.val + left_gain + right_gain)
  5. 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:

  1. Preorder DFS: write node.val or "null" for missing nodes
  2. Join with a delimiter (e.g., comma)

Deserialize:

  1. Split string into token list
  2. 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:

  1. Create root from first token
  2. Use a queue of nodes that need children assigned
  3. 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 formatRecursive depth-firstFamiliar LeetCode format
Null markersExplicit for every missing childTrailing nulls omittable
String lengthUsually shorter for sparse treesCan have many trailing nulls
DeserializationClean recursive implementationIterative, 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:

  1. If the current node is None, this path failed — return False.
  2. If the current node holds the target — found it, return True.
  3. 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

StepPart 1 (find target)Part 2 (all paths)Part 3 (path sum)
Choosevisit nodepath.append(node.val)path.append(node.val)
Explorerecurse left, then rightrecurse left, then rightrecurse left, then right
Unchooseimplicit (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]:

  1. backtrack(0, []) records [], then tries each starting index.
  2. Appending 1 gives [1] — recorded, then backtrack(1, [1]) continues.
  3. Inside that call, appending 2 gives [1, 2] — and so on.
  4. 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

ApproachTimeSpace
Backtracking (no duplicates)O(n · 2ⁿ)O(n) recursion stack + O(n · 2ⁿ) output
BitmaskO(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

ConceptOrder matters?ExampleCount
CombinationNoPicking 3 from {A, B, C, D}C(4,3) = 4
PermutationYesArranging 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

OperationTimeSpace
Generate all C(n,k) combinationsO(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.

InputAs a combinationAs 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

ApproachTimeSpace
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 permutationO(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 n pairs of parentheses, write a function to generate all combinations of well-formed parentheses.

Example 1: Input: n = 3 Output: ["((()))","(()())","(())()","()(())","()()()"]

Example 2: Input: n = 1 Output: ["()"]

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

  1. Generate all 2^(2n) strings of length 2n using ( and ).
  2. For each string, check if it is a valid parentheses sequence.
  3. 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

  1. Start with an empty string, open = 0, close = 0.
  2. If len(current) == 2 * n, add current to results and return.
  3. If open < n, recurse with ( appended and open + 1.
  4. If close < open, recurse with ) appended and close + 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 most 2n

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 0 if the array is empty.

Given an array nums, return the sum of all XOR totals for every subset of nums.

Note: Subsets with the same elements should be counted multiple times.

Example 1: Input: nums = [1, 3] Output: 6 Explanation: Subsets: {} → 0, {1} → 1, {3} → 3, {1,3} → 2. Sum = 0+1+3+2 = 6.

Example 2: Input: nums = [5, 1, 6] Output: 28

Constraints:

  • 1 <= nums.length <= 12
  • 1 <= nums[i] <= 20

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Bit manipulation — XOR behaves differently from addition; know that a ^ a = 0 and a ^ 0 = a
  • Subsets / power set — understanding that an array of length n has 2^n subsets
  • 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

  1. Define a recursive function dfs(index, current_xor).
  2. At each index, add current_xor to the total (this accounts for the current subset).
  3. Recurse by including nums[index] (XOR it in) and by excluding it (XOR unchanged).
  4. 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

  1. Compute or_total = OR of all elements in nums.
  2. 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 nums of 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 nums are 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

  1. Initialize result = [[]].
  2. For each num in nums:
    • For each existing subset s in result, create s + [num].
    • Extend result with all the new subsets.
  3. 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 the 2^n subsets, 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

  1. Define backtrack(start, current).
  2. Append a copy of current to result (every state is a valid subset).
  3. For each index i from start to len(nums) - 1:
    • Append nums[i] to current.
    • Recurse with start = i + 1.
    • Pop nums[i] from current (backtrack).
  4. 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^n subsets, each costs O(n) to copy
  • Space: O(n) — recursion depth is at most n (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 candidates and a target integer target, return a list of all unique combinations of candidates where the chosen numbers sum to target. You may return the combinations in any order.

The same number may be chosen from candidates an 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 = 7 Output: [[2, 2, 3], [7]]

Example 2: Input: candidates = [2, 3], target = 4 Output: [[2, 2], [3], [2, 3]] — wait, [3] sums to 3 not 4. Correct: [[2, 2]]… Actually: [[2, 2], [2, 2]] — no. 2+2=4 and 3 alone is 3 not 4. Output: [[2, 2]]

Example 3: Input: candidates = [2, 3, 5], target = 8 Output: [[2, 2, 2, 2], [2, 3, 3], [3, 5]]

Constraints:

  • 1 <= candidates.length <= 30
  • 2 <= candidates[i] <= 40
  • All elements of candidates are 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

  1. Compute max_len = target // min(candidates).
  2. Generate all combinations with repetition up to length max_len.
  3. Filter those that sum exactly to target.
  4. 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

  1. Define backtrack(start, current, remaining).
  2. If remaining == 0: append a copy of current to results and return.
  3. If remaining < 0: return (prune).
  4. For each index i from start to len(candidates) - 1:
    • Append candidates[i] to current.
    • Recurse with start = i (same element allowed again) and remaining - candidates[i].
    • Pop candidates[i] from current.

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 in candidates where the candidate numbers sum to target.

Each number in candidates may 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 = 8 Output: [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]

Example 2: Input: candidates = [2, 5, 2, 1, 2], target = 5 Output: [[1, 2, 2], [5]]

Constraints:

  • 1 <= candidates.length <= 100
  • 1 <= candidates[i] <= 50
  • 1 <= 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

  1. Sort candidates.
  2. Use backtracking to enumerate all subsets.
  3. If a subset sums to target, add its tuple to a result set.
  4. 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

  1. Sort candidates.
  2. Define backtrack(start, current, remaining).
  3. If remaining == 0: record current and return.
  4. For i from start to end:
    • Skip if i > start and candidates[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 with i + 1, pop.

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 n and k, return all possible combinations of k numbers chosen from the range [1, n].

You may return the answer in any order.

Example 1: Input: n = 4, k = 2 Output: [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]

Example 2: Input: n = 1, k = 1 Output: [[1]]

Constraints:

  • 1 <= n <= 20
  • 1 <= 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

  1. Create a range [1, 2, ..., n].
  2. Use itertools.combinations(range(1, n+1), k) to get all combinations.
  3. 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

  1. Define backtrack(start, current).
  2. If len(current) == k: append a copy to results and return.
  3. For i from start to n:
    • Pruning: if n - i + 1 < k - len(current), there aren’t enough numbers left — break.
    • Append i, recurse with start = i + 1, pop i.

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 nums of 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 nums are 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

  1. Initialize used = [False] * len(nums) and current = [].
  2. Define backtrack().
  3. If len(current) == len(nums): append a copy to results and return.
  4. For each index i in nums:
    • If used[i]: skip.
    • Mark used[i] = True, append nums[i].
    • Recurse.
    • Mark used[i] = False, pop nums[i].

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 the used array

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

  1. Define backtrack(start).
  2. If start == len(nums): append a copy of nums to results and return.
  3. For i from start to len(nums) - 1:
    • Swap nums[start] and nums[i].
    • Recurse with start + 1.
    • Swap nums[start] and nums[i] back.

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 extra used array 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 nums that 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

  1. Sort nums.
  2. Backtrack to generate all subsets.
  3. Store each subset as a tuple in a set.
  4. 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

  1. Sort nums.
  2. Define backtrack(start, current).
  3. Append a copy of current to results (every state is a valid subset).
  4. For i from start to end:
    • If i > start and nums[i] == nums[i-1]: skip (duplicate at same level).
    • Append nums[i], recurse with i + 1, pop.

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 most 2^n subsets, 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 nums that 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 used array
  • 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

  1. Sort nums (optional for brute force but good habit).
  2. Backtrack with a used array; at base case, convert to tuple and add to a set.
  3. 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

  1. Sort nums.
  2. Initialize used = [False] * len(nums).
  3. Define backtrack(current).
  4. If len(current) == len(nums): append a copy and return.
  5. 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.

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 plus used array

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 n grid of characters board and a string word, return true if word exists 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: true

Example 2: Input: board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "SEE" Output: true

Example 3: Input: board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCB" Output: false

Constraints:

  • m == board.length, n == board[i].length
  • 1 <= m, n <= 6
  • 1 <= word.length <= 15
  • board and word consist 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

  1. For each cell (r, c) in the grid, call dfs(r, c, 0) where 0 is the current index in word.
  2. In dfs(r, c, index):
    • If index == len(word): return True (all characters matched).
    • If out of bounds, already visited, or board[r][c] != word[index]: return False.
    • 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.

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 the m*n starting cells, DFS explores up to 4 directions at each of L steps
  • Space: O(L) — recursion depth equals word length L

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, partition s such 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 <= 16
  • s consists 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

  1. Define backtrack(start, current).
  2. If start == len(s): append a copy of current to results and return.
  3. For end from start + 1 to len(s):
    • Extract substr = s[start:end].
    • If substr is a palindrome:
      • Append substr to current.
      • Recurse with start = end.
      • Pop substr.

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

  1. Precompute dp[i][j] = True if s[i:j+1] is a palindrome.
  2. Run the same backtracking, but use dp[start][end-1] instead of calling is_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 — overall O(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-9 inclusive, 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 1 does not map to any letters.

2 → abc    3 → def    4 → ghi    5 → jkl
6 → mno    7 → pqrs   8 → tuv    9 → wxyz

Example 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 <= 4
  • digits[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

  1. Initialize result = [""].
  2. For each digit in digits:
    • Get its letters from the phone map.
    • Replace result with every existing string in result extended by each letter.
  3. Return result (or [] if digits is 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

  1. Return [] if digits is empty.
  2. Define the phone keypad mapping.
  3. Define backtrack(index, current).
  4. If index == len(digits): append "".join(current) to results and return.
  5. For each letter in phone_map[digits[index]]:
    • Append letter, recurse with index + 1, pop letter.

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; n digits; 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 matchsticks where matchsticks[i] is the length of the ith 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 true if you can make this square and false otherwise.

Example 1: Input: matchsticks = [1, 1, 2, 2, 2] Output: true Explanation: 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: false Explanation: 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 <= 15
  • 1 <= 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

  1. If sum(matchsticks) % 4 != 0: return False.
  2. Set target = sum(matchsticks) // 4.
  3. Initialize sides = [0, 0, 0, 0].
  4. Define backtrack(index):
    • If index == len(matchsticks): return all sides equal target.
    • For each side in sides:
      • If sides[side] + matchsticks[index] <= target:
        • Add matchstick to side, recurse, remove.
        • If recursion returned True, return True.
  5. 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:

  1. 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.

  2. 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

  1. Early exit: sum % 4 != 0 or any stick > target.
  2. Sort matchsticks in descending order.
  3. 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 nums and an integer k, return true if it is possible to divide this array into k non-empty subsets whose sums are all equal.

Example 1: Input: nums = [4, 3, 2, 3, 5, 2, 1], k = 4 Output: true Explanation: 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 = 3 Output: false

Constraints:

  • 1 <= k <= len(nums) <= 16
  • 0 < nums[i] < 10000
  • Every element of nums will 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

  1. If sum(nums) % k != 0: return False.
  2. Set target = sum(nums) // k.
  3. Sort nums descending; if nums[0] > target: return False.
  4. Define backtrack(index, buckets).
  5. If all buckets are target: return True.
  6. Try placing nums[index] into each bucket (skip if overflow).
  7. 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

  1. Compute target.
  2. Define backtrack(mask, current_sum):
    • current_sum is the running sum of the current (incomplete) bucket.
    • If mask == (1 << n) - 1: all elements placed, return True.
    • For each i not in mask:
      • If current_sum + nums[i] <= target:
        • If current_sum + nums[i] == target: recurse with mask | (1 << i) and current_sum = 0 (bucket complete).
        • Else: recurse with mask | (1 << i) and current_sum + nums[i].
  3. 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 most 2^n unique masks, each exploring up to n elements
  • 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 n queens on an n x n chessboard 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 = 4 Output: [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]

Example 2: Input: n = 1 Output: [["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

  1. Track cols, diag (row-col), anti_diag (row+col) as sets.
  2. Define backtrack(row, board).
  3. If row == n: record the board and return.
  4. For each col in 0..n-1:
    • If col, row-col, or row+col is in any conflict set: skip.
    • Place 'Q', add to conflict sets, recurse with row+1.
    • Remove queen, remove from conflict sets.

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

  1. Use queens = [-1] * n to track the column placement per row.
  2. Use sets for cols, diag, anti_diag.
  3. 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 n queens on an n x n chessboard 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 = 4 Output: 2

Example 2: Input: n = 1 Output: 1

Constraints:

  • 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

  1. Run solveNQueens(n) from N-Queens I.
  2. 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

  1. Initialize count = 0.
  2. Define backtrack(row):
    • If row == n: increment count and return.
    • For each col in 0..n-1:
      • Skip if conflict.
      • Add to sets, recurse with row + 1, remove from sets.
  3. Call backtrack(0) and return count.

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 s and a dictionary of strings wordDict, add spaces in s to 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 <= 20
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 10
  • s and wordDict[i] consist of only lowercase English letters.
  • All the strings of wordDict are 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

  1. Convert wordDict to a set for O(1) lookup.
  2. Define backtrack(start, current_words).
  3. If start == len(s): append " ".join(current_words) to results and return.
  4. For each word in wordDict:
    • If s[start:start+len(word)] == word:
      • Append word, recurse with start + len(word), pop.

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

  1. Define backtrack(start) that returns all sentence fragments for s[start:].
  2. Base case: start == len(s) → return [""].
  3. If start in memo: return memo[start].
  4. For each word that matches s[start:start+len(word)]:
    • Recurse on start + len(word) to get suffixes.
    • Prepend word to each suffix (with a space separator).
  5. 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

OperationHeapSorted ArrayUnsorted Array
Find min/maxO(1)O(1)O(n)
InsertO(log n)O(n)O(1)
Remove min/maxO(log n)O(n)O(n)
Build from n elementsO(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:

RelationshipFormula
Parent(i - 1) // 2
Left child2 * i + 1
Right child2 * i + 2

Let’s verify with the tree above. Node 3 is at index 1:

  • Its parent is at (1 - 1) // 2 = 0 → value 1. Correct.
  • Its left child is at 2*1 + 1 = 3 → value 7. Correct.
  • Its right child is at 2*1 + 2 = 4 → value 9. 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

OperationTimeWhy
pushO(log n)Sift up travels at most tree height
popO(log n)Sift down travels at most tree height
peekO(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.

MethodTimeWhen to use
Push n timesO(n log n)Elements arrive one at a time
heapify onceO(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:

  1. Every element in the max-heap is ≤ every element in the min-heap.
  2. 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:

  1. 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.
  2. If len(max_heap) > len(min_heap) + 1: move max-heap’s top to min-heap.
  3. 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

OperationTimeSpace
add_numO(log n)
find_medianO(1)
Total spaceO(n)

Compared to alternatives:

ApproachInsertMedian
Sorted listO(n)O(1)
Re-sort each timeO(n log n)O(1)
Two heapsO(log n)O(1)
Order-statistics treeO(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 the kth largest element in sorted order, not the kth distinct element.

Implement the KthLargest class:

  • KthLargest(int k, int[] nums) — initializes the object with the integer k and the stream of integers nums.
  • int add(int val) — appends the integer val to the stream and returns the element representing the kth largest element in the stream.

Example 1: Input: k = 3, nums = [4, 5, 8, 2], then add(3), add(5), add(10), add(9), add(4) Output: [4, 5, 5, 8, 8]

Constraints:

  • 1 <= k <= 10^4
  • 0 <= nums.length <= 10^4
  • -10^4 <= nums[i] <= 10^4
  • -10^4 <= val <= 10^4
  • At most 10^4 calls will be made to add
  • It is guaranteed that there will be at least k elements in the array when add is 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 heapq moduleheappush, 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

  1. In __init__, store k and concatenate val into a sorted list.
  2. In add(val), append val, sort the list, then return sorted_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

  1. In __init__, heapify the input and pop elements until the heap has exactly k items.
  2. In add(val), push val onto the heap. If the heap size exceeds k, pop the minimum. Return heap[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 stones where stones[i] is the weight of the ith 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 x and y where x <= y. The result of this smash is:

  • If x == y, both stones are destroyed.
  • If x != y, the stone of weight x is destroyed and the stone of weight y has new weight y - 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: 1

Example 2: Input: stones = [1] Output: 1

Constraints:

  • 1 <= stones.length <= 30
  • 1 <= stones[i] <= 1000

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Heaps / Priority Queues — extracting the maximum element efficiently
  • Python’s heapq module — 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

  1. While len(stones) > 1: a. Sort stones in descending order. b. Let y = stones[0], x = stones[1]. Remove both. c. If y != x, append y - x back to stones.
  2. Return stones[0] if the list is non-empty, else 0.

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

  1. Negate all stone weights and build a heap with heapq.heapify.
  2. While more than one stone remains: a. Pop the two largest (most negative values), negate back to get y >= x. b. If y != x, push -(y - x) back onto the heap.
  3. Return -heap[0] if the heap is non-empty, else 0.

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 points where points[i] = [xi, yi] represents a point on the X-Y plane, and an integer k, return the k closest 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 = 1 Output: [[-2, 2]]

Example 2: Input: points = [[3, 3], [5, -1], [-2, 4]], k = 2 Output: [[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 sqrt when comparing distances since sqrt is monotonic
  • Heaps / Priority Queues — maintaining a bounded collection of k elements efficiently
  • Python’s heapq moduleheappush, heappop, and nsmallest

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

  1. For each point, compute x² + y² (no need for sqrt since we’re just comparing).
  2. Sort the points by this distance.
  3. Return the first k points.

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

  1. Initialize an empty heap.
  2. For each point [x, y]: a. Push (-dist, x, y) onto the heap where dist = x² + y². b. If len(heap) > k, pop the root (farthest point).
  3. 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 nums and an integer k, return the kth largest element in the array.

Note that it is the kth largest element in sorted order, not the kth distinct element.

Can you solve it without sorting?

Example 1: Input: nums = [3, 2, 1, 5, 6, 4], k = 2 Output: 5

Example 2: Input: nums = [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4 Output: 4

Constraints:

  • 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 heapq moduleheappush, 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

  1. Sort nums in descending order.
  2. 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

  1. Initialize an empty heap.
  2. For each num in nums: a. Push num. b. If len(heap) > k, pop the minimum.
  3. 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

  1. Pick a pivot (we’ll use the last element for simplicity).
  2. Partition: move all elements greater than the pivot to the left side. Track the boundary index p.
  3. If p == k - 1, return nums[p].
  4. If p < k - 1, recurse on the right partition with the same k.
  5. If p > k - 1, recurse on the left partition with the same k.

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 get O(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 least n intervals 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 = 2 Output: 8 Explanation: A possible sequence is: A -> B -> idle -> A -> B -> idle -> A -> B

Example 2: Input: tasks = ["A","A","A","B","B","B"], n = 0 Output: 6

Constraints:

  • 1 <= tasks.length <= 10^4
  • tasks[i] is an uppercase English letter
  • 0 <= 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

  1. Count the frequency of each task.
  2. Find max_count = highest frequency.
  3. Count how many tasks share that max frequency (num_max).
  4. 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

  1. Count frequencies, build a max-heap (negate counts).
  2. Use a deque cooldown storing (count, available_at_time).
  3. Simulate time t = 0, 1, 2, ...: a. If cooldown has a task ready at time t, 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. Increment t.
  4. 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 by O(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 class:

  • Twitter() — initializes your Twitter object.
  • postTweet(userId, tweetId) — composes a new tweet with ID tweetId by the user userId.
  • 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 ID followerId starts following the user with ID followeeId.
  • unfollow(followerId, followeeId) — the user with ID followerId stops following the user with ID followeeId.

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 <= 500
  • 0 <= tweetId <= 10^4
  • All tweets have unique IDs
  • At most 3 * 10^4 calls will be made to postTweet, getNewsFeed, follow, and unfollow

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

  1. Store tweets as (timestamp, tweetId) per user in a list.
  2. Store followees per user in a set (always include self).
  3. 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) per getNewsFeed where 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

  1. Same data storage as brute force.
  2. 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) per getNewsFeed where f = number of followees (heap seed), then O(10 log f) for extraction — effectively O(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 n tasks labeled from 0 to n - 1 represented by a 2D integer array tasks, where tasks[i] = [enqueueTime_i, processingTime_i] means that the ith task will be available to process at enqueueTime_i and will take processingTime_i units 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 == n
  • 1 <= n <= 10^5
  • 1 <= 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

  1. Tag each task with its original index: (enqueueTime, processingTime, index).
  2. Sort by enqueue time.
  3. Maintain time (current CPU time) and a list of completed task indices.
  4. 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

  1. Create sorted_tasks = sorted(enumerate(tasks), key=lambda x: x[1][0]) — sorted by enqueue time, preserving original index.
  2. Initialize time = 0, heap = [], i = 0, result = [].
  3. While result has fewer than n items: a. Push all tasks with enqueueTime <= time onto the heap as (processingTime, originalIndex). b. If heap is empty, jump time to the next task’s enqueue time and continue. c. Pop (pt, orig_idx) from the heap, append orig_idx to result, advance time += pt.
  4. 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 of s so 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 <= 500
  • s consists 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 Counter from the collections module

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

  1. Count character frequencies.
  2. If any count exceeds (len(s) + 1) // 2, return "".
  3. Sort characters by frequency descending.
  4. Place them at even indices 0, 2, 4, ... then odd indices 1, 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

  1. Count frequencies. If any > (n + 1) // 2, return "".
  2. Build a max-heap (-count, char).
  3. While the heap is non-empty: a. Pop (neg_count1, char1) — the most frequent character. b. Append char1 to result. c. If the heap is non-empty, pop (neg_count2, char2) — second most frequent. d. Append char2 to result. e. Push back any characters that still have remaining count.
  4. 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 s is called happy if it satisfies the following conditions:

  • s only contains the letters 'a', 'b', and 'c'.
  • s does not contain "aaa", "bbb", or "ccc" as a substring.
  • s contains at most a occurrences of the letter 'a'.
  • s contains at most b occurrences of the letter 'b'.
  • s contains at most c occurrences of the letter 'c'.

Given three integers a, b, and c, 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 = 7 Output: "ccaccbcc" (or any valid happy string of the same length)

Example 2: Input: a = 7, b = 1, c = 0 Output: "aabaa" (any valid output with at most 2 consecutive a’s)

Constraints:

  • 0 <= a, b, c <= 100
  • a + 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

  1. Start with counts = [(-a, 'a'), (-b, 'b'), (-c, 'c')].
  2. 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.
  3. 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

  1. Build max-heap (-count, char) for a, b, c (skip zeros).
  2. While the heap is non-empty: a. Pop (neg_cnt1, ch1) — the most frequent. b. If the result ends with ch1 twice:
    • If heap is empty, break (can’t place anything else).
    • Pop (neg_cnt2, ch2) — second most frequent.
    • Append ch2, push ch2 back (if remaining), push ch1 back. c. Otherwise, append ch1, push ch1 back (if remaining).
  3. 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 capacity empty seats. The vehicle only drives east (i.e., it cannot turn around and drive west).

You are given the integer capacity and an array trips where trips[i] = [numPassengers_i, from_i, to_i] indicates that the ith trip has numPassengers_i passengers and the passengers must be picked up from from_i and dropped off at to_i.

Return true if it is possible to pick up and drop off all passengers for all the given trips, or false otherwise.

Example 1: Input: trips = [[2,1,5],[3,3,7]], capacity = 4 Output: false

Example 2: Input: trips = [[2,1,5],[3,3,7]], capacity = 5 Output: true

Constraints:

  • 1 <= trips.length <= 1000
  • trips[i].length == 3
  • 1 <= numPassengers_i <= 100
  • 0 <= from_i < to_i <= 1000
  • 1 <= 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

  1. Create a difference array diff[0..1001] initialized to 0.
  2. For each trip [num, frm, to]: diff[frm] += num, diff[to] -= num.
  3. Compute the running sum (prefix sum) of diff. If any prefix sum > capacity, return false.
  4. 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

  1. Sort trips by from location.
  2. Initialize heap (min by to location) and current_passengers = 0.
  3. For each trip [num, frm, to]: a. Drop off all passengers with to <= frm (pop from heap while condition holds, subtract from current). b. Add num to current passengers and push (to, num) onto the heap. c. If current_passengers > capacity, return false.
  4. 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 MedianFinder class:

  • MedianFinder() — initializes the MedianFinder object.
  • void addNum(int num) — adds the integer num from the data stream to the data structure.
  • double findMedian() — returns the median of all elements so far. Answers within 10^-5 of the actual answer will be accepted.

Example 1: Input: addNum(1), addNum(2), findMedian()1.5, addNum(3), findMedian()2.0

Constraints:

  • -10^5 <= num <= 10^5
  • There will be at least one element before findMedian is called
  • At most 5 * 10^4 calls will be made to addNum and findMedian

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 heapq module — 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

  1. Store elements in a sorted list nums.
  2. addNum(num): use bisect.insort to insert in sorted position.
  3. 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) per addNum (insertion shift), O(1) per findMedian
  • 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] and large[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

  1. addNum(num): a. Push -num to small (max-heap via negation). b. If small has elements and large has elements and -small[0] > large[0]: move small’s max to large. c. If len(large) > len(small): move large’s min to small.
  2. findMedian():
    • If equal sizes: (-small[0] + large[0]) / 2.0
    • If small is larger: -small[0]

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) per addNum, O(1) per findMedian
  • 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 k distinct projects before the IPO. Help LeetCode design the best way to maximize its total capital after finishing at most k distinct projects.

You are given n projects where the ith project has a pure profit profits[i] and a minimum capital of capital[i] required to start it.

Initially, you have w capital. When you finish a project, you will obtain its pure profit and the profit will be added to your total capital. Pick at most k distinct 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: 4

Example 2: Input: k = 3, w = 0, profits = [1, 2, 3], capital = [0, 1, 2] Output: 6

Constraints:

  • 1 <= k <= 10^5
  • 0 <= w <= 10^9
  • n == profits.length == capital.length
  • 1 <= n <= 10^5
  • 0 <= profits[i] <= 10^4
  • 0 <= 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 heapq with 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

  1. Repeat at most k times: a. Find all projects where capital[i] <= w. b. If none exist, break. c. Pick the project with the maximum profit. Add its profit to w. Mark it as done.
  2. 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

  1. Build locked = [(capital[i], profits[i]) for i in range(n)] as a min-heap.
  2. available is a max-heap (negate profits) of projects we can currently afford.
  3. Repeat at most k times: a. While locked is non-empty and locked[0][0] <= w, pop it and push -profit onto available. b. If available is empty, break (can’t afford anything). c. Pop the most profitable from available, add its profit to w.
  4. 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:

  1. Compute hash(key) → get an index.
  2. 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

OperationAverageWorst case
putO(1)O(n)
getO(1)O(n)
removeO(1)O(n)
resizeO(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

PropertyUndirectedDirected
Edge directionBoth waysOne way
Real exampleFriendships, roadsTwitter follows, hyperlinks
NotationA — BA → B

What’s Coming in This Section

TopicWhat You’ll Learn
Intro to GraphsAdjacency matrix and adjacency list representations
Matrix DFSDepth-first search on a 2D grid — flood fill and island counting
Matrix BFSBreadth-first search on a 2D grid — shortest path guaranteed
Adjacency ListDFS 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:

TermDefinitionExample
Vertex (node)A single entity in the graphA person, city, or webpage
EdgeA connection between two verticesA friendship, road, or hyperlink
PathA sequence of vertices connected by edgesLondon → Paris → Rome
CycleA path that starts and ends at the same vertexA → B → C → A
ConnectedEvery vertex can reach every other vertexA fully connected road network
WeightedEdges 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 MatrixAdjacency List
SpaceO(V²) — always allocates V×VO(V + E) — only stores real edges
Check if edge existsO(1) — instant lookupO(degree) — scan neighbour list
List all neighboursO(V) — scan entire rowO(degree) — direct access
Best forDense 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:

  1. If the cell is out of bounds — stop.
  2. If the cell is water (0) — stop.
  3. If we’ve already visited this cell — stop.
  4. 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
TimeO(rows × cols) — each cell visited at most once
SpaceO(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
TimeO(rows × cols) — each cell visited at most once
SpaceO(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 structureStack (LIFO)Queue (FIFO)
Traversal styleGoes deep firstGoes wide first
Finds shortest path?NoYes (unweighted)
Use forCycle detection, connected components, topological sortShortest 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 log is 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

  1. Set dist[source] = 0, dist[all others] = infinity.
  2. Push (0, source) into a min-heap (priority queue).
  3. Pop the node with the smallest distance.
  4. For each neighbour, if dist[current] + edge_weight < dist[neighbour], update dist[neighbour] and push to the heap.
  5. Repeat until the heap is empty.

Step-by-Step Trace

Starting from A:

StepProcessABCDE
Init0
1Pop A (0)042
2Pop C (2)04→321012
3Pop B (3)03269
4Pop D (6)03269→8
5Pop E (8)03268

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
SpaceO(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

DomainApplication
GPS NavigationGoogle Maps, Apple Maps, Waze — find fastest/shortest route
Network RoutingOSPF protocol routes internet packets through the cheapest path
Game PathfindingA* algorithm is Dijkstra’s with a heuristic added for speed
Airline OptimisationMinimum-cost flight connections between hubs
Social NetworksDegrees of separation (with uniform weights = BFS, weighted = Dijkstra)
Robot NavigationAutonomous 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 prev pointer 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:

  1. Start with any vertex in the tree (the “visited” set).
  2. Look at all edges that cross from the visited set to the unvisited set.
  3. Pick the cheapest such edge.
  4. Add the new vertex to the visited set.
  5. 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
StepVisitedCandidate EdgesChosen
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
SpaceO(V + E)

Prim’s vs Kruskal’s

Both algorithms find the MST, but they approach it differently:

Prim’sKruskal’s
StrategyGrow one connected tree from a start nodeSort all edges, add non-cycle edges greedily
Data structureMin-heap + visited setUnion-Find
Best forDense graphs (many edges)Sparse graphs (few edges)
Starting pointRequires a start vertexWorks globally on all edges
ConnectivityWorks only on connected graphsNaturally handles disconnected graphs (gives a forest)
ImplementationSimilar to Dijkstra’sRequires 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

DomainApplication
TelecommunicationsMinimum cable to connect all buildings/cities in a network
Electrical GridMinimum wire to connect all substations
Water DistributionMinimum pipe to connect all households
Road NetworksMinimum road construction to connect all towns
Machine LearningSingle-linkage clustering uses MST structure
Image SegmentationMST-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:

EdgeWeightAdd?
B-C1Yes — connects B and C
A-C2Yes — connects A to {B,C}
B-D3Yes — connects D to {A,B,C}
A-B4Skip — A and B already connected
C-D5Skip — C and D already connected
C-E6Yes — connects E to {A,B,C,D}
D-E7Skip — D and E already connected
B-E8Skip — 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 component
  • union(x, y) — merges the components of x and y; returns False if 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 edgesO(E log E)
Union-Find operationsO(E · α(V)) ≈ O(E)
Total timeO(E log E)
SpaceO(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

CriterionKruskal’sPrim’s
ApproachGlobal: sort all edges, pick greedilyLocal: grow one tree from a start vertex
Key data structureUnion-FindMin-heap
Time complexityO(E log E)O(E log V)
Best forSparse graphs (E << V²)Dense graphs (E ≈ V²)
Pre-sorted edgesExcellent — sorting is already doneNo advantage
Disconnected graphsNaturally produces a spanning forestOnly spans one component
ImplementationSlightly simpler to reason aboutSimilar to Dijkstra’s — reusable pattern
ParallelismHarder to paralleliseHarder 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

DomainApplication
Network DesignMinimum cable/fibre for connecting buildings or cities
Electrical GridMinimum wire for power distribution
Water PipesMinimum pipe for municipal water networks
Pre-sorted DataWhen edge list comes pre-sorted (e.g., from a distance database), Kruskal’s is immediately efficient
Cluster AnalysisSingle-linkage hierarchical clustering builds an MST
Image SegmentationKruskal-based algorithms group similar pixels
Approximation AlgorithmsMST 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

  1. Compute the in-degree of every vertex.
  2. Enqueue all vertices with in-degree 0 (no prerequisites).
  3. 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.
  4. If the result contains all vertices — success. If not — the graph has a cycle.

Step-by-Step Trace

Using the course graph above:

StepQueueProcessedIn-degrees updated
Init[Maths101, Programming]All computed
1[Programming]Maths101DataStructures: 2→1
2[]ProgrammingDataStructures: 1→0, Algorithms: 2→1 → enqueue DS
3[DataStructures]
4[Algorithms, Databases]DataStructuresAlgorithms: 1→0, Databases: 1→0
5[Databases]AlgorithmsMachineLearning: 2→1
6[]DatabasesMachineLearning: 1→0 → enqueue ML
7[MachineLearning]
8DoneMachineLearning

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

  1. For each unvisited vertex, run a DFS.
  2. When a vertex finishes (all its descendants have been visited), push it onto a stack.
  3. 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
MechanismIn-degree counting + queuePost-order DFS + stack
Cycle detectionYes — check len(result) != len(graph)Yes — check for GRAY neighbours
ImplementationIterative (no recursion depth issues)Recursive (may hit stack limits on large graphs)
Which cycleTells you nodes involved (unprocessed nodes)Detects existence on encounter
Order of outputStable with consistent queue orderingDepends 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
TimeO(V + E)
SpaceO(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

DomainApplication
Build systemsMake, Bazel, Gradle — compile files in dependency order
Package managersnpm, pip, apt — install packages before those that need them
Course schedulingUniversity prerequisite planning
SpreadsheetsEvaluate formulas in dependency order (Excel, Google Sheets)
Data pipelinesApache Airflow, dbt — run transforms in correct order
GitCommit history forms a DAG; topological sort underlies git log --topo-order
Compiler internalsResolve 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 col grid grid representing a map where grid[i][j] = 1 represents land and grid[i][j] = 0 represents 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: 16

Constraints:

  • row == grid.length, col == grid[i].length
  • 1 <= row, col <= 100
  • grid[i][j] is 0 or 1
  • 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

  1. Initialize perimeter = 0.
  2. For each cell (r, c) where grid[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.
  3. 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

  1. Count cells = number of 1s in the grid.
  2. Count neighbors = number of pairs where a 1 is directly to the right or directly below another 1.
  3. 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 words written in the alien language, and the order of the alphabet, return true if and only if the given words are sorted lexicographically in this alien language.

Example 1: Input: words = ["hello","leetcode"], order = "hlabcdefgijkmnopqrstuvwxyz" Output: true

Example 2: Input: words = ["word","world","row"], order = "worldabcefghijkmnpqstuvxyz" Output: false

Example 3: Input: words = ["apple","app"], order = "abcdefghijklmnopqrstuvwxyz" Output: false

Constraints:

  • 1 <= words.length <= 100
  • 1 <= words[i].length <= 20
  • order.length == 26
  • All characters in words[i] and order are 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

  1. Build a rank map: rank[order[i]] = i for each index i.
  2. 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, return False.
    • If one is a prefix of the other: words[i] must not be longer than words[i+1].
  3. Return True if 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

  1. Build rank map as before.
  2. Define alien_key(word) = tuple of rank[ch] for each char in word.
  3. 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 n people labeled from 1 to n. There is a rumor that one of these people is secretly the town judge.

If the town judge exists, then:

  1. The town judge trusts nobody.
  2. Everybody else (except possibly the judge) trusts the town judge.
  3. There is exactly one person who satisfies properties 1 and 2.

You are given an array trust where trust[i] = [a, b] means person a trusts person b. Return the label of the town judge if the town judge exists and can be identified, or return -1 otherwise.

Example 1: Input: trust = [[1,3],[2,3]], n = 3 Output: 3

Example 2: Input: trust = [[1,3],[2,3],[3,1]], n = 3 Output: -1

Example 3: Input: trust = [[1,2]], n = 2 Output: 2

Constraints:

  • 1 <= n <= 1000
  • 0 <= trust.length <= 10^4
  • trust[i].length == 2
  • All the pairs in trust are unique
  • a != 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

  1. Build trusts_set[person] = set of people they trust.
  2. Build trusted_by_count[person] = how many people trust them.
  3. For each person p: if len(trusts_set[p]) == 0 and trusted_by_count[p] == n - 1, return p.
  4. 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

  1. Initialize score[1..n] = 0.
  2. For each [a, b] in trust: score[a] -= 1 (trusts someone), score[b] += 1 (trusted by someone).
  3. Return the person with score[p] == n - 1, or -1 if 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 n 2D binary grid grid which 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: 3

Constraints:

  • m == grid.length, n == grid[i].length
  • 1 <= m, n <= 300
  • grid[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

  1. Initialize count = 0.
  2. For each cell (r, c): if grid[r][c] == '1', increment count and call dfs(r, c).
  3. dfs(r, c): mark grid[r][c] = '0', then recursively call dfs on each valid land neighbor.
  4. 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

  1. For each unvisited land cell (r, c), increment count and enqueue (r, c).
  2. While the queue is non-empty: dequeue (r, c), mark it visited, enqueue all valid land neighbors.
  3. 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

  1. Initialize Union-Find with each land cell as its own component.
  2. For each land cell, union with right and bottom neighbors if they are land.
  3. 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 n binary matrix grid. An island is a group of 1s (representing land) connected 4-directionally (horizontal or vertical). You may assume all four edges of the grid are surrounded by 0s (water).

The area of an island is the number of cells with value 1 in the island.

Return the maximum area of an island in grid. If there is no island, return 0.

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: 6

Constraints:

  • m == grid.length, n == grid[i].length
  • 1 <= m, n <= 50
  • grid[i][j] is either 0 or 1

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

  1. Initialize max_area = 0.
  2. For each unvisited land cell (r, c): run dfs(r, c) which returns the island’s area; update max_area.
  3. dfs(r, c): if out of bounds or water or already visited, return 0. Otherwise mark visited and return 1 + dfs(neighbors).
  4. 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

  1. For each unvisited land cell, start BFS, mark it visited immediately.
  2. Count cells dequeued per BFS run; update max_area.
  3. 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 graph

Example 2: Input: adjList = [[]] Output: A single node with no neighbors

Constraints:

  • The number of nodes is in range [0, 100]
  • 1 <= Node.val <= 100
  • Node.val is 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

  1. If node is None, return None.
  2. Create a hash map visited = {}.
  3. Define clone(node):
    • If node is already in visited, return visited[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.
  4. 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

  1. Create a clone of the starting node and put it in visited.
  2. Enqueue the starting node.
  3. While the queue is non-empty: dequeue node, for each neighbor:
    • If not in visited, create a clone and enqueue the original.
    • Add visited[neighbor] to visited[node].neighbors.
  4. 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 n 2D grid initialized with these three possible values:

  • -1 — a wall or an obstacle
  • 0 — a gate
  • 2147483647 (INF) — an empty room

Fill 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].length
  • 1 <= m, n <= 250
  • grid[i][j] is one of -1, 0, or 2^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

  1. For each empty room (r, c), run BFS outward until you find a gate.
  2. Record the distance to the nearest gate.
  3. 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

  1. Collect all gate positions and enqueue them all at once.
  2. 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 + 1 and enqueue it.
    • Walls (-1) and already-visited cells are skipped.
  3. In-place modification — update rooms directly.

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 n grid where each cell can have one of three values:

  • 0 — empty cell
  • 1 — fresh orange
  • 2 — rotten orange

Every 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: 4

Example 2: Input: grid = [[2,1,1],[0,1,1],[1,0,1]] Output: -1

Example 3: Input: grid = [[0,2]] Output: 0

Constraints:

  • m == grid.length, n == grid[i].length
  • 1 <= m, n <= 10
  • grid[i][j] is 0, 1, or 2

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

  1. Count initial fresh oranges.
  2. Repeat: scan the grid, collect newly rotten oranges, rot them, decrement fresh, increment minutes.
  3. Stop when fresh == 0 (return minutes) 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) for to_rot list

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

  1. Count fresh oranges. Enqueue all initially rotten oranges.
  2. 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.
  3. Return minutes if fresh == 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 n rectangular 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 n integer matrix heights where heights[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].length
  • 1 <= m, n <= 200
  • 0 <= 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

  1. For each cell (r, c), run DFS flowing downhill (next cell height ≤ current height).
  2. Check if Pacific border or Atlantic border is reachable.
  3. 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

  1. Initialize Pacific reachable set with all top-row and left-column cells.
  2. Initialize Atlantic reachable set with all bottom-row and right-column cells.
  3. Run BFS (or DFS) from each set, expanding to neighbors with height ≥ current.
  4. 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 n matrix board containing '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].length
  • 1 <= m, n <= 200
  • board[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

  1. For each 'O' cell: run BFS to see if the border is reachable via connected O’s.
  2. 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

  1. For every 'O' on the border, run DFS marking connected O’s as 'S' (safe).
  2. 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 deadends and a target string. Return the minimum total number of turns required to open the lock, or return -1 if it is impossible.

Example 1: Input: deadends = ["0201","0101","0102","1212","2002"], target = "0202" Output: 6

Example 2: Input: deadends = ["8888"], target = "0009" Output: 1

Example 3: Input: deadends = ["0000"], target = "8888" Output: -1

Constraints:

  • 1 <= deadends.length <= 500
  • deadends[i].length == 4
  • target.length == 4
  • target will not be in deadends
  • target != "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

  1. If "0000" is in deadends, return -1.
  2. Use BFS starting from "0000" with distance 0.
  3. At each state, generate 8 neighbors (4 wheels × 2 directions).
  4. Skip visited states and deadends.
  5. Return the distance when target is reached, or -1 if 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

  1. Maintain two frontiers: begin (from start) and end (from target).
  2. At each step, expand the smaller frontier: generate all neighbors, skip visited/dead.
  3. If a new neighbor appears in the other frontier, return the current turn count + 1.
  4. Use two separate visited sets visited_begin and visited_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 numCourses courses you have to take, labeled from 0 to numCourses - 1. You are given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course bi first if you want to take course ai.

Return true if you can finish all courses. Otherwise, return false.

Example 1: Input: numCourses = 2, prerequisites = [[1,0]] Output: true

Example 2: Input: numCourses = 2, prerequisites = [[1,0],[0,1]] Output: false

Constraints:

  • 1 <= numCourses <= 2000
  • 0 <= prerequisites.length <= 5000
  • prerequisites[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

  1. Build adjacency list from prerequisites.
  2. Mark all nodes as WHITE (0).
  3. For each unvisited node, run DFS:
    • Mark it GRAY (1) when entering.
    • For each neighbor: if GRAY, cycle found — return False; if WHITE, recurse.
    • Mark it BLACK (2) when done.
  4. Return True if 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

  1. Compute in-degree for each node.
  2. Enqueue all nodes with in-degree 0.
  3. While queue is non-empty: dequeue a node, increment processed count, decrement neighbors’ in-degrees. Enqueue neighbors that reach in-degree 0.
  4. 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 numCourses courses you have to take, labeled from 0 to numCourses - 1. You are given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course bi first if you want to take course ai.

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 <= 2000
  • 0 <= 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

  1. Build the adjacency list.
  2. For each unvisited node, run DFS. Append to order after all children are processed.
  3. If a cycle is detected at any point, return [].
  4. Return order reversed (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

  1. Compute in-degrees. Enqueue all nodes with in-degree 0.
  2. While queue is non-empty: dequeue course, add to order. For each neighbor, decrement in-degree; if 0, enqueue.
  3. Return order if len(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 n nodes labeled from 0 to n - 1 and 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: true

Example 2: Input: n = 5, edges = [[0,1],[1,2],[2,3],[1,3],[1,4]] Output: false

Constraints:

  • 1 <= n <= 2000
  • 0 <= edges.length <= 5000
  • edges[i].length == 2
  • 0 <= 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

  1. If len(edges) != n - 1, return False.
  2. Build adjacency list for the undirected graph.
  3. Run DFS from node 0, tracking visited nodes.
  4. Return True if all n nodes 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

  1. Initialize Union-Find with n nodes.
  2. For each edge (a, b):
    • If find(a) == find(b), cycle detected — return False.
    • Otherwise, union(a, b).
  3. Return True if all edges processed successfully (and len(edges) == n-1 ensures 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 numCourses courses you have to take, labeled from 0 to numCourses - 1. You are given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course ai first if you want to take course bi.

You are also given an array queries where queries[j] = [uj, vj]. For the j-th query, you should answer whether course uj is a prerequisite of course vj or not.

Return a boolean array answer, where answer[j] is the answer to the j-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 <= 100
  • 0 <= prerequisites.length <= (numCourses * (numCourses - 1) / 2)
  • prerequisites[i].length == 2, no duplicates, no self-loops
  • 1 <= 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

  1. Build adjacency list from prerequisites.
  2. For each query (u, v), run BFS from u.
  3. Return True if v was reached, else False.

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

  1. Initialize reach[i][j] = False for all pairs.
  2. For each direct prerequisite (a, b): set reach[a][b] = True.
  3. For each k from 0 to n-1: for each i, for each j: if reach[i][k] and reach[k][j], set reach[i][j] = True.
  4. Answer each query (u, v) with reach[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 n nodes. There is also an edges array, where edges[i] = [a, b] means that there is an edge between node a and node b in the graph.

Return the total number of connected components in that graph.

Example 1: Input: n = 3, edges = [[0,1],[0,2]] Output: 1

Example 2: Input: n = 6, edges = [[0,1],[1,2],[2,3],[4,5]] Output: 2

Constraints:

  • 1 <= n <= 100
  • 0 <= 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

  1. Build adjacency list.
  2. Track visited set.
  3. For each node from 0 to n-1: if unvisited, increment count and run DFS to mark all connected nodes.
  4. 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

  1. Initialize parent[i] = i, count = n.
  2. For each edge (a, b): if find(a) != find(b), union them and decrement count.
  3. 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 n nodes labeled from 1 to n, with one additional edge added. The added edge has two different vertices chosen from 1 to n, and was not an edge that already existed. The graph is represented as an array edges where edges[i] = [ai, bi] indicates that there is an edge between nodes ai and bi in the graph.

Return an edge that can be removed so that the resulting graph is a tree of n nodes. 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.length
  • 3 <= n <= 1000
  • edges[i].length == 2
  • 1 <= 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

  1. Maintain the current edge set as an adjacency list.
  2. For each new edge (a, b): run DFS from a to check if b is already reachable.
    • If yes, return [a, b] (this edge is redundant).
    • If no, add the edge to the graph.

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

  1. Initialize Union-Find with nodes 1 to n.
  2. For each edge (a, b) in order:
    • If find(a) == find(b), return [a, b].
    • Otherwise, union(a, b).

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 accounts where each element accounts[i] is a list of strings, where accounts[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 <= 1000
  • 2 <= accounts[i].length <= 10
  • 1 <= accounts[i][j].length <= 30
  • accounts[i][0] consists of English letters
  • accounts[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

  1. 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.
  2. Run DFS from each unvisited email, collecting the full connected component.
  3. 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

  1. Map each unique email to an integer index.
  2. Initialize Union-Find on indices.
  3. For each account, union all email indices with the first email’s index.
  4. Group email indices by their find root.
  5. 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 equations and an array of real numbers values, where equations[i] = [Ai, Bi] and values[i] represent the equation Ai / Bi = values[i]. Each Ai or Bi is a string that represents a single variable.

You are also given some queries, where queries[j] = [Cj, Dj] represents the j-th query where you must find the result of Cj / 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 <= 20
  • equations[i].length == 2
  • 1 <= Ai.length, Bi.length <= 5
  • values[i] > 0.0
  • 1 <= 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

  1. Build a weighted adjacency list from equations and values.
  2. For each query (src, dst):
    • If either is unknown, return -1.0.
    • If src == dst, return 1.0.
    • BFS from src, tracking the accumulated product. Stop when dst is found.
  3. Return -1.0 if dst is 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

  1. Initialize dist[a][b] = val for each equation, dist[a][a] = 1.0 for all known variables, dist[b][a] = 1/val.
  2. For each intermediate k, for each i, for each j: if dist[i][k] and dist[k][j] are known, set dist[i][j] = dist[i][k] * dist[k][j].
  3. Answer each query with dist[src][dst] or -1.0 if 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 n nodes labeled from 0 to n - 1, and an array of n - 1 edges where edges[i] = [ai, bi] indicates that there is an undirected edge between the two nodes ai and bi in the tree, you can choose any node of the tree as the root. When you select a node x as the root, the resulting tree has height h. 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^4
  • edges.length == n - 1
  • 0 <= 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

  1. For each node from 0 to n-1, run BFS to get the tree height rooted there.
  2. Collect all nodes with minimum height.
  3. 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

  1. Build adjacency list, compute degree for each node.
  2. Initialize a queue with all leaf nodes (degree == 1).
  3. 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.
  4. 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 beginWord to word endWord using a dictionary wordList is a sequence of words beginWord -> s1 -> s2 -> ... -> sk such that:

  • Every adjacent pair of words differs by a single letter.
  • Every si for 1 <= i <= k is in wordList. Note that beginWord does not need to be in wordList.
  • sk == endWord

Given two words, beginWord and endWord, and a dictionary wordList, return the number of words in the shortest transformation sequence from beginWord to endWord, or 0 if 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 <= 10
  • endWord.length == beginWord.length
  • 1 <= 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

  1. Convert wordList to a set for fast lookup.
  2. BFS with (current_word, steps). Start at (beginWord, 1).
  3. For each current word, check every word in the word set that differs by one letter.
  4. Return steps when endWord is 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

  1. Convert wordList to a set.
  2. BFS from beginWord. For each word, generate neighbors by replacing each of L positions with each of 26 letters.
  3. Keep only neighbors that are in word_set and not yet visited.
  4. Return step count when endWord is 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 n integer matrix heights where heights[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: 2

Example 2: Input: heights = [[1,2,3],[3,8,4],[5,3,5]] Output: 1

Constraints:

  • m == heights.length
  • n == heights[i].length
  • 1 <= m, n <= 100
  • 1 <= 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

  1. Use DFS from (0, 0), passing the current maximum effort seen on this path.
  2. At the destination, record the effort if it’s the best seen.
  3. Use a visited set to avoid revisiting cells on the same path.
  4. 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

  1. Initialize dist grid with infinity; set dist[0][0] = 0.
  2. Push (0, 0, 0)(effort, row, col) — into a min-heap.
  3. 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.
  4. 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 n nodes, labeled 1 to n. You are also given times, a list of travel times as directed edges times[i] = (ui, vi, wi), where ui is the source node, vi is the target node, and wi is 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 all n nodes 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 = 2 Output: 2

Example 2: Input: times = [[1,2,1]], n = 2, k = 1 Output: 1

Constraints:

  • 1 <= k <= n <= 100
  • 1 <= times.length <= 6000
  • times[i].length == 3
  • 1 <= ui, vi <= n
  • 0 <= 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

  1. Initialize dist[k] = 0 and dist[all others] = infinity.
  2. Repeat n-1 times: for each edge (u, v, w), if dist[u] + w < dist[v], update dist[v].
  3. If any dist[v] is still infinity, return -1.
  4. 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

  1. Build an adjacency list from the edge list.
  2. Initialize dist = {k: 0} and push (0, k) into the min-heap.
  3. While the heap is not empty:
    • Pop (time, node).
    • If time > dist[node], skip (stale).
    • For each neighbor (v, w), if dist[node] + w < dist.get(v, inf), update and push.
  4. If len(dist) < n, return -1 (some nodes unreachable).
  5. 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 tickets represented 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 <= 300
  • tickets[i].length == 2
  • tickets[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

  1. Sort tickets so destinations are in lexicographic order.
  2. Use DFS/backtracking from “JFK”, marking tickets as used.
  3. If all tickets are used, record the itinerary.
  4. 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

  1. Build the adjacency list, sorting destinations in reverse order.
  2. Start DFS from “JFK”.
  3. While there are unvisited destinations from the current node, pop the next destination and recurse.
  4. After all neighbors are exhausted, append the current node to the result.
  5. 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 points representing integer coordinates of some points on a 2D-plane, where points[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: 20

Example 2: Input: points = [[3,12],[-2,5],[-4,1]] Output: 18

Constraints:

  • 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

  1. Start from point 0. Mark it as visited.
  2. Push all edges from point 0 to the heap.
  3. While not all points are in the MST:
    • Pop the cheapest edge (cost, next_point).
    • If next_point is already visited, skip.
    • Add cost to the total. Mark next_point as visited.
    • Push all edges from next_point to unvisited points into the heap.
  4. 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

  1. Generate all pairs of points and compute Manhattan distances.
  2. Sort edges by distance.
  3. Initialize Union-Find with n components.
  4. For each edge (cost, i, j) in sorted order:
    • If i and j are in different components, union them and add cost.
    • Stop when we’ve added n-1 edges (MST is complete).
  5. 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 n integer matrix grid where each value grid[i][j] represents the elevation at position (i, j).

The rain starts to fall. At time t, the depth of the water everywhere is t. You can swim from a square to another 4-directionally adjacent square if and only if the elevation of both squares individually is at most t. 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: 3

Example 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: 16

Constraints:

  • n == grid.length
  • n == grid[i].length
  • 1 <= n <= 50
  • 0 <= 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

  1. Binary search: lo = grid[0][0], hi = n*n - 1.
  2. For each mid, run BFS from (0, 0) visiting only cells with value <= mid.
  3. If (n-1, n-1) is reachable, shrink to hi = mid. Else lo = mid + 1.
  4. 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

  1. Initialize dist[0][0] = grid[0][0], all others to infinity.
  2. Push (grid[0][0], 0, 0) into the min-heap.
  3. While heap is not empty:
    • Pop (cost, r, c).
    • If (r, c) is the destination, return cost.
    • 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.
  4. 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 words from the alien language’s dictionary, where the strings in words are 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 <= 100
  • 1 <= 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

  1. Collect all unique characters.
  2. Compare each pair of adjacent words: find the first differing character, add directed edge (first[i] → second[i]).
  3. If words[i] is a prefix of words[i-1] (and longer), return "" — invalid.
  4. Run Kahn’s BFS topological sort (by in-degree).
  5. 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

  1. Build the same directed graph as above.
  2. For each unvisited node, run DFS.
  3. Color nodes: False = in current path (visiting), True = done.
  4. If we visit a node that is in the current path, there’s a cycle.
  5. 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 n cities connected by some number of flights. You are given an array flights where flights[i] = [fromi, toi, pricei] indicates that there is a flight from city fromi to city toi with cost pricei.

Given three integers src, dst, and k, return the cheapest price from src to dst with at most k stops. 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=1 Output: 700

Example 2: Input: n=3, flights=[[0,1,100],[1,2,100],[0,2,500]], src=0, dst=2, k=1 Output: 200

Constraints:

  • 1 <= n <= 100
  • 0 <= flights.length <= (n * (n-1) / 2)
  • flights[i].length == 3
  • 0 <= src, dst, k < n
  • src != 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

  1. Initialize dist = [inf] * n, dist[src] = 0.
  2. Repeat k+1 times:
    • Make a copy temp = dist[:].
    • For each flight (u, v, price): if dist[u] + price < temp[v], update temp[v].
    • Set dist = temp.
  3. 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

  1. Build adjacency list from flights.
  2. Push (0, src, k+1)(cost, city, remaining_stops) — into min-heap.
  3. Track visited = {city: best_remaining_stops} to prune.
  4. While heap is not empty:
    • Pop (cost, city, stops).
    • If city == dst, return cost.
    • 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.
  5. Return -1 if 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 n vertices numbered from 0 to n - 1, and an array edges where edges[i] = [ai, bi, weighti] represents a bidirectional and weighted edge between nodes ai and bi.

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 <= 100
  • 1 <= edges.length <= min(200, n*(n-1)/2)
  • edges[i].length == 3
  • 0 <= ai < bi < n
  • 1 <= 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

  1. Compute the baseline MST weight using Kruskal’s.
  2. For each edge i:
    • Critical test: run Kruskal’s skipping edge i. If resulting weight > baseline (or graph is disconnected), edge i is critical.
    • Pseudo-critical test: force edge i into the MST first, then run Kruskal’s on remaining edges. If the total equals baseline, edge i is pseudo-critical.
  3. 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 rowConditions of size m with each rowConditions[i] = [abovei, belowi], which means that row of number abovei should appear above the row of number belowi.
  • a 2D integer array colConditions of size n with each colConditions[i] = [lefti, righti], which means that column of number lefti should appear to the left of the column of number righti.

The numbers 1 through k must appear in the matrix exactly once. Return any matrix of size k x k satisfying 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 <= 400
  • 1 <= 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:

  1. Find a valid topological ordering of numbers 1..k for row positions.
  2. Find a valid topological ordering of numbers 1..k for column positions.
  3. Place each number at (row_order[num], col_order[num]) in the k×k matrix.

If either topological sort finds a cycle, return [].

Algorithm

  1. Build directed graphs for rows and columns from the respective conditions.
  2. Run Kahn’s topological sort on each graph.
  3. If either sort doesn’t include all k nodes (cycle detected), return [].
  4. Create a row_pos and col_pos mapping: row_pos[num] = its position in row order.
  5. Build the k×k zero matrix. For each number 1..k, set matrix[row_pos[num]][col_pos[num]] = num.
  6. 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 index i and index j, i != j, if and only if gcd(nums[i], nums[j]) > 1, where gcd is the greatest common divisor.

Your task is to determine if for every pair of indices i and j in nums, there exists a sequence of traversals that can take us from i to j.

Return True if it is possible to traverse between all such pairs of indices, or False otherwise.

Example 1: Input: nums = [2,3,6] Output: True

Example 2: Input: nums = [3,9,5] Output: False

Example 3: Input: nums = [4,3,12,8] Output: True

Constraints:

  • 1 <= nums.length <= 10^5
  • 1 <= 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

  1. For each pair (i, j), compute gcd(nums[i], nums[j]).
  2. If GCD > 1, union i and j.
  3. 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

  1. Initialize Union-Find over indices 0..n-1 plus prime numbers up to max(nums).
  2. For each nums[i], factorize it.
  3. For each prime factor p of nums[i], union index i with a “node” representing prime p.
  4. After all numbers are processed, check if all indices have the same root.
  5. 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

PropertyMemoization (top-down)Tabulation (bottom-up)
StyleRecursive + cacheIterative + table
Solves only needed subproblemsYesNo (fills entire table)
Stack overflow riskYes (deep recursion)No
Often easier to write firstYesNo
Often faster in practiceNoYes

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 diff command 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 climbPaths
11(1)
22(1,1) or (2)
33(1,1,1), (1,2), (2,1)
45(1,1,1,1), (1,1,2), (1,2,1), (2,1,1), (2,2)
58

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: gain nums[i] plus the best you could do up to house i-2
  • Skip house i: take whatever the best was up to house i-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 diff algorithm computes the LCS of two files line by line. Lines in the LCS are unchanged; everything else is an insertion or deletion. This is why git diff is 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

ItemWeightValue
A23
B34
C45
D56

Capacity W = 5.

By hand, let’s enumerate some options:

Items chosenTotal weightTotal value
A only23
B only34
A + B57
C only45
D only56

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:

  1. Skip item i — the answer is whatever the best was without it: dp[i-1][w]
  2. Take item i — only legal if weight[i] <= w. The answer is value[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

VersionTimeSpace
2D tabulationO(n × W)O(n × W)
1D rolling arrayO(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

ProblemTimeSpace
Unbounded knapsackO(n × W)O(W)
Coin change (min)O(coins × W)O(W)
Coin change (ways)O(coins × W)O(W)
Rod cuttingO(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

ProblemTimeSpaceSpace (optimised)
LCS lengthO(m × n)O(m × n)O(min(m,n))
LCS with tracebackO(m × n)O(m × n)— (need full table)
Edit distanceO(m × n)O(m × n)O(min(m,n))
Longest common substringO(m × n)O(m × n)O(n)
Shortest common supersequenceO(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 patchdiff computes LCS on the lines of two files. Lines in the LCS are unchanged. Everything else is a + or - line. git merge uses 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

ProblemApproachTimeSpace
Is palindrome?Two pointersO(n)O(1)
Longest palindromic substringExpand from centreO(n²)O(1)
Longest palindromic subsequence2D DP (diagonal fill)O(n²)O(n²)
Minimum insertionsLPS + subtractionO(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 n steps to reach the top. Each time you can climb either 1 or 2 steps. In how many distinct ways can you climb to the top?

Example 1: Input: n = 2 Output: 2 (1+1, 2)

Example 2: Input: n = 3 Output: 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

  1. Base cases: ways(0) = 1 (one way to stand at bottom), ways(1) = 1.
  2. 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

  1. Handle base cases: if n == 1, return 1.
  2. Initialize prev2 = 1 (ways to reach step 0) and prev1 = 1 (ways to reach step 1).
  3. For each step from 2 to n: curr = prev1 + prev2, then slide the window.
  4. 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 cost where cost[i] is the cost of the ith 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 index 1. 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: 15

Example 2: Input: cost = [1,100,1,1,1,100,1,1,100,1] Output: 6

Constraints:

  • 2 <= cost.length <= 1000
  • 0 <= 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

  1. Base case: min_cost(i) = 0 if i >= len(cost).
  2. Recursive case: min_cost(i) = cost[i] + min(min_cost(i+1), min_cost(i+2)).
  3. 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

  1. Initialize dp = [0] * (n + 1) where the last two positions are 0 (reaching “top” is free).
  2. Fill from i = n-1 down to 0: dp[i] = cost[i] + min(dp[i+1], dp[i+2]).
  3. 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, and Tn+3 = Tn + Tn+1 + Tn+2 for n >= 0.

Given n, return the value of Tn.

Example 1: Input: n = 4 Output: 4

Example 2: Input: n = 25 Output: 1389537

Constraints:

  • 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

  1. Base cases: T(0) = 0, T(1) = 1, T(2) = 1.
  2. 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

  1. Initialize a = 0 (T0), b = 1 (T1), c = 1 (T2).
  2. Handle the base cases directly.
  3. For each step from 3 to n: compute d = a + b + c, then a, b, c = b, c, d.
  4. 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 nums representing 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: 4

Example 2: Input: nums = [2,7,9,3,1] Output: 12

Constraints:

  • 1 <= nums.length <= 100
  • 0 <= 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

  1. Define rob(i) = max money from houses i..n-1.
  2. At each house: rob(i) = max(nums[i] + rob(i+2), rob(i+1)).
  3. 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-1dp[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

  1. Handle edge cases: if n == 1, return nums[0].
  2. Initialize prev2 = nums[0], prev1 = max(nums[0], nums[1]).
  3. For each house from index 2 to n-1: curr = max(prev1, prev2 + nums[i]).
  4. 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 nums representing 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: 3

Example 2: Input: nums = [1,2,3,1] Output: 4

Constraints:

  • 1 <= nums.length <= 100
  • 0 <= 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:

  1. Rob houses 0..n-2 (exclude the last house)
  2. 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

  1. If n == 1, return nums[0].
  2. Run House Robber I on nums[:-1] (skip last).
  3. Run House Robber I on nums[1:] (skip first).
  4. 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 in s.

Example 1: Input: s = "babad" Output: "bab" (or "aba", both are valid)

Example 2: Input: s = "cbbd" Output: "bb"

Constraints:

  • 1 <= s.length <= 1000
  • s consists 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

  1. For every pair (i, j) where i <= j, extract s[i:j+1].
  2. Check if it’s a palindrome.
  3. Track the longest palindrome found.
  4. 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

  1. For each center position (single char and between chars):
    • Expand outward while s[left] == s[right].
    • Track the longest palindrome found.
  2. 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 <= 1000
  • s consists 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

  1. For every pair (i, j), check if s[i:j+1] is a palindrome.
  2. Increment count if it is.
  3. 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

  1. 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.
  2. 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-Z can 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 s containing only digits, return the number of ways to decode it. If there is no valid decoding, return 0.

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: 0

Constraints:

  • 1 <= s.length <= 100
  • s contains only digits
  • s may 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

  1. At each position i, check if s[i] is a valid single digit (not ‘0’).
  2. Check if s[i:i+2] is a valid two-digit encoding (10-26).
  3. 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] = 1 if s[0] != '0', else 0

Transitions:

  • Single digit: if s[i-1] != '0', then dp[i] += dp[i-1] (the last character forms a valid letter)
  • Two digits: if 10 <= int(s[i-2:i]) <= 26, then dp[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

  1. Initialize dp1 = 1 (represents dp[i-1]), dp2 = 1 (represents dp[i-2]).
  2. For each position i from 1 to n:
    • Compute curr = 0.
    • Single digit: if s[i-1] != '0', add dp1.
    • Two digits: if 10 <= int(s[i-2:i]) <= 26, add dp2.
    • Slide: dp2 = dp1, dp1 = curr.
  3. 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 coins representing coins of different denominations and an integer amount representing 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 = 15 Output: 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 = 15 Output: 3 (5 + 5 + 5)

Example 3: Input: coins = [2], amount = 3 Output: -1

Constraints:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 2^31 - 1
  • 0 <= 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

  1. dp(amount) = fewest coins to make amount.
  2. Base case: dp(0) = 0.
  3. 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

  1. Initialize dp = [inf] * (amount + 1), dp[0] = 0.
  2. For each i from 1 to amount:
    • For each coin in coins:
      • If coin <= i and dp[i - coin] + 1 < dp[i]: update dp[i].
  3. 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: 0

Constraints:

  • 1 <= nums.length <= 2 * 10^4
  • -10 <= nums[i] <= 10
  • The product of any prefix or suffix of nums is 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

  1. For each starting index i:
    • Maintain a running product, extending to the right.
    • Update the global maximum at each step.
  2. 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

  1. Initialize max_prod = min_prod = result = nums[0].
  2. 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.
  3. 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 s and a dictionary of strings wordDict, return true if s can 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: true

Example 2: Input: s = "applepenapple", wordDict = ["apple","pen"] Output: true

Example 3: Input: s = "catsandog", wordDict = ["cats","dog","sand","and","cat"] Output: false

Constraints:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= 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

  1. If s is empty, return True.
  2. For each word in wordDict, if s starts with it, recurse on s[len(word):].
  3. 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

  1. Initialize dp = [False] * (n + 1), set dp[0] = True (empty string is trivially segmentable).
  2. For each i from 1 to n:
    • For each j from 0 to i:
      • If dp[j] and s[j:i] in word_set: set dp[i] = True, break.
  3. 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: 4

Constraints:

  • 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 Indicesdp[i] depends on all dp[j] where j < 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

  1. Initialize dp = [1] * n (each element is an LIS of length 1 by itself).
  2. For each i from 1 to n-1:
    • For each j from 0 to i-1:
      • If nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1).
  3. 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

  1. Initialize an empty tails list.
  2. For each number in nums:
    • Use binary search to find the leftmost position in tails where tails[pos] >= num.
    • If pos == len(tails), append num (new longer subsequence).
    • Otherwise, replace tails[pos] = num (better tail for this length).
  3. 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, return true if you can partition the array into two subsets such that the sum of the elements in both subsets is equal, or false otherwise.

Example 1: Input: nums = [1,5,11,5] Output: true ([1,5,5] and [11])

Example 2: Input: nums = [1,2,3,5] Output: false

Constraints:

  • 1 <= nums.length <= 200
  • 1 <= 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

  1. Compute total = sum(nums). If total is odd, return False immediately.
  2. Set target = total // 2.
  3. Use recursion: at each index, either include the current element or skip it.
  4. 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

  1. Check odd total → False.
  2. Initialize dp = {0} (achievable sums).
  3. For each num in nums:
    • For each j in current dp (iterate backwards if using an array):
      • Add j + num to dp.
    • If target is in dp: return True early.
  4. 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 nums and a target integer target, return the number of possible combinations that add up to target.

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 = 4 Output: 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 = 3 Output: 0

Constraints:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 1000
  • All the integers in nums are unique
  • 1 <= 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

  1. dp(remaining) = number of ways to reach remaining using any numbers in nums.
  2. Base case: dp(0) = 1.
  3. Recursive case: sum of dp(remaining - num) for all num in nums where num <= 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

  1. Initialize dp = [0] * (target + 1), dp[0] = 1.
  2. For each i from 1 to target:
    • For each num in nums:
      • If num <= i: dp[i] += dp[i - num].
  3. 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 to n.

A perfect square is an integer that is the square of an integer (e.g., 1, 4, 9, 16, …).

Example 1: Input: n = 12 Output: 3 (4 + 4 + 4)

Example 2: Input: n = 13 Output: 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

  1. Precompute all perfect squares up to n.
  2. dp(remaining) = minimum squares needed to sum to remaining.
  3. Base case: dp(0) = 0.
  4. 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

  1. Precompute squares: [1, 4, 9, 16, ...] up to n.
  2. Initialize dp = [inf] * (n + 1), dp[0] = 0.
  3. For each i from 1 to n:
    • For each sq in squares where sq <= i:
      • dp[i] = min(dp[i], dp[i - sq] + 1).
  4. 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 of k positive integers, where k >= 2, and maximize the product of those integers. Return the maximum product you can get.

Example 1: Input: n = 2 Output: 1 (2 = 1 + 1, product = 1)

Example 2: Input: n = 10 Output: 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

  1. dp(n) = max product from breaking n into at least 2 pieces.
  2. For each split k from 1 to n-1:
    • product = k * max(n - k, dp(n - k)).
  3. 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

  1. Initialize dp = [0] * (n + 1), dp[1] = 1.
  2. For each i from 2 to n:
    • For each j from 1 to i-1:
      • dp[i] = max(dp[i], j * (i - j), j * dp[i - j]).
  3. 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

  1. dp(i) = best score advantage the current player can achieve from index i to end.
  2. For each choice k in {1, 2, 3}: take sum(stoneValue[i:i+k]) and subtract dp(i+k) (opponent’s best from that point).
  3. 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

  1. Initialize dp = [0] * (n + 3) (padding with zeros for out-of-bounds).
  2. Precompute prefix sums for fast range sums.
  3. Fill from right to left: dp[i] = max(prefix[i+k] - prefix[i] - dp[i+k] for k in 1,2,3).
  4. Return “Alice” if dp[0] > 0, “Bob” if dp[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 n grid. 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 = 7 Output: 28

Example 2: Input: m = 3, n = 2 Output: 3

Constraints:

  • 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

  1. Define dfs(i, j) returning number of paths to reach (i, j).
  2. If i == 0 or j == 0, return 1 (base case: only one way along edge).
  3. Otherwise return dfs(i-1, j) + dfs(i, j-1).
  4. 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

  1. Create a memo dictionary.
  2. In dfs(i, j), return 1 if at an edge, or look up the cache.
  3. 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

  1. Create a m x n table filled with 1s.
  2. For i from 1 to m-1, and j from 1 to n-1:
    • dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 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 to O(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 n grid. 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. Return 0 if the start or end is blocked.

Example 1: Input: obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]] Output: 2

Example 2: Input: obstacleGrid = [[0,1],[0,0]] Output: 1

Constraints:

  • 1 <= m, n <= 100
  • obstacleGrid[i][j] is 0 or 1

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

  1. If obstacleGrid[0][0] == 1 or obstacleGrid[m-1][n-1] == 1, return 0.
  2. Define dfs(i, j):
    • If out of bounds or obstacleGrid[i][j] == 1, return 0.
    • If i == 0 and j == 0, return 1.
    • Return dfs(i-1, j) + dfs(i, j-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

    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

  1. Create a m x n dp table of zeros.
  2. Fill dp[0][0] = 1 if not an obstacle.
  3. Fill first column: dp[i][0] = dp[i-1][0] if obstacleGrid[i][0] == 0, else 0.
  4. Fill first row: dp[0][j] = dp[0][j-1] if obstacleGrid[0][j] == 0, else 0.
  5. For interior cells: if obstacle set 0, else dp[i-1][j] + dp[i][j-1].
  6. 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 to O(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 n grid 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: 7 Explanation: The path 1 → 3 → 1 → 1 → 1 has sum 7.

Example 2: Input: grid = [[1,2,3],[4,5,6]] Output: 12

Constraints:

  • 1 <= m, n <= 200
  • 0 <= 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

  1. Define dfs(i, j) returning the minimum cost path to reach (i, j).
  2. If i == 0 and j == 0, return grid[0][0].
  3. If i == 0, return grid[0][j] + dfs(0, j-1) (can only come from left).
  4. If j == 0, return grid[i][0] + dfs(i-1, 0) (can only come from above).
  5. 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

  1. Create a m x n dp table.
  2. dp[0][0] = grid[0][0].
  3. Fill first row: dp[0][j] = dp[0][j-1] + grid[0][j].
  4. Fill first column: dp[i][0] = dp[i-1][0] + grid[i][0].
  5. For interior: dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]).
  6. 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 to O(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 text1 and text2, 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, return 0.

Example 1: Input: text1 = "abcde", text2 = "ace" Output: 3 Explanation: The LCS is "ace".

Example 2: Input: text1 = "abc", text2 = "abc" Output: 3

Example 3: Input: text1 = "abc", text2 = "def" Output: 0

Constraints:

  • 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

  1. Define dfs(i, j) = LCS length for text1[:i] and text2[:j].
  2. Base case: if i == 0 or j == 0, return 0.
  3. If text1[i-1] == text2[j-1], return 1 + dfs(i-1, j-1).
  4. 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

  1. Create dp of size (m+1) x (n+1) filled with 0.
  2. For i in 1..m, for j in 1..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])
  3. 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 to O(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 0 if none remain).

Example 1: Input: stones = [2, 7, 4, 1, 8, 1] Output: 1

Example 2: Input: stones = [31, 26, 33, 21, 40] Output: 5

Constraints:

  • 1 <= stones.length <= 30
  • 1 <= 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

  1. Define dfs(i, total) where total is the running signed sum so far.
  2. Base case: when i == len(stones), return abs(total).
  3. 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

  1. Compute S = sum(stones). Target = S // 2.
  2. Initialise dp = {0} (set of reachable sums).
  3. For each stone: dp = {s + stone for s in dp} | dp (but cap at target).
  4. Find best = max(s for s in dp if s <= target).
  5. 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) where S = 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 prices where prices[i] is the price of a stock on day i. 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: 3 Explanation: 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: 0

Constraints:

  • 1 <= prices.length <= 5000
  • 0 <= 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

  1. Define dfs(i, holding) = max profit from day i onwards, given whether we currently hold a stock.
  2. Base case: i >= len(prices) → return 0.
  3. If holding:
    • Sell today: profit = prices[i] + dfs(i+2, False) (skip day i+1 for cooldown)
    • Hold: dfs(i+1, True)
    • Return max of these.
  4. If not holding:
    • Buy today: -prices[i] + dfs(i+1, True)
    • Skip: dfs(i+1, False)
    • Return max of these.

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 idle
  • sold[i] = holding[i-1] + prices[i] — sell what we hold
  • idle[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

  1. Initialise holding = -prices[0], sold = 0, idle = 0.
  2. 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.
  3. 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 amount and an array of integers coins representing coin denominations. Return the number of combinations (order doesn’t matter) that make up the amount. If no combination is possible, return 0.

Example 1: Input: amount = 5, coins = [1, 2, 5] Output: 4 Explanation: 5=5, 5=2+2+1, 5=2+1+1+1, 5=1+1+1+1+1

Example 2: Input: amount = 3, coins = [2] Output: 0

Example 3: Input: amount = 10, coins = [10] Output: 1

Constraints:

  • 1 <= coins.length <= 300
  • 1 <= coins[i] <= 5000
  • 0 <= 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

  1. Define dfs(i, remaining) = number of ways to make remaining using coins[i:].
  2. Base case: remaining == 0 → return 1 (found a valid combination).
  3. Base case: remaining < 0 or i == len(coins) → return 0.
  4. Return dfs(i, remaining - coins[i]) + dfs(i + 1, remaining).
    • First term: use coins[i] again (unbounded).
    • Second term: skip to next coin.

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

  1. dp = [0] * (amount + 1), set dp[0] = 1.
  2. For each coin in coins:
    • For j from coin to amount:
      • dp[j] += dp[j - coin]
  3. 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 nums and an integer target. Assign a + or - to each element of nums. Return the number of different ways you can assign symbols to make the sum of nums equal to target.

Example 1: Input: nums = [1,1,1,1,1], target = 3 Output: 5

Example 2: Input: nums = [1], target = 1 Output: 1

Constraints:

  • 1 <= nums.length <= 20
  • 0 <= 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

  1. Define dfs(i, total) = number of ways to reach target using nums[i:] with current total.
  2. Base: i == len(nums) → return 1 if total == target, else 0.
  3. 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

  1. Define dfs(i, total) with a memo dictionary.
  2. If (i, total) is in memo, return it.
  3. 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, and s3, return true if s3 is formed by an interleaving of s1 and s2. 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: true

Example 2: Input: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbbaccc" Output: false

Example 3: Input: s1 = "", s2 = "", s3 = "" Output: true

Constraints:

  • 0 <= s1.length, s2.length <= 100
  • s3.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

  1. Define dfs(i, j) = can s1[:i] and s2[:j] interleave to form s3[:i+j]?
  2. Base case: i == len(s1) and j == len(s2) → return True.
  3. If i < len(s1) and s1[i] == s3[i+j], try dfs(i+1, j).
  4. If j < len(s2) and s2[j] == s3[i+j], try dfs(i, j+1).
  5. Return True if 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

  1. If len(s1) + len(s2) != len(s3), return False.
  2. Create (m+1) x (n+1) dp table, dp[0][0] = True.
  3. Fill first column: dp[i][0] = dp[i-1][0] and s1[i-1] == s3[i-1].
  4. Fill first row: dp[0][j] = dp[0][j-1] and s2[j-1] == s3[j-1].
  5. 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]).
  6. 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 to O(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 piles of 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. Return true if Alice wins.

Example 1: Input: piles = [5, 3, 4, 5] Output: true

Example 2: Input: piles = [3, 7, 2, 3] Output: true

Constraints:

  • 2 <= piles.length <= 500
  • piles.length is even
  • 1 <= 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 gain piles[i], then opponent gains dp[i+1][j] over us)
  • Take right: piles[j] - dp[i][j-1]

Alice wins if dp[0][n-1] > 0.

Algorithm

  1. Create n x n dp table.
  2. Base case: dp[i][i] = piles[i] (only one pile, take it).
  3. Fill by increasing interval length (length 2, 3, …, n).
  4. dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1]).
  5. 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 X piles where 1 <= X <= 2 * M. After taking, M = max(M, X) for the next turn. Alice goes first with M = 1. Return the maximum number of stones Alice can get.

Example 1: Input: piles = [2, 7, 9, 4, 4] Output: 10

Example 2: Input: piles = [1, 2, 3, 4, 5] Output: 9

Constraints:

  • 1 <= piles.length <= 100
  • 1 <= 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

  1. Precompute suffix sums.
  2. Define dfs(i, M) = max stones the current player can collect starting at index i with parameter M.
  3. If i + 2*M >= n, current player takes all remaining piles.
  4. Otherwise, try each X from 1 to 2*M; new M' = max(M, X). Opponent plays dfs(i+X, M'). Current player gets suffix[i] - dfs(i+X, M').
  5. 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 states. Each state does O(n) work in the worst case, giving O(n³) total.

Algorithm

  1. 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 n integer 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: 4 Explanation: The longest increasing path is [1, 2, 6, 9].

Example 2: Input: matrix = [[3,4,5],[3,2,6],[2,2,1]] Output: 4

Constraints:

  • 1 <= m, n <= 200
  • 0 <= 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

  1. For each cell (i, j), call dfs(i, j, -1) (previous value starts at -1 so any cell is valid).
  2. In DFS: for each of 4 directions, if the neighbour is in bounds and strictly larger, recurse.
  3. 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

  1. Create a dp table of zeros.
  2. For each cell (i, j), call dfs(i, j).
  3. In dfs(i, j): if dp[i][j] is already computed, return it.
  4. Otherwise, check all 4 neighbours that are in bounds and strictly greater.
  5. dp[i][j] = 1 + max(dfs(neighbour)) over valid neighbours.
  6. 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 s and t, return the number of distinct subsequences of s that equal t.

Example 1: Input: s = "rabbbit", t = "rabbit" Output: 3 Explanation: Three ways to choose which b to drop.

Example 2: Input: s = "babgbag", t = "bag" Output: 5

Constraints:

  • 1 <= s.length, t.length <= 1000
  • s and t consist 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

  1. Define dfs(i, j) = number of ways to match t[j:] using characters from s[i:].
  2. Base case: j == len(t) → return 1 (matched all of t).
  3. Base case: i == len(s) → return 0 (ran out of s).
  4. If s[i] == t[j]: return dfs(i+1, j+1) + dfs(i+1, j).
  5. 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

  1. Create (m+1) x (n+1) dp table.
  2. Set dp[i][0] = 1 for all i in 0..m.
  3. Fill row by row, applying the transition.
  4. 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 word1 and word2, return the minimum number of operations required to convert word1 to word2. The three allowed operations are: Insert a character, Delete a character, or Replace a character.

Example 1: Input: word1 = "horse", word2 = "ros" Output: 3 Explanation: horse → rorse (replace h→r), rorse → rose (delete r), rose → ros (delete e)

Example 2: Input: word1 = "intention", word2 = "execution" Output: 5

Constraints:

  • 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 of word1 → now word2[j-1] is matched, recurse on (i, j-1).
  • Delete: delete word1[i-1] → recurse on (i-1, j).
  • Replace: replace word1[i-1] with word2[j-1] → recurse on (i-1, j-1).

Algorithm

  1. Define dfs(i, j) = min edits to convert word1[:i] to word2[:j].
  2. Base: i == 0 → return j (insert all of word2).
  3. Base: j == 0 → return i (delete all of word1).
  4. If characters match: dfs(i-1, j-1).
  5. 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 to word2[:j] requires j insertions.
  • dp[i][0] = i — convert word1[:i] to empty string requires i deletions.

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

  1. Create (m+1) x (n+1) dp table.
  2. Initialise first row and column with 0..n and 0..m.
  3. Fill row by row using the transition.
  4. 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 n balloons, indexed from 0 to n-1. Each balloon has a number nums[i]. If you burst balloon i, you gain nums[i-1] * nums[i] * nums[i+1] coins. The balloons at the boundaries use 1 for 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: 167 Explanation: 3*1*5 + 3*5*8 + 1*3*8 + 1*8*1 = 15 + 120 + 24 + 8 = 167

Example 2: Input: nums = [1, 5] Output: 10

Constraints:

  • 1 <= nums.length <= 300
  • 0 <= 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

  1. Pad nums with sentinels: nums = [1] + nums + [1], length n+2.
  2. Create dp table of size (n+2) x (n+2).
  3. Fill by increasing interval length (from 2 upward).
  4. For each interval (i, j), try each k in (i, j) as the last balloon burst.
  5. dp[i][j] = max(dp[i][k] + nums[i]*nums[k]*nums[j] + dp[k][j]).
  6. 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 s and a pattern p, 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: false

Example 2: Input: s = "aa", p = "a*" Output: true

Example 3: Input: s = "ab", p = ".*" Output: true

Constraints:

  • 1 <= s.length <= 20
  • 1 <= p.length <= 30
  • s contains only lowercase letters.
  • p contains 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

  1. Define dfs(i, j) = does s[i:] match p[j:]?
  2. Base: j == len(p) → return i == len(s).
  3. Check if current p[j] matches s[i]: first_match = i < len(s) and p[j] in {s[i], '.'}.
  4. If j + 1 < len(p) and p[j+1] == '*':
    • Zero uses: dfs(i, j+2).
    • One or more: first_match and dfs(i+1, j).
    • Return OR of these.
  5. 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) or dp[i-1][j] if p[j-2] matches s[i-1] (extend a match).
  • Else if p[j-1] matches s[i-1] (direct char or .): dp[i][j] = dp[i-1][j-1].

Algorithm

  1. Create (m+1) x (n+1) dp table, dp[0][0] = True.
  2. Fill row 0 for patterns that match empty strings (a*, a*b*, etc.).
  3. Fill the rest using the transition above.
  4. 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.

OperatorSymbolWhat 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 out
  • a ^ 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

TrickCodeUse Case
Check odd/evenn & 1Parity, loop branching
Clear lowest set bitn & (n-1)Counting bits, power-of-2 check
Check power of 2n > 0 and n & (n-1) == 0Alignment checks
Set bit kn | (1 << k)Grant permissions, set flags
Clear bit kn & ~(1 << k)Revoke permissions, clear flags
Toggle bit kn ^ (1 << k)Flip a flag
Check bit k(n >> k) & 1Read a flag
Multiply by 2^kn << kFast scaling
Divide by 2^kn >> kFast downscaling, extracting channels
XOR swapa^=b; b^=a; a^=bNo 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.

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: 1

Example 2: Input: nums = [4, 1, 2, 1, 2] Output: 4

Example 3: Input: nums = [1] Output: 1

Constraints:

  • 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 = 0 and a ^ 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

  1. Create an empty hash map count.
  2. For each number in nums, increment count[num] by 1.
  3. 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

  1. Initialize result = 0.
  2. XOR every number in nums into result.
  3. 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 = 11 Output: 3 Explanation: 11 in binary is 1011, which has three 1s.

Example 2: Input: n = 128 Output: 1 Explanation: 128 in binary is 10000000, which has one 1.

Example 3: Input: n = 2147483645 Output: 30

Constraints:

  • 1 <= n <= 2³¹ - 1

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Bitwise AND (&)n & 1 isolates the least significant bit
  • Right shift (>>)n >> 1 divides 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

  1. Convert n to a binary string using bin(n) — this produces something like '0b1011'.
  2. Count the occurrences of '1' in that string.
  3. 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

  1. Initialize count = 0.
  2. While n > 0:
    • Add n & 1 to count (this is 1 if the last bit is set, 0 otherwise).
    • Right-shift n by 1: n >>= 1.
  3. 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

  1. Initialize count = 0.
  2. While n > 0:
    • Increment count.
    • Set n = n & (n - 1) to clear the lowest set bit.
  3. 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 array ans of length n + 1 such that for each i (0 <= i <= n), ans[i] is the number of 1’s in the binary representation of i.

Example 1: Input: n = 2 Output: [0, 1, 1] Explanation: 0 → 0, 1 → 1, 2 → 10

Example 2: Input: n = 5 Output: [0, 1, 1, 2, 1, 2] Explanation: 0 → 0, 1 → 1, 2 → 10, 3 → 11, 4 → 100, 5 → 101

Constraints:

  • 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 >> 1 is the same as i // 2, removing the last bit
  • Bitwise AND (&)i & 1 extracts 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

  1. Initialize ans = [].
  2. For each i from 0 to n (inclusive):
    • Count the 1-bits in i using the bit-shifting method.
    • Append the count to ans.
  3. 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 >> 1 removes the last bit — we already know how many 1s are in i >> 1 from 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

  1. Initialize dp = [0] * (n + 1).
  2. For i from 1 to n:
    • Set dp[i] = dp[i >> 1] + (i & 1).
  3. 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 a and b, 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⁴
  • a and b consist 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() and int() — 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

  1. Convert a to an integer using int(a, 2).
  2. Convert b to an integer using int(b, 2).
  3. Add them.
  4. 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, carry 0
  • Sum 1 → digit 1, carry 0
  • Sum 2 → digit 0, carry 1
  • Sum 3 → digit 1, carry 1

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

  1. Initialize pointers i = len(a) - 1, j = len(b) - 1, carry = 0, and result = [].
  2. While i >= 0 or j >= 0 or carry:
    • Add int(a[i]) if i >= 0, else 0.
    • Add int(b[j]) if j >= 0, else 0.
    • Add carry.
    • Append total % 2 as a string character to result.
    • Set carry = total // 2.
    • Decrement i and j.
  3. Reverse result and 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 (00111001011110000010100101000000 in binary)

Example 2: Input: n = 11111111111111111111111111111101 (binary) Output: 3221225471 (10111111111111111111111111111111 in 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

  1. Convert n to a binary string with bin(n)[2:].
  2. Zero-pad to exactly 32 characters using .zfill(32).
  3. Reverse the string with [::-1].
  4. 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:

  1. Shift result one position left to make room for the next bit.
  2. Read the last bit of n with n & 1 and OR it into result.
  3. Right-shift n to 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

  1. Initialize result = 0.
  2. Repeat 32 times:
    • Left-shift result by 1: result <<= 1.
    • OR in the last bit of n: result |= n & 1.
    • Right-shift n by 1: n >>= 1.
  3. 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 nums containing n distinct 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: 2

Example 2: Input: nums = [0, 1] Output: 2

Example 3: Input: nums = [9, 6, 4, 2, 3, 5, 7, 0, 1] Output: 8

Constraints:

  • n == nums.length
  • 1 <= n <= 10⁴
  • 0 <= nums[i] <= n
  • All the numbers of nums are unique.

Follow up: Can you implement a solution using only O(1) extra space and O(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 propertiesa ^ 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

  1. Let n = len(nums).
  2. Compute expected = n * (n + 1) // 2.
  3. 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

  1. Initialize result = len(nums) (start with n, the index that has no paired value).
  2. For each i from 0 to len(nums) - 1:
    • XOR result with both i and nums[i].
  3. 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 a and b, return the sum of the two integers without using the operators + or -.

Example 1: Input: a = 1, b = 2 Output: 3

Example 2: Input: a = 2, b = 3 Output: 5

Constraints:

  • -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

  1. Use operator.add or functools.reduce — these call __add__ under the hood.
  2. 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

  1. Use a 32-bit mask MASK = 0xFFFFFFFF.
  2. While b != 0:
    • Compute carry = (a & b) << 1 masked to 32 bits.
    • Compute a = (a ^ b) masked to 32 bits.
    • Set b = carry.
  3. If the result fits in a 31-bit positive integer (a <= 0x7FFFFFFF), return a.
  4. 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, return x with its digits reversed. If reversing x causes the value to go outside the signed 32-bit integer range [-2³¹, 2³¹ - 1], return 0.

Assume the environment does not allow you to store 64-bit integers (signed or unsigned).

Example 1: Input: x = 123 Output: 321

Example 2: Input: x = -123 Output: -321

Example 3: Input: x = 120 Output: 21

Example 4: Input: x = 1534236469 Output: 0 Explanation: Reversed integer 9646324351 overflows 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

  1. Record the sign of x.
  2. Reverse the absolute value as a string.
  3. Convert back to integer and reapply the sign.
  4. 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

  1. Determine the sign; work with x = abs(x).
  2. Initialize result = 0 and INT_MAX = 2**31 - 1.
  3. 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.
  4. 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 left and right that represent the range [left, right], return the bitwise AND of all numbers in this range, inclusive.

Example 1: Input: left = 5, right = 7 Output: 4

Example 2: Input: left = 0, right = 0 Output: 0

Example 3: Input: left = 1, right = 2147483647 Output: 0

Constraints:

  • 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

  1. Initialize result = left.
  2. For each number from left + 1 to right, AND it into result.
  3. 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

  1. Initialize shift = 0.
  2. While left != right:
    • Right-shift both: left >>= 1, right >>= 1.
    • Increment shift.
  3. 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 n and x. You have to construct an array of positive integers nums of size n where for every 0 <= i < n - 1, nums[i + 1] is greater than nums[i], and the result of the bitwise AND of all elements of nums is x.

Return the minimum possible value of nums[n - 1].

Example 1: Input: n = 3, x = 4 Output: 6 Explanation: The array [4, 5, 6] has AND = 4 & 5 & 6 = 4, and the last element is 6.

Example 2: Input: n = 2, x = 7 Output: 15 Explanation: The array [7, 15] has AND = 7 & 15 = 7, and the last element is 15.

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

  1. Initialize current = x.
  2. Repeat n - 1 times:
    • Increment current by 1.
    • Use OR to ensure all bits of x are still set: current = current | x.
  3. 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 for n up 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 x has a 0)
  • We want the 1st valid number (0-indexed), so embed 1 = ...001 into free bits.
  • Position 3 is the first free bit. Place the bit 1 of (n-1) = 1 there.
  • Result: 0111 | 1000 = 1111 = 15

Example: n = 3, x = 4 = 100

  • Free bits: positions 0, 1, 3, 4, … (anywhere x has a 0)
  • Embed n-1 = 2 = 10 into 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
  • Result: 100 | 010 = 110 = 6

Example: n = 3, x = 7 = 111

  • Free bits: positions 3, 4, 5, …
  • Embed n-1 = 2 = 10 into free bits.
    • Free bit 0 (position 3): embed bit 0 of 2 = 0
    • Free bit 1 (position 4): embed bit 1 of 2 = 1
  • 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

  1. Let m = n - 1 (the index of the element we want, 0-based).
  2. Start with result = x.
  3. Scan bit positions from 0 upward, tracking two separate bit indices:
    • i — current bit position in the final result
    • j — current bit position in m
  4. While m >> j > 0 (there are still bits to embed from m):
    • If bit i of x is 0 (it’s a free position):
      • Extract bit j of m and place it at position i of result.
      • Advance j to the next bit of m.
    • Advance i to the next bit position.
  5. 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 $20 bills. You start with no change. Return true if you can provide every customer with the exact change, false otherwise.

Example 1: Input: bills = [5, 5, 5, 10, 20] Output: true

Example 2: Input: bills = [5, 5, 10, 10, 20] Output: false

Constraints:

  • 1 <= bills.length <= 10^5
  • bills[i] is 5, 10, or 20

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

  1. Initialise five = 0, ten = 0.
  2. For each bill in bills:
    • $5: increment five.
    • $10: if five > 0, decrement five, increment ten. Else return False.
    • $20: prefer ten + five over five * 3. If neither works, return False.
  3. 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 element nums[i] represents your maximum jump length from that position. Return true if you can reach the last index.

Example 1: Input: nums = [2, 3, 1, 1, 4] Output: true

Example 2: Input: nums = [3, 2, 1, 0, 4] Output: false Explanation: You always land on index 3 which has jump length 0.

Constraints:

  • 1 <= nums.length <= 10^4
  • 0 <= 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

  1. Use a set reachable initialised with {0}.
  2. For each index i in reachable:
    • Add all indices from i+1 to i + nums[i].
  3. 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

  1. Initialise max_reach = 0.
  2. For each index i from 0 to n-1:
    • If i > max_reach, return False.
    • max_reach = max(max_reach, i + nums[i]).
  3. 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 index 0. Each element nums[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: 2 Explanation: Jump 2 → 3, then 3 → last.

Example 2: Input: nums = [2, 3, 0, 1, 4] Output: 2

Constraints:

  • 1 <= nums.length <= 10^4
  • 0 <= 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

  1. BFS from index 0.
  2. For each index in the current level, add all reachable indices to the next level.
  3. 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

  1. Initialise jumps = 0, cur_end = 0, farthest = 0.
  2. For i from 0 to n-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.
  3. 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 integers minJump and maxJump. Start at index 0 (which is always '0'). On each move, jump from index i to any index j where i + minJump <= j <= i + maxJump and s[j] == '0'. Return true if you can reach index n - 1.

Example 1: Input: s = "011010", minJump = 2, maxJump = 3 Output: true Explanation: Jump from index 0 → 3 → 5.

Example 2: Input: s = "01101110", minJump = 2, maxJump = 3 Output: false

Constraints:

  • 2 <= s.length <= 10^5
  • s[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

  1. Start BFS from {0}.
  2. For each reachable index i, explore j from i + minJump to i + maxJump.
  3. Add unvisited '0' indices to the next level.
  4. Return True if n-1 is 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

  1. reachable = [False] * n, reachable[0] = True.
  2. window_count = 0 — count of reachable positions in the current window.
  3. For j from 1 to n-1:
    • If j - minJump >= 0 and reachable[j - minJump]: increment window_count.
    • If j - maxJump - 1 >= 0 and reachable[j - maxJump - 1]: decrement window_count.
    • If s[j] == '0' and window_count > 0: reachable[j] = True.
  4. 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 n gas stations in a circular route. At station i, you get gas[i] fuel and it costs cost[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 -1 if 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: 3

Example 2: Input: gas = [2,3,4], cost = [3,4,3] Output: -1

Constraints:

  • 1 <= n <= 10^5
  • 0 <= 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

  1. For each start i from 0 to n-1:
    • Set tank = 0.
    • Simulate going through n stations (wrapping with modulo).
    • If tank goes negative at any point, break.
    • If we complete n steps, return i.
  2. 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:

  1. Feasibility: if sum(gas) < sum(cost), it’s impossible — return -1.
  2. Finding the start: if we start from some station and the cumulative surplus (tank) goes negative at station k, then no starting station from the original start up to k can 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 station k. So we skip ahead and try k + 1 as the new candidate.

A single pass tracks total_surplus (overall feasibility) and tank (current candidate’s viability).

Algorithm

  1. total = 0, tank = 0, start = 0.
  2. For each station i:
    • net = gas[i] - cost[i].
    • total += net, tank += net.
    • If tank < 0: reset start = i + 1, tank = 0.
  3. If total < 0, return -1.
  4. 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 size groupSize consecutive cards. Return true if she can, false otherwise.

Example 1: Input: hand = [1,2,3,6,2,3,4,7,8], groupSize = 3 Output: true Explanation: Groups: [1,2,3], [2,3,4], [6,7,8]

Example 2: Input: hand = [1,2,3,4,5], groupSize = 4 Output: false

Constraints:

  • 1 <= hand.length <= 10^4
  • 0 <= hand[i] <= 10^9
  • 1 <= 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 SortedDict or 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

  1. While hand is non-empty:
    • Sort hand.
    • Take the smallest card start = hand[0].
    • For i from start to start + groupSize - 1:
      • If i is not in hand, return False.
      • Remove one occurrence of i.
  2. 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 and list.remove per 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

  1. If len(hand) % groupSize != 0, return False.
  2. Count frequencies: count = Counter(hand).
  3. Sort the unique cards.
  4. For each unique card c (in sorted order):
    • If count[c] == 0, skip.
    • n = count[c] — need to start n groups here.
    • For i from 0 to groupSize - 1:
      • If count[c + i] < n, return False.
      • count[c + i] -= n.
  5. 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 of Rs and Ds. 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^4
  • senate[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

  1. Represent the senate as a list.
  2. In each round, track available bans per party.
  3. Remove banned senators.
  4. 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

  1. Build radiant queue and dire queue of indices.
  2. While both queues are non-empty:
    • Pop r = radiant.popleft() and d = dire.popleft().
    • Smaller index wins and re-queues at + n.
  3. 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 triplets and a target triplet target. Merging two triplets means: [max(a,d), max(b,e), max(c,f)]. You can merge any two triplets any number of times. Return true if you can form target from the given triplets.

Example 1: Input: triplets = [[2,5,3],[1,8,4],[1,7,5]], target = [2,7,5] Output: true Explanation: 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: false

Constraints:

  • 1 <= triplets.length <= 10^5
  • triplets[i].length == target.length == 3
  • 1 <= 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

  1. For every subset of triplets:
    • Merge by taking max at each position.
    • If the result equals target, return True.
  2. 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

  1. Filter out triplets where any element exceeds the corresponding target element.
  2. Take the component-wise max across all remaining triplets.
  3. Return True if the result equals target.

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 <= 500
  • s consists 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

  1. For each starting position start:
    • end = last_occurrence[s[start]].
    • For each index i from start to end:
      • end = max(end, last_occurrence[s[i]]).
    • Record end - start + 1, move start = 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

  1. Precompute last[c] = last index of character c.
  2. start = 0, end = 0.
  3. For each i, c in enumerate(s):
    • end = max(end, last[c]).
    • If i == end: partition complete, append end - start + 1, set start = i + 1.
  4. 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 s containing '(', ')', and '*', return true if 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: true

Example 2: Input: s = "(*)" Output: true

Example 3: Input: s = "(*))" Output: true

Constraints:

  • 1 <= s.length <= 100
  • s[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

  1. Define dfs(i, open_count):
    • Base: i == len(s) → return open_count == 0.
    • If open_count < 0, return False.
    • If s[i] == '(': dfs(i+1, open+1).
    • If s[i] == ')': dfs(i+1, open-1).
    • If s[i] == '*': try all three.
  2. 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 both lo and hi.
  • ')' decrements both.
  • '*' decrements lo (treat as ')') but increments hi (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

  1. lo = hi = 0.
  2. For each char c in s:
    • If '(': lo += 1, hi += 1.
    • If ')': lo -= 1, hi -= 1.
    • If '*': lo -= 1, hi += 1.
    • If hi < 0, return False.
    • lo = max(lo, 0).
  3. 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 n children 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: 5 Explanation: Candies: [2, 1, 2]

Example 2: Input: ratings = [1, 2, 2] Output: 4 Explanation: Candies: [1, 2, 1]

Constraints:

  • 1 <= n <= 2 * 10^4
  • 0 <= 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

  1. Initialise candies = [1] * n.
  2. Repeat until stable:
    • For each i from 0 to n-1:
      • If ratings[i] > ratings[i-1] and candies[i] <= candies[i-1]: candies[i] = candies[i-1] + 1.
      • If ratings[i] > ratings[i+1] and candies[i] <= candies[i+1]: candies[i] = candies[i+1] + 1.
  3. 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 to n passes, each O(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

  1. candies = [1] * n.
  2. Left-to-right: for i in 1..n-1, if ratings[i] > ratings[i-1], candies[i] = candies[i-1] + 1.
  3. Right-to-left: for i in n-2..0, if ratings[i] > ratings[i+1], candies[i] = max(candies[i], candies[i+1] + 1).
  4. 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 intervals where intervals[i] = [start_i, end_i] represent the start and end of the i-th interval and intervals is sorted in ascending order by start_i. You are also given an interval newInterval = [start, end] that represents the start and end of another interval.

Insert newInterval into intervals so that intervals is still sorted in ascending order by start_i and intervals still 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^4
  • intervals[i].length == 2
  • 0 <= start_i <= end_i <= 10^5
  • intervals is sorted by start_i in ascending order
  • newInterval.length == 2
  • 0 <= 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

  1. Append newInterval to intervals.
  2. Sort intervals by start time.
  3. Initialize result with the first interval.
  4. For each remaining interval, if it overlaps the last entry in result, extend that entry’s end. Otherwise append the interval to result.
  5. 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

  1. Initialize result = [] and i = 0.
  2. Phase 1 — Add intervals before new one: While i < len(intervals) and intervals[i][1] < newInterval[0], append intervals[i] to result and advance i.
  3. Phase 2 — Merge overlapping intervals: While i < len(intervals) and intervals[i][0] <= newInterval[1], expand newInterval to cover the union: newInterval[0] = min(newInterval[0], intervals[i][0]), newInterval[1] = max(newInterval[1], intervals[i][1]), advance i. Then append the merged newInterval.
  4. Phase 3 — Add remaining intervals: Append intervals[i:] to result.
  5. 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 intervals where intervals[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^4
  • intervals[i].length == 2
  • 0 <= 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

  1. Loop until no merges happen in a full pass:
    • For every ordered pair (i, j), if intervals[i] and intervals[j] overlap, replace them with their union and remove the other.
  2. 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

  1. Sort intervals by start time.
  2. Push the first interval onto result.
  3. For each subsequent interval [start, end]:
    • If start <= result[-1][1] (overlaps with last merged interval), update result[-1][1] = max(result[-1][1], end).
    • Otherwise append [start, end] as a new entry.
  4. 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 intervals where intervals[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: 1

Example 2: Input: intervals = [[1,2],[1,2],[1,2]] Output: 2

Example 3: Input: intervals = [[1,2],[2,3]] Output: 0

Constraints:

  • 1 <= intervals.length <= 10^5
  • intervals[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

  1. For every subset of intervals (via bitmask):
    • Check if removing that subset leaves a non-overlapping set.
    • Track the minimum subset size that works.
  2. 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

  1. Sort intervals by end time.
  2. Initialize keep = 1 (always keep the first interval) and last_end = intervals[0][1].
  3. For each subsequent interval [start, end]:
    • If start >= last_end (no overlap), keep it: increment keep, update last_end = end.
  4. 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 intervals where intervals[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: false

Example 2: Input: intervals = [[7,10],[2,4]] Output: true

Constraints:

  • 0 <= intervals.length <= 10^4
  • intervals[i].length == 2
  • 0 <= 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 whenever c < 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

  1. For every pair (i, j) with i < j:
    • If intervals[i][0] < intervals[j][1] and intervals[j][0] < intervals[i][1], there is an overlap — return False.
  2. 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

  1. Sort intervals by start time.
  2. For each consecutive pair (intervals[i], intervals[i+1]):
    • If intervals[i][1] > intervals[i+1][0], return False (the current meeting has not ended when the next one starts).
  3. 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 intervals where intervals[i] = [start_i, end_i], return the minimum number of conference rooms required.

Example 1: Input: intervals = [[0,30],[5,10],[15,20]] Output: 2

Example 2: Input: intervals = [[7,10],[2,4]] Output: 1

Constraints:

  • 1 <= intervals.length <= 10^4
  • 0 <= 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

  1. Sort intervals by start time.
  2. Maintain a list rooms where each entry is the end time of the meeting currently occupying that room.
  3. For each meeting [start, end]:
    • Find any room whose current meeting ends by start. If found, update that room’s end time to end.
    • Otherwise add a new room with end time end.
  4. 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

  1. Sort intervals by start time.
  2. Initialize a min-heap heap.
  3. For each meeting [start, end]:
    • If heap is non-empty and heap[0] <= start, pop the earliest-ending room (it is free).
    • Push end onto the heap (allocate or reuse a room).
  4. 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

  1. Create starts = sorted([i[0] for i in intervals]) and ends = sorted([i[1] for i in intervals]).
  2. Initialize rooms = 0, max_rooms = 0, s = 0, e = 0.
  3. While s < n:
    • If starts[s] < ends[e]: a meeting starts before the earliest end — new room needed. Increment rooms, advance s.
    • Else: a meeting ends before or when this one starts — free a room. Decrement rooms, advance e.
    • Update max_rooms = max(max_rooms, rooms).
  4. 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 n representing the number of rooms numbered 0 to n - 1. You are also given a 2D integer array meetings where meetings[i] = [start_i, end_i] represents the start and end times of meeting i (end time is exclusive — the room is free again at end_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: 0

Example 2: Input: n = 3, meetings = [[1,20],[2,10],[3,5],[4,9],[6,8]] Output: 1

Constraints:

  • 1 <= n <= 100
  • 1 <= meetings.length <= 10^5
  • 0 <= start_i < end_i <= 5 * 10^5
  • All values of start_i are 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

  1. Sort meetings by start time.
  2. Maintain end_times[i] = when room i next becomes free (initially 0).
  3. Maintain count[i] = meetings held in room i.
  4. For each meeting [start, end]:
    • Find the lowest-indexed room r where end_times[r] <= start. If found, assign this meeting.
    • Otherwise, find the room r with the smallest end_times[r], delay the meeting: new end = end_times[r] + (end - start).
    • Update end_times[r] and count[r].
  5. 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

  1. Sort meetings by start time.
  2. Initialize available = min-heap([0, 1, ..., n-1]) and occupied = min-heap() (pairs of (end_time, room_id)).
  3. Initialize count = [0] * n.
  4. For each meeting [start, end]:
    • Move rooms from occupied to available if their end time ≤ start.
    • If available is non-empty: pop the lowest-numbered room, schedule meeting ending at end.
    • Else: pop (earliest_end, room) from occupied, schedule ending at earliest_end + (end - start).
    • Push (new_end, room) onto occupied, increment count[room].
  5. 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, where intervals[i] = [left_i, right_i] describes the i-th interval starting at left_i and ending at right_i (inclusive). The size of an interval is defined as right_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 contains queries[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^5
  • 1 <= queries.length <= 10^5
  • 1 <= left_i <= right_i <= 10^7
  • 1 <= 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

  1. For each query q in queries:
    • Initialize best = -1.
    • For each interval [left, right]: if left <= q <= right, compute size right - left + 1 and update best = min(best, size) if best == -1 or size is smaller.
    • Append best to the result.
  2. 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

  1. Sort intervals by start time. Create sorted queries paired with original indices.
  2. Initialize a min-heap heap (entries: (size, end)), interval pointer i = 0, and result dict.
  3. For each (q, original_idx) in sorted queries:
    • Push all intervals with start <= q onto the heap as (size, end).
    • Pop from heap top all intervals where end < q (they no longer contain q).
    • If heap is non-empty, result[original_idx] = heap[0][0]; else -1.
  4. 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 = 1 Output: "A"

Example 2: Input: columnNumber = 28 Output: "AB"

Example 3: Input: columnNumber = 701 Output: "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

  1. Generate column titles one at a time, counting from 1 up to columnNumber.
  2. To generate the next title, increment the last character. If it wraps past Z, carry over (like binary addition).
  3. 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

  1. Initialize result = [].
  2. While n > 0:
    • Subtract 1 from n to shift to 0-indexed: n -= 1.
    • Append chr(n % 26 + ord('A')) to result.
    • Integer-divide: n //= 26.
  3. Reverse result and 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 s and t, we say t divides s if and only if s = t + t + t + ... + t + t (i.e., t is concatenated with itself one or more times to form s).

Given two strings str1 and str2, return the largest string x such that x divides both str1 and str2.

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 <= 1000
  • str1 and str2 consist 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

  1. Let candidate_len iterate from min(len(str1), len(str2)) down to 1.
  2. For each length, extract t = str1[:candidate_len].
  3. Check if t * (len(str1) // candidate_len) == str1 and t * (len(str2) // candidate_len) == str2 and both lengths are divisible.
  4. Return the first (longest) t that passes.
  5. 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

  1. If str1 + str2 != str2 + str1, return "" — no common divisor exists.
  2. Compute g = gcd(len(str1), len(str2)).
  3. 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 next pointers 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

  1. Traverse the list and collect all values into vals.
  2. Build result_vals by interleaving each adjacent pair with gcd(a, b).
  3. Construct a new linked list from result_vals and 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

  1. Start with cur = head.
  2. While cur and cur.next are 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).
  3. 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 of matrix. 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.length
  • n == matrix[i].length
  • 1 <= m, n <= 1000
  • 1 <= 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 row i, column j
  • 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

  1. Let m = len(matrix) and n = len(matrix[0]).
  2. Create a new matrix of shape n × m filled with zeros.
  3. For each i in 0..m-1 and j in 0..n-1: set result[j][i] = matrix[i][j].
  4. 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

  1. Unpack matrix into rows and pass them all to zip: zip(*matrix).
  2. zip groups column-wise across rows, producing tuples.
  3. 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 n 2D 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].length
  • 1 <= n <= 20
  • -1000 <= matrix[i][j] <= 1000

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Matrix transposition — swapping matrix[i][j] and matrix[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

  1. Create a copy temp of the matrix.
  2. For each (i, j): set matrix[j][n-1-i] = temp[i][j].
  3. 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:

  1. Transpose the matrix (swap matrix[i][j] with matrix[j][i] for all i < j). This flips the matrix across its main diagonal.
  2. 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

  1. Transpose: For i in 0..n-1 and j in i+1..n-1, swap matrix[i][j] and matrix[j][i].
  2. 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

  1. For each layer l from 0 to n//2 - 1:
    • For each position i from l to n - 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).

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 n matrix, 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.length
  • n == matrix[i].length
  • 1 <= 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

  1. Directions: right, down, left, up as (dr, dc) pairs.
  2. Walk cell by cell, adding each to the result.
  3. Before each step, check if the next cell is in-bounds and unvisited. If not, turn right (rotate direction index).
  4. Stop when all m * n cells 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

  1. Initialize top = 0, bottom = m-1, left = 0, right = n-1.
  2. While top <= bottom and left <= right:
    • Right: add matrix[top][c] for c in left..right. Then top += 1.
    • Down: add matrix[r][right] for r in top..bottom. Then right -= 1.
    • Left: if top <= bottom, add matrix[bottom][c] for c in right..left (reversed). Then bottom -= 1.
    • Up: if left <= right, add matrix[r][left] for r in bottom..top (reversed). Then left += 1.
  3. 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 n integer matrix, if an element is 0, set its entire row and column to 0. 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.length
  • n == matrix[i].length
  • 1 <= 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

  1. Scan the entire matrix and collect zero_rows and zero_cols (sets of indices).
  2. For every cell (i, j): if i is in zero_rows or j is in zero_cols, set matrix[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.

  1. Check if row 0 has any zero (store in first_row_zero).
  2. Check if col 0 has any zero (store in first_col_zero).
  3. For cells (i, j) with i >= 1 and j >= 1: if matrix[i][j] == 0, mark matrix[i][0] = 0 and matrix[0][j] = 0.
  4. Use those markers to zero out the interior cells.
  5. Zero out the first row and first column based on the booleans from steps 1-2.

Algorithm

  1. first_row_zero = any(matrix[0][j] == 0 for j in range(n)).
  2. first_col_zero = any(matrix[i][0] == 0 for i in range(m)).
  3. For i in 1..m-1 and j in 1..n-1: if matrix[i][j] == 0, set matrix[i][0] = 0 and matrix[0][j] = 0.
  4. For i in 1..m-1 and j in 1..n-1: if matrix[i][0] == 0 or matrix[0][j] == 0, set matrix[i][j] = 0.
  5. If first_row_zero: set matrix[0][j] = 0 for all j.
  6. If first_col_zero: set matrix[i][0] = 0 for all i.

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 n is 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 true if n is a happy number, and false if not.

Example 1: Input: n = 19 Output: true

Example 2: Input: n = 2 Output: false

Constraints:

  • 1 <= n <= 2^31 - 1

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Digit extraction — using % 10 and // 10 to 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

  1. Initialize seen = set().
  2. While n != 1 and n not in seen:
    • Add n to seen.
    • Replace n with the sum of squares of its digits.
  3. 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

  1. Initialize slow = n and fast = digit_square_sum(n).
  2. While fast != 1 and slow != fast:
    • Move slow one step: slow = digit_square_sum(slow).
    • Move fast two steps: fast = digit_square_sum(digit_square_sum(fast)).
  3. 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 each digits[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 <= 100
  • 0 <= digits[i] <= 9
  • digits does 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

  1. Convert digits to an integer n using int(''.join(map(str, digits))).
  2. Compute n + 1.
  3. 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

  1. Iterate from i = len(digits) - 1 down to 0:
    • If digits[i] < 9: increment it and return digits immediately (no carry propagates).
    • Otherwise: set digits[i] = 0 and continue left (carry).
  2. 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, and M.

SymbolValue
I1
V5
X10
L50
C100
D500
M1000

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 and IX = 9.

Given a roman numeral string s, convert it to an integer.

Example 1: Input: s = "III" Output: 3

Example 2: Input: s = "LVIII" Output: 58

Example 3: Input: s = "MCMXCIV" Output: 1994

Constraints:

  • 1 <= s.length <= 15
  • s contains only the characters ('I', 'V', 'X', 'L', 'C', 'D', 'M')
  • It is guaranteed that s is 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

  1. Define a map of two-character special cases and their values.
  2. For each special case, replace occurrences in s with a placeholder or just compute its contribution.
  3. 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

  1. Build val = {'I':1, 'V':5, 'X':10, 'L':50, 'C':100, 'D':500, 'M':1000}.
  2. Initialize result = 0.
  3. For each index i in 0..len(s)-1:
    • If i + 1 < len(s) and val[s[i]] < val[s[i+1]]: subtract val[s[i]].
    • Else: add val[s[i]].
  4. 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 calculates x raised to the power n (i.e., x^n).

Example 1: Input: x = 2.00000, n = 10 Output: 1024.00000

Example 2: Input: x = 2.10000, n = 3 Output: 9.26100

Example 3: Input: x = 2.00000, n = -2 Output: 0.25000

Constraints:

  • -100.0 < x < 100.0
  • -2^31 <= n <= 2^31 - 1
  • n is an integer
  • Either x is not zero or n > 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 exponentsx^(-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

  1. If n < 0, set x = 1/x and n = -n.
  2. Initialize result = 1.0.
  3. Multiply result *= x exactly n times.
  4. 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

  1. If n < 0: set x = 1/x, n = -n.
  2. Initialize result = 1.0.
  3. While n > 0:
    • If n is odd (n % 2 == 1): multiply result *= x.
    • Square x: x *= x.
    • Halve n: n //= 2.
  4. 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 num1 and num2 represented as strings, return the product of num1 and num2, 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 <= 200
  • num1 and num2 consist of digits only
  • Both num1 and num2 do not contain any leading zeros, except the number 0 itself

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

  1. Create pos = [0] * (len(num1) + len(num2)).
  2. For each i from len(num1)-1 down to 0:
    • For each j from len(num2)-1 down 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).
  3. 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 DetectSquares class:

  • DetectSquares() Initializes the object with an empty data structure.
  • void add(int[] point) Adds a new point point = [x, y] to the data structure.
  • int count(int[] point) Counts the number of ways to form axis-aligned squares with point as one corner.

Example 1: Input: add([3,10]), add([11,2]), add([3,2]), then count([11,10]) Output: 1

Example 2: Input: add([3,10]), add([11,2]), add([3,2]), add([11,10]), then count([11,10]) Output: 1 (the square using the original points; the query point itself does not auto-contribute)

Constraints:

  • point.length == 2
  • 0 <= x, y <= 1000
  • At most 3000 calls will be made to add and count.

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

  1. Store all added points in a list pts.
  2. For count(qx, qy): for every pair (p1, p2) from pts:
    • 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.
  3. 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

  1. Maintain pt_count: dict[(x,y) -> count] for all added points.
  2. Maintain x_points: dict[x -> set of y values] for quick lookup.
  3. For count(qx, qy):
    • Initialize result = 0.
    • For each x with stored points (excluding x == qx — that would give zero-width):
      • For each y stored at that x:
        • If abs(qx - x) != abs(qy - y) or y == qy: skip (not a valid diagonal).
        • Otherwise: result += pt_count[(x, y)] * pt_count[(qx, y)] * pt_count[(x, qy)].
    • Return result.

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 nums and an integer k, return true if there are two distinct indices i and j in the array such that nums[i] == nums[j] and abs(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

  1. For each index i, iterate over the previous k indices (or from the start, whichever is closer).
  2. If nums[i] == nums[j] for any j in that range, return true.
  3. 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

  1. Initialize an empty set window.
  2. For each index i:
    • If nums[i] is already in window, return true.
    • Add nums[i] to window.
    • If the window size exceeds k, remove nums[i - k] from the set to keep it at most size k.
  3. Return false if 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
  • s consists 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

  1. For each start index i, for each end index j >= i, extract the substring s[i:j+1].
  2. Check if all characters in that substring are unique (convert to a set and compare lengths).
  3. 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

  1. Initialize left = 0, an empty set char_set, and max_len = 0.
  2. For each right in range len(s):
    • While s[right] is already in char_set, remove s[left] and increment left.
    • Add s[right] to char_set.
    • Update max_len = max(max_len, right - left + 1).
  3. 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 s and an integer k. You can choose any character of the string and change it to any other uppercase English character. You can perform this operation at most k times. 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

  1. For each start i and end j, count character frequencies in s[i:j+1].
  2. Check if (j - i + 1) - max(freq.values()) <= k.
  3. 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

  1. Initialize left = 0, freq = {} (character counts in window), max_freq = 0, max_len = 0.
  2. For each right:
    • Add s[right] to freq and update max_freq.
    • While (right - left + 1) - max_freq > k, shrink: decrement freq[s[left]], move left right.
    • Update max_len = max(max_len, right - left + 1).
  3. 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 s1 and s2, return true if s2 contains a permutation of s1, or false otherwise. In other words, return true if one of s1’s permutations is a substring of s2.

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

  1. Compute the length m = len(s1).
  2. For each starting index i in s2 where a window of size m fits:
    • Extract s2[i:i+m] and check if it’s an anagram of s1 (compare sorted strings or Counter objects).
  3. Return true if any window matches, otherwise false.

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

  1. Build frequency arrays need (for s1) and have (for the first window of s2).
  2. If they match immediately, return true.
  3. Slide the window across s2:
    • Add the new right character to have.
    • Remove the old left character from have.
    • If have == need, return true.
  4. 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 nums and a positive integer target, return the minimal length of a subarray whose sum is greater than or equal to target. If there is no such subarray, return 0 instead.

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

  1. For each start index i, accumulate a running sum by adding elements one by one.
  2. As soon as the sum >= target, record the window length and break the inner loop.
  3. 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

  1. Initialize left = 0, current_sum = 0, min_len = infinity.
  2. For each right in range(len(nums)):
    • Add nums[right] to current_sum.
    • While current_sum >= target:
      • Update min_len = min(min_len, right - left + 1).
      • Subtract nums[left] from current_sum and increment left.
  3. Return min_len if it was updated, otherwise 0.
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 s and t of lengths m and n respectively, return the minimum window substring of s such that every character in t (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

  1. For each pair (i, j), extract substring s[i:j+1].
  2. For each character in t, confirm its count in the substring is at least as large.
  3. 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

  1. Build count_t (frequency of each char in t). Set need = len(count_t) (distinct chars needed), have = 0.
  2. Use window = {} for the current window’s char counts, left = 0, and track best window (length, left, right).
  3. For each right:
    • Add s[right] to window. If window[s[right]] == count_t[s[right]], increment have.
    • While have == need (window is valid):
      • Update best if current window is smaller.
      • Remove s[left] from window; if it drops below requirement, decrement have.
      • Increment left.
  4. 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 size k which is moving from the very left of the array to the very right. You can only see the k numbers 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

  1. For each starting index i from 0 to n - k:
    • Compute max(nums[i:i+k]).
    • Append to result.
  2. 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

  1. Initialize an empty deque dq (stores indices) and result list.
  2. For each index i:
    • While dq is not empty and nums[dq[-1]] <= nums[i], pop from the back.
    • Append i to the back of dq.
    • Pop from the front if dq[0] <= i - k (out of window).
    • Once i >= k - 1 (first full window), append nums[dq[0]] to result.
  3. 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, where ops[i] is the i-th operation you must apply to the record and is one of the following:

  • An integer x — Record a new score of x.
  • "+" — 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 <= 1000
  • ops[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

  1. Initialize an empty stack to hold valid scores.
  2. 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).
  3. 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 most n scores.

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 s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid.

An input string is valid if:

  1. Open brackets must be closed by the same type of brackets.
  2. Open brackets must be closed in the correct order.
  3. 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^4
  • s consists 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

  1. Repeatedly replace (), [], and {} with empty string "".
  2. Stop when no replacement was made in a full pass.
  3. If the string is empty, return True; otherwise False.

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²) — each replace call is O(n) and we may do up to O(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

  1. Create a mapping from each closing bracket to its corresponding open bracket.
  2. Initialize an empty stack.
  3. 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.
  4. After processing all characters, return True only 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 most n/2 open 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, and empty).

Implement the MyStack class:

  • MyStack() Initializes the stack object.
  • void push(int x) Pushes element x to 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() Returns true if the stack is empty, false otherwise.

Example 1: Input: [“MyStack”,“push”,“push”,“top”,“pop”,“empty”], [[], [1], [2], [], [], []] Output: [null,null,null,2,2,false]

Constraints:

  • 1 <= x <= 9
  • At most 100 calls will be made to push, pop, top, and empty.
  • All calls to pop and top are valid.

Prerequisites

Before attempting this problem, you should be comfortable with:

  • Queue — A first-in, first-out (FIFO) data structure; deque from collections is 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):

  1. Enqueue x into q2.
  2. Move all elements from q1 into q2 one by one.
  3. Swap q1 and q2 (the new element is now at the front of q1).

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 — n is 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):

  1. Append x to the back of the queue.
  2. Rotate the queue len(q) - 1 times: dequeue from the front and enqueue at the back.
    • Now x is at the front.

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, and empty).

Implement the MyQueue class:

  • MyQueue() Initializes the queue object.
  • void push(int x) Pushes element x to 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() Returns true if the queue is empty, false otherwise.

Note: You must use only standard stack operations — which means only push to top, peek/pop from top, size, and is empty are 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 100 calls will be made to push, pop, peek, and empty.
  • All calls to pop and peek are 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):

  1. Push x onto inbox.

pop():

  1. If outbox is empty, pour all of inbox into outbox (reverse order preserved).
  2. Pop and return the top of outbox.

peek():

  1. If outbox is empty, pour all of inbox into outbox.
  2. Peek at the top of outbox.

empty():

  1. Return True if both inbox and outbox are 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 MinStack class:

  • MinStack() Initializes the stack object.
  • void push(int val) Pushes the element val onto 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, and getMin operations will always be called on non-empty stacks.
  • At most 3 * 10^4 calls will be made to push, pop, top, and getMin.

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):

  1. Push val onto the main stack.
  2. Compute the new minimum: min(val, min_stack[-1]) if min_stack is non-empty, else val.
  3. Push that minimum onto the min stack.

pop():

  1. Pop from both the main stack and the min stack simultaneously.

top():

  1. Return the top of the main stack.

getMin():

  1. 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 is 2n.

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 tokens that 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^4
  • tokens[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 + means 3 + 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

  1. Initialize an empty stack.
  2. For each token:
    • If it is a number (not one of the four operators), convert it to int and push it.
    • If it is an operator, pop b then a (order matters for - and /), compute a op b, and push the result.
  3. 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)/2 numbers 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 asteroids of 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] <= 1000
  • asteroids[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

  1. Initialize an empty stack.
  2. For each asteroid a:
    • Set alive = True.
    • While alive and 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, set alive = False.
      • If |a| < stack[-1]: incoming asteroid explodes — set alive = False.
    • If alive, push a onto the stack.
  3. 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 most n asteroids.

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 temperatures representing the daily temperatures, return an array answer such that answer[i] is the number of days you have to wait after the i-th day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0 instead.

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^5
  • 30 <= 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

  1. Initialize answer array of zeros with length n.
  2. For each index i, scan j = i+1, i+2, ... until temperatures[j] > temperatures[i].
  3. 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

  1. Initialize answer = [0] * n and an empty stack of indices.
  2. For each index i:
    • While stack is non-empty and temperatures[i] > temperatures[stack[-1]]:
      • Pop index j from the stack.
      • Set answer[j] = i - j.
    • Push i onto the stack.
  3. Any indices remaining in the stack get answer[j] = 0 (already initialized).
  4. 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 StockSpanner class:

  • StockSpanner() Initializes the object of the class.
  • int next(int price) Returns the span of the given price for 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^4 calls will be made to next.

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):

  1. Start with span = 1 (counts today).
  2. While the stack is non-empty and stack[-1][0] <= price:
    • Pop (prev_price, prev_span) from the stack.
    • Add prev_span to span.
  3. Push (price, span) onto the stack.
  4. 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 per next() call — each price is pushed once and popped at most once across all calls.
  • Space: O(n) — the stack holds at most n entries (one per next() 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 n cars at given miles away from the starting mile 0, traveling to reach the mile target.

You are given two integer arrays position and speed, both of length n, where position[i] is the starting mile of the i-th car and speed[i] is the speed in miles per hour of the i-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.length
  • 1 <= n <= 10^5
  • 0 < target <= 10^6
  • 0 <= position[i] < target
  • All values of position are 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

  1. Pair up (position[i], speed[i]) and sort by position in descending order (closest to target first).
  2. Initialize an empty stack.
  3. For each car, compute time = (target - pos) / speed.
  4. If the stack is empty or time > stack[-1], push time (new fleet).
  5. Otherwise, this car catches up to the fleet ahead — skip it (do not push).
  6. 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 <= 3000
  • path consists of English letters, digits, period '.', slash '/' or '_'.
  • path is 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

  1. Split path on '/' to get components.
  2. Initialize an empty stack.
  3. 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.
  4. 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 most n/2 components.

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 the encoded_string inside the square brackets is being repeated exactly k times. Note that k is 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 <= 30
  • s consists of lowercase English letters, digits, and square brackets '[]'.
  • It is guaranteed that s is 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

  1. Initialize current_str = "", current_num = 0, and an empty stack.
  2. For each character c:
    • If c is 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 "" and 0.
    • If c == ']': pop (prev_str, num), set current_str = prev_str + num * current_str.
    • Otherwise (a letter): append c to current_str.
  3. 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) where n is 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, and current_str can 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 FreqStack class:

  • FreqStack() Constructs an empty frequency stack.
  • void push(int val) Pushes an integer val onto 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^4 calls will be made to push and pop.
  • 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):

  1. Increment freq[val] (default 0).
  2. Update max_freq = max(max_freq, freq[val]).
  3. Append val to group[freq[val]] (the stack for that frequency).

pop():

  1. Pop val from group[max_freq].
  2. Decrement freq[val].
  3. If group[max_freq] is now empty, decrement max_freq.
  4. 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 both push and pop — all operations are hash map lookups and stack push/pop.
  • Space: O(n) — the frequency map and group map together hold n total 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 heights representing the histogram’s bar height where the width of each bar is 1, 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^5
  • 0 <= 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

  1. For each starting bar i, track the running minimum height as you extend right.
  2. For each ending bar j >= i, compute area = min_height * (j - i + 1).
  3. 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 over n bars.
  • 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

  1. Initialize max_area = 0 and an empty stack of (start_index, height).
  2. For each bar at index i with height h:
    • Set start = i.
    • While stack is non-empty and stack[-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).
    • Push (start, h).
  3. After the loop, process remaining bars in the stack:
    • For each (idx, h) in stack: area = h * (n - idx).
  4. 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 most n bars.

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^5
  • s[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

  1. Create a reversed copy of s using slicing: s[::-1].
  2. 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

  1. Initialize left = 0 and right = len(s) - 1.
  2. While left < right:
    • Swap s[left] and s[right].
    • Increment left, decrement right.

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, return true if it is a palindrome, or false otherwise.

Example 1: Input: s = "A man, a plan, a canal: Panama" Output: true

Example 2: Input: s = "race a car" Output: false

Example 3: Input: s = " " Output: true

Constraints:

  • 1 <= s.length <= 2 * 10^5
  • s consists 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

  1. Build cleaned by iterating s and keeping only characters where c.isalnum() is True, lowercased.
  2. 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

  1. Initialize left = 0, right = len(s) - 1.
  2. While left < right:
    • Advance left while left < right and s[left] is not alphanumeric.
    • Retreat right while left < right and s[right] is not alphanumeric.
    • If s[left].lower() != s[right].lower(), return False.
    • Move both pointers inward: left += 1, right -= 1.
  3. 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, return true if the string can be a palindrome after deleting at most one character from it.

Example 1: Input: s = "aba" Output: true

Example 2: Input: s = "abca" Output: true (delete 'c')

Example 3: Input: s = "abc" Output: false

Constraints:

  • 1 <= s.length <= 10^5
  • s consists 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

  1. Define a helper is_palindrome(t) that returns whether string t reads the same forward and backward.
  2. If s is already a palindrome, return True.
  3. For each index i from 0 to len(s) - 1:
    • Build t = s[:i] + s[i+1:] (delete character at i).
    • If is_palindrome(t), return True.
  4. 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

  1. Define a helper is_palindrome(t, l, r) that checks if t[l..r] is a palindrome using two pointers.
  2. Initialize left = 0, right = len(s) - 1.
  3. 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).
  4. 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 word1 and word2. Merge the strings by adding letters in alternating order, starting with word1. 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 <= 100
  • word1 and word2 consist 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

  1. Initialize result = [].
  2. Zip word1 and word2 — for each pair (c1, c2), append c1 then c2 to result.
  3. Find which string is longer and append its remaining characters.
  4. 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

  1. Initialize i = 0, result = [].
  2. While i < len(word1) or i < len(word2):
    • If i < len(word1), append word1[i].
    • If i < len(word2), append word2[i].
    • Increment i.
  3. 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 nums1 and nums2, sorted in non-decreasing order, and two integers m and n, representing the number of elements in nums1 and nums2 respectively.

Merge nums2 into nums1 as one sorted array in-place. nums1 has length m + n; the last n positions are zeroed out as placeholders.

Example 1: Input: nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3 Output: [1,2,2,3,5,6]

Example 2: Input: nums1 = [1], m = 1, nums2 = [], n = 0 Output: [1]

Constraints:

  • nums1.length == m + n
  • nums2.length == n
  • 0 <= 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

  1. Copy nums2 into nums1[m:].
  2. Sort nums1 in 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

  1. Initialize p1 = m - 1, p2 = n - 1, tail = m + n - 1.
  2. While p1 >= 0 and p2 >= 0:
    • If nums1[p1] >= nums2[p2]: write nums1[p1] to nums1[tail], decrement p1.
    • Else: write nums2[p2] to nums1[tail], decrement p2.
    • Decrement tail.
  3. While p2 >= 0: write nums2[p2] to nums1[tail], decrement both p2 and tail.
  4. (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 nums sorted 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. Return k — the number of unique elements in nums.

The first k elements of nums must 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] <= 100
  • nums is 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

  1. Build unique by iterating nums and appending a value only when it differs from the previous value.
  2. Write unique back into the first len(unique) positions of nums.
  3. 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

  1. Initialize k = 1 (the first element is always unique, so the write position starts at index 1).
  2. For i from 1 to len(nums) - 1:
    • If nums[i] != nums[k - 1]: write nums[i] to nums[k], increment k.
  3. 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 numbers that is already sorted in non-decreasing order, find two numbers such that they add up to a specific target number. Return the indices of the two numbers as an integer array [index1, index2] where 1 <= 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 = 9 Output: [1,2]

Example 2: Input: numbers = [2,3,4], target = 6 Output: [1,3]

Example 3: Input: numbers = [-1,0], target = -1 Output: [1,2]

Constraints:

  • 2 <= numbers.length <= 3 * 10^4
  • -1000 <= numbers[i] <= 1000
  • numbers is 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

  1. For i from 0 to len(numbers) - 2:
    • For j from i + 1 to len(numbers) - 1:
      • If numbers[i] + numbers[j] == target: return [i + 1, j + 1].

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

  1. Initialize left = 0, right = len(numbers) - 1.
  2. While left < right:
    • Compute total = numbers[left] + numbers[right].
    • If total == target: return [left + 1, right + 1].
    • If total < target: increment left.
    • If total > target: decrement right.

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 that i != j, i != k, j != k, and nums[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

  1. Initialize result as a set.
  2. For every trio of indices i < j < k:
    • If nums[i] + nums[j] + nums[k] == 0, add tuple(sorted([nums[i], nums[j], nums[k]])) to result.
  3. 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

  1. Sort nums.
  2. For i from 0 to n - 3:
    • Skip if nums[i] == nums[i - 1] and i > 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, advance left and retreat right, then skip duplicates on both sides.
      • If sum < target: increment left.
      • If sum > target: decrement right.
  3. 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 nums of n integers, return an array of all the unique quadruplets [nums[a], nums[b], nums[c], nums[d]] such that a, b, c, d are distinct indices and nums[a] + nums[b] + nums[c] + nums[d] == target.

Example 1: Input: nums = [1,0,-1,0,-2,2], target = 0 Output: [[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]

Example 2: Input: nums = [2,2,2,2,2], target = 8 Output: [[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

  1. Initialize result as a set.
  2. Use four nested loops over indices a < b < c < d.
  3. If the four values sum to target, add the sorted tuple to result.
  4. Convert result to 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

  1. Sort nums.
  2. For i from 0 to n - 4:
    • Skip if i > 0 and nums[i] == nums[i - 1].
    • For j from i + 1 to n - 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, advance left/retreat right, skip duplicates.
        • If total < target: increment left.
        • If total > target: decrement right.
  3. 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 by k steps, where k is non-negative. Do this in-place with O(1) extra space.

Example 1: Input: nums = [1,2,3,4,5,6,7], k = 3 Output: [5,6,7,1,2,3,4]

Example 2: Input: nums = [-1,-100,3,99], k = 2 Output: [3,99,-1,-100]

Constraints:

  • 1 <= nums.length <= 10^5
  • -2^31 <= nums[i] <= 2^31 - 1
  • 0 <= 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) with k % 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

  1. Compute n = len(nums) and k = k % n.
  2. Create rotated = [0] * n.
  3. For each i, set rotated[(i + k) % n] = nums[i].
  4. Copy rotated back into nums.

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:

  1. Reversing the entire array.
  2. Reversing the first k elements.
  3. Reversing the last n - k elements.

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

  1. Compute k = k % n to handle k >= n.
  2. Define reverse(nums, left, right) — swap elements inward until left >= right.
  3. Call reverse(nums, 0, n - 1) — reverse entire array.
  4. Call reverse(nums, 0, k - 1) — reverse first k elements.
  5. Call reverse(nums, k, n - 1) — reverse last n - k elements.

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 height of length n. There are n vertical lines drawn such that the two endpoints of the i-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: 49

Example 2: Input: height = [1,1] Output: 1

Constraints:

  • n == height.length
  • 2 <= n <= 10^5
  • 0 <= 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

  1. Initialize max_water = 0.
  2. For each pair (i, j) with i < j:
    • Compute water = min(height[i], height[j]) * (j - i).
    • Update max_water = max(max_water, water).
  3. 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

  1. Initialize left = 0, right = n - 1, max_water = 0.
  2. While left < right:
    • Compute water = min(height[left], height[right]) * (right - left).
    • Update max_water.
    • If height[left] <= height[right]: increment left.
    • Else: decrement right.
  3. 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 people where people[i] is the weight of the i-th person, and an infinite number of boats where each boat can carry a maximum weight of limit. Each boat carries at most two people at the same time, provided the sum of the weight of those people is at most limit.

Return the minimum number of boats to carry every given person.

Example 1: Input: people = [1,2], limit = 3 Output: 1

Example 2: Input: people = [3,2,2,1], limit = 3 Output: 3

Example 3: Input: people = [3,5,3,4], limit = 5 Output: 4

Constraints:

  • 1 <= people.length <= 5 * 10^4
  • 1 <= 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

  1. Sort people.
  2. Use a used set to track who has boarded.
  3. 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.
  4. 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

  1. Sort people.
  2. Initialize left = 0, right = len(people) - 1, boats = 0.
  3. While left <= right:
    • If people[left] + people[right] <= limit: increment left (both board together).
    • Decrement right (heaviest person always boards).
    • Increment boats.
  4. 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 n non-negative integers representing an elevation map where the width of each bar is 1, 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: 6

Example 2: Input: height = [4,2,0,3,2,5] Output: 9

Constraints:

  • n == height.length
  • 1 <= n <= 2 * 10^4
  • 0 <= 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

  1. Initialize total = 0.
  2. For each index i from 0 to n - 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] to total.
  3. 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

  1. Build max_left: scan left to right, max_left[i] = max(max_left[i-1], height[i]).
  2. Build max_right: scan right to left, max_right[i] = max(max_right[i+1], height[i]).
  3. For each i, add min(max_left[i], max_right[i]) - height[i] to total.

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

  1. Initialize left = 0, right = n - 1, max_left = 0, max_right = 0, total = 0.
  2. While left <= right:
    • If max_left <= max_right:
      • Update max_left = max(max_left, height[left]).
      • Add max_left - height[left] to total.
      • Increment left.
    • Else:
      • Update max_right = max(max_right, height[right]).
      • Add max_right - height[right] to total.
      • Decrement right.
  3. 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 list solve this?
  • Do I need a set for membership?
  • Do I need a dict for lookup?
  • Is a tuple the cleanest key or state representation?

SQL Notes

Browser execution note: Most SELECT queries in this section can now run directly in the browser against an in-memory SQLite database with preloaded sample data such as customers, orders, and Sales.* tables. SQL Server setup scripts and T-SQL-specific syntax are still reference-only. That includes commands and features like GO, USE, CREATE DATABASE, ALTER DATABASE, DROP DATABASE, and some SQL Server-only functions such as FORMAT().

Table of Content

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, and Sales.OrdersArchive. SQL Server database bootstrap scripts and T-SQL-specific syntax remain reference-only. Examples using GO, 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:

  1. FROM (and joins – later)
  2. WHERE
  3. GROUP BY
  4. Aggregates (e.g., SUM, AVG, COUNT)
  5. HAVING
  6. SELECT (columns, expressions, aliases)
  7. DISTINCT
  8. ORDER BY
  9. TOP / LIMIT (engine-dependent)

Why this matters:

  • You cannot use aggregates in WHERE (they don’t exist yet).
  • HAVING filters groups, after GROUP BY.
  • Some aliases from SELECT can be used in ORDER BY but not in WHERE.

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

CategoryNamePurposeExamples
DQLData Query LanguageRead/query dataSELECT
DDLData Definition LanguageDefine/change table structureCREATE, ALTER, DROP
DMLData Manipulation LanguageInsert/update/delete table rowsINSERT, 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

CategoryFunctionDescriptionExample
StringLOWER()Convert text to lowercaseLOWER(country)
AggregateSUM()Add up numeric valuesSUM(score)
AggregateCOUNT()Count rows / non-NULL valuesCOUNT(id)
AggregateAVG()Average numeric valuesAVG(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 from customers.
  • 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 score from 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 up score for 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_name is neither:

    • in GROUP BY, nor
    • inside an aggregate like SUM.
  • SQL doesn’t know which first_name to 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;
  • WHERE limits rows before grouping.
  • HAVING limits 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 TOP with ORDER BY if 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 TABLE removes 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' where id INT is 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;
  • DELETE without WHERE deletes all rows.
  • TRUNCATE TABLE is 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

  1. Forgetting WHERE in UPDATE / DELETE

    • Risk: change or delete all rows.
    • Tip: Always test with SELECT first, then turn it into UPDATE/DELETE.
  2. Mismatched columns and values in INSERT

    • Number and order must line up.
    • Tip: Always specify the column list explicitly.
  3. Wrong data types in INSERT

    • Example: string 'Max' into an INT column.
    • Tip: Check CREATE TABLE definitions.
  4. Using = with NULL

    • Wrong: WHERE score = NULL (always false).
    • Correct: WHERE score IS NULL or IS NOT NULL.
  5. SELECT with GROUP BY – non-aggregated columns

    • Error when you select a column not in GROUP BY or an aggregate.
    • Rule: every selected column must be grouped or aggregated.
  6. Using HAVING for row-level filtering

    • Row conditions (e.g., score != 0) belong in WHERE, not HAVING.
  7. Misunderstanding BETWEEN boundaries

    • BETWEEN 100 AND 500 includes both 100 and 500.
  8. Relying on TOP without ORDER BY

    • Without ORDER BY, the “top 3” could be random-ish.

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 to GROUP BY or 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 NULL column. ✱ 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

  1. SQL Categories – “Q, D, M”:

    • Query → DQLSELECT
    • Define → DDLCREATE, ALTER, DROP
    • Modify → DMLINSERT, UPDATE, DELETE
  2. SELECT Clause Order – “SF WOGHD” → “See Funny WOGH Dogs”:

    • SELECT
    • FROM
    • WHERE
    • ORDER BY
    • GROUP BY
    • HAVING
    • (DISTINCT, TOP are usually next to SELECT)
  3. Filtering Operators – “CLRMS” (think “colors”):

    • Comparison (=, >, <, <> …)
    • Logical (AND, OR, NOT)
    • Range (BETWEEN)
    • Membership (IN, NOT IN)
    • Search (LIKE)
  4. LIKE Wildcards – “Percent = many, Underscore = one”:

    • % → any number of characters.
    • _ → exactly one character.
  5. 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

  1. What are the three main categories of SQL commands and which verbs belong to each?
  2. What is the difference between DDL and DML?
  3. What is a primary key and why is it important?

SELECT & Filtering

  1. Write a query to get first_name and country from customers where score is greater than 500.
  2. How do you sort customers by country ascending and then by score descending?
  3. What’s the difference between WHERE and HAVING? When do you use each?

Aggregation

  1. Write a query to calculate the total score and number of customers per country.
  2. Modify that query to only show countries with total score > 1000. Should that filter be in WHERE or HAVING?

DDL / DML

  1. Write a CREATE TABLE statement for a simple products table with id, product_name, and price.
  2. How do you add an email column to the persons table?
  3. How do you change the score of customer with ID 6 to 0?
  4. How do you delete all customers with id > 5?

Filtering Operators

  1. Write a query to get all customers whose score is between 100 and 500 (inclusive).
  2. Write a query to get all customers from either Germany or USA using IN.
  3. Write a query to get all customers whose first name starts with M and ends with a.
  4. How do you find all customers where country is 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 WHERE to HAVING or vice versa?”

8.3 Practice Checklist (Level 1–3)

Level 1 – Basics

  • Write 5 SELECT queries on customers:

    • 2 with WHERE,
    • 1 with ORDER BY,
    • 1 with DISTINCT,
    • 1 with TOP.
  • For each operator (=, <>, >, <, BETWEEN, IN, LIKE), write one example.

Level 2 – Aggregation

  • Write 3 GROUP BY queries on customers:

    • Total score per country,
    • Average score per country (excluding score = 0),
    • Number of customers per country.
  • For each, add a HAVING condition and explain in plain words what it does.

Level 3 – DDL & DML

  • Create your own table (e.g., books or movies) using CREATE TABLE.

  • Insert at least 5 rows (include one intentional mistake, then fix it).

  • Write:

    • 1 UPDATE with a WHERE that affects 1 row,
    • 1 DELETE with a WHERE that affects a few rows,
    • 1 TRUNCATE TABLE (on a test table only).

9. Very Short Recap

9.1 Key Takeaways (Bullet Recap)

  1. Databases live on a server and store data in tables made of rows and columns.
  2. SQL commands are grouped into DQL (SELECT), DDL (CREATE, ALTER, DROP), and DML (INSERT, UPDATE, DELETE).
  3. SELECT is built with clauses like FROM, WHERE, GROUP BY, HAVING, ORDER BY, plus extras like DISTINCT and TOP.
  4. WHERE filters individual rows; HAVING filters groups after aggregation.
  5. ORDER BY controls sort order; always combine TOP with ORDER BY for meaningful “top N” queries.
  6. GROUP BY + aggregates (SUM, AVG, COUNT) let you summarize data per group.
  7. DDL defines the structure of your tables; DML changes the data in them.
  8. Common pitfalls: missing WHERE in UPDATE/DELETE, mismatched columns/values in INSERT, using = with NULL, violating GROUP BY rules.
  9. Logical execution order (FROM → WHERE → GROUP BY → Aggregates → HAVING → SELECT → DISTINCT → ORDER BY → TOP) explains many rules and errors.
  10. Active recall questions + regular practice on a small table like customers will 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

  1. Comments in SQL
  2. SELECT All Columns
  3. SELECT Specific Columns
  4. WHERE Clause (Filtering Rows)
  5. ORDER BY (Sorting Results)
  6. GROUP BY (Aggregating Data)
  7. HAVING (Filtering Groups)
  8. DISTINCT (Unique Values)
  9. TOP (Limiting Rows)
  10. Putting It All Together
  11. “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 from customers.
  • SELECT * FROM orders → same idea for orders.

🧠 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 where score is not zero.
  • country = 'Germany' → keep rows where country is 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.).
  • 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
  • WHERE filters rows before grouping.
  • HAVING filters 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 country returns 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, TOP just 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):

  1. FROM customers
  2. WHERE score != 0 → filter rows.
  3. GROUP BY country → group remaining rows by country.
  4. AVG(score) → compute average for each country.
  5. HAVING AVG(score) > 430 → keep only groups with high averages.
  6. SELECT country, AVG(score) AS avg_score
  7. ORDER 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_type with 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

  1. Write a single-line comment that says: Get all German customers.

    💡 Answer
        -- Get all German customers
        SELECT *
        FROM customers
        WHERE country = 'Germany';
    
  2. 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;
    
  3. 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

  1. Write a query to retrieve all columns from the customers table.

    💡 Answer
     -- 1) All customers
     SELECT *
     FROM customers;
    
  2. Write a query to retrieve all columns from the orders table.

    💡 Answer
    -- 2) All orders
    SELECT *
    FROM orders; 
    

3. SELECT Specific Columns

✅ Exercises

  1. Write a query that returns only first_name and country from customers.

    💡 Answer
    SELECT
    first_name,
    country
    FROM customers; 
    
  2. Write a query that returns id, first_name, and score from customers.

    💡 Answer
    SELECT
        id,
        first_name,
        score
    FROM customers;
    

4. WHERE Clause (Filtering Rows)

✅ Exercises

  1. Retrieve all customers whose score is exactly 0.

    💡 Answer
    SELECT
        *
    FROM customers
    WHERE score = 0;
    
  2. Retrieve all customers not from 'Germany'.

    💡 Answer
    SELECT
        *
    FROM customers
    WHERE country != 'Germany';
    
  3. Retrieve all customers from 'USA' with a score greater than 500.

    💡 Answer
    SELECT
        *
    FROM customers
    WHERE country = 'USA'
      AND score > 500;
    
  4. Retrieve only first_name and country for customers with score not equal to 0.

    💡 Answer
    SELECT
        first_name,
        country
    FROM customers
    WHERE score != 0;
    

5. ORDER BY (Sorting Results)

✅ Exercises

  1. List all customers ordered by first_name alphabetically (A→Z).

    💡 Answer
    SELECT
        *
    FROM customers
    ORDER BY first_name ASC;
    
  2. List all customers sorted by country (A→Z) and then by score from highest to lowest.

    💡 Answer
    SELECT
        *
    FROM customers
    ORDER BY country ASC,
             score DESC;
    
  3. Show only first_name, country, score for customers where score != 0, sorted by score from highest to lowest.

    💡 Answer
    SELECT
        first_name,
        country,
        score
    FROM customers
    WHERE score != 0
    ORDER BY score DESC;
    
  4. Retrieve all customers ordered by score from lowest to highest.

    💡 Answer
    SELECT
        *
    FROM customers
    ORDER BY score ASC;
    

6. GROUP BY (Aggregating Data)

✅ Exercises

  1. For each country, calculate the total score of its customers. Return country and total_score.

    💡 Answer
    -- 1) Total score per country
    SELECT
        country,
        SUM(score) AS total_score
    FROM customers
    GROUP BY country;
    
  2. For each country, show both:

    • the total score
    • the number of customers Return country, total_score, and total_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;
    
  3. 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

  1. 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;
    
  2. Same as above, but ignore customers whose score is 0 when 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;
    
  3. True/False (then justify):

    • “You can use HAVING instead of WHERE to 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.
    

8. DISTINCT (Unique Values)

✅ Exercises

  1. Get a list of all distinct countries that appear in the customers table.

    💡 Answer
    -- 1) Unique list of countries
    SELECT DISTINCT country
    FROM customers;
    
  2. 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).
    
  3. Suppose you add another column in the future (e.g., city). What happens if you run SELECT 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

  1. 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;
    
  2. 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;
    
  3. Retrieve the lowest 2 customers based on score.

    💡 Answer
    -- 3) Lowest 2 by score
    SELECT TOP 2 *
    FROM customers
    ORDER BY score ASC;
    
  4. 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

  1. Using customers, write a query that:

    • Ignores rows where score = 0,
    • Groups by country,
    • Computes AVG(score) as avg_score,
    • Only keeps countries where avg_score > 430,
    • Sorts results by avg_score from 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;
    
  2. 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;
    
  3. 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

  1. 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;
    
  2. Select the constant number 999 and give it the alias magic_number.

    💡 Answer
    -- 2) Constant number with alias
    SELECT 999 AS magic_number;
    
  3. Select the constant string 'VIP' as customer_type alongside id and first_name from customers.

    💡 Answer
    -- 3) Constant string column with other columns
    SELECT
        id,
        first_name,
        'VIP' AS customer_type
    FROM customers;
    
  4. 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:

  1. CREATE - Creating Tables
  2. ALTER - Modifying Table Structure
  3. 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:

  1. INSERT - Adding Data to Tables
  2. UPDATE - Modifying Existing Data
  3. 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:

  1. Comparison Operators : =, <>, >, >=, <, <=
  2. Logical Operators: AND, OR, NOT
  3. Range Filtering: BETWEEN
  4. Set Filtering: IN
  5. 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:

  1. JOINS – glue tables side by side (add columns).
  2. 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 typeWhat rows do you get?Does table order matter?Typical use
No joinSeparate results from each table (two independent SELECTs).N/AJust view data from each table, no relationship.
INNER JOINOnly 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 JOINAll 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 JOINAll 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 JOINAll 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 JOINRows from left table with no match in right (using LEFT JOIN + WHERE B.key IS NULL).YesFind “only in A” rows, e.g. customers without orders.
RIGHT ANTI JOINRows from right table with no match in left.YesFind “only in B” rows, e.g. orders without customers.
FULL ANTI JOINRows that don’t match on either side (unmatched from A + unmatched from B).NoSee all inconsistencies between two tables.
CROSS JOINAll combinations of rows from A and B (Cartesian product).N/ARare, 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).

OperatorEffect (set theory)Duplicates?
UNIONAll rows from both sets, duplicates removed (like mathematical union).Removed.
UNION ALLAll rows from both sets, keeping duplicates.Kept.
EXCEPTRows in first set that are not in second (“A minus B”).Unique results.
INTERSECTOnly 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.key equals B.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 BY inside each part (unless you wrap each SELECT in parentheses / subqueries); by default ORDER BY goes 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 id appears as customer_id in orders.
  • 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_id and sales are 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_id doesn’t match a real customer.
  • If there’s no matching customer, c.id and c.first_name are 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 NULL filters 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.Orders is 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 SourceTable column to show where each row came from.
  • ORDER BY appears once at the end.

6. Common mistakes & tips

6.1 Joins

  1. Forgetting the join condition (ON clause)

    • This effectively becomes a CROSS JOIN and multiplies rows.
    • Always check: “What keys am I matching on?”
  2. 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.
  3. Using RIGHT JOIN when a LEFT JOIN would do

    • Often clearer to flip tables and use LEFT JOIN (slide “Alternative To RIGHT JOIN”).
  4. 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.
  5. 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.
  6. Ambiguous column names in SELECT/WHERE/ORDER BY

    • When multiple tables have id or FirstName, always qualify: c.FirstName, o.OrderID.

6.2 Set operators

  1. 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.
  2. Wrong column order

    • The RULE: Column Order example shows LastName, CustomerID vs EmployeeID, LastName – this flips meaning of columns.
    • Always match columns by position and meaning.
  3. Relying on column names from second SELECT

    • Final column names come from the first SELECT (CustomerID AS ID → final column name is ID).
  4. Using ORDER BY in each SELECT

    • As slides note, ORDER BY can only be used once at the end of the combined result (unless you wrap subqueries).
  5. 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

  1. Q: In a LEFT JOIN, which table’s rows are always kept? A: The left table (primary source of data).

  2. Q: What does an INNER JOIN return? A: Only rows that have matching keys in both tables.

  3. 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.

  4. Q: In a FULL JOIN, what types of rows appear? A: Matching rows, plus unmatched rows from each side with NULLs.

  5. Q: Which set operator returns rows that are in the first result but not the second? A: EXCEPT.

  6. Q: Who “owns” the column names in a UNION result? A: The first SELECT.

  7. Q: Where should ORDER BY appear 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.

  1. Customers without orders

    • Write a query that returns all columns from customers for those who never placed an order.
  2. Orders without customers

    • Using the RIGHT ANTI JOIN pattern or its LEFT JOIN alternative, list all orders that don’t have a matching customer.
  3. Orders with full details

    • Recreate the 4-table query: Orders + Customers + Products + Employees, showing the columns from the script.
  4. Inner join via LEFT join

    • Rewrite the customers–orders INNER JOIN using LEFT JOIN + WHERE IS NOT NULL.
  5. Cross join

    • Write a query that generates all combinations of customers and orders (just SELECT *).

8.3 Set operator exercises

  1. Employees who are not customers

    • Write the EXCEPT query using Sales.Employees and Sales.Customers.
  2. People who are both employees and customers

    • Write the INTERSECT query.
  3. Combine Customers and Employees into a single de-duplicated list of names

    • Use UNION.
  4. Combine Orders and OrdersArchive into one report

    • Include a SourceTable column and order by OrderID, as in the script.
  5. 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: CustomerID vs FirstName.)

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 NULL and 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 BY in 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

  2. Advanced Joins

  3. Multiple Table Joins (4 Tables)


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
  2. UNION
  3. UNION ALL
  4. EXCEPT
  5. INTERSECT

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
  • CASE expressions (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

ConceptMeaning
FunctionReusable operation that takes input(s) and returns a value. E.g. LOWER('Maria') → 'maria'.
Single-row functionReturns one result per row (e.g. LEN(first_name) for each customer).
Multi-row / aggregate functionCombines multiple rows into one result (e.g. SUM(Sales)); mentioned but not the focus here.
Nested functionsUsing the output of one function as input to another, e.g. LEN(LOWER(LEFT('Maria',2))).
String functionsWork on text: CONCAT, UPPER, LOWER, TRIM, REPLACE, LEN, LEFT, RIGHT, SUBSTRING.
Numeric functionsWork on numbers: ROUND, ABS.
Date & time functionsGet or transform dates/times: GETDATE, DATEPART, DATENAME, DATETRUNC, YEAR/MONTH/DAY, EOMONTH, FORMAT, CONVERT, CAST, DATEADD, DATEDIFF, ISDATE.
NULL functionsFunctions and patterns for missing values: ISNULL, COALESCE, NULLIF, IS NULL, IS NOT NULL.
CASE expressionConditional 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)
  • FORMAT uses .NET-style format codes (e.g. 'dd/MM/yyyy', 'N2').
  • CONVERT uses style numbers (e.g. 23, 101).
  • CAST is 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;
  • CONCAT joins first_name and country with a hyphen.
  • Unlike +, CONCAT safely 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));
  • TRIM cleans 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 using LEN() 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 LOWER then UPPER.
  • 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.52
  • ROUND(3.516, 1)3.5
  • ROUND(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;
  • ABS removes 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 / MONTH for easy grouping; DATENAME for 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 LAG for 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 comparing NULL, 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;
  • CASE inside SUM counts only rows where condition holds.
  • Very common pattern for “conditional counts”.

6. Common mistakes & tips

  1. Using = NULL instead of IS NULL

    • Wrong: WHERE Score = NULL
    • Right: WHERE Score IS NULL
  2. Forgetting to TRIM before LEN, LEFT/RIGHT, or comparisons

    • ' John ''John'. Use TRIM(first_name) for cleaner logic.
  3. Losing NULL handling in concatenation

    • FirstName + ' ' + LastName becomes NULL if LastName is NULL.
    • Use COALESCE(LastName, '') or CONCAT(FirstName, ' ', LastName).
  4. Interpreting FORMAT output as dates/numbers

    • FORMAT returns strings; don’t use formatted values for numeric sorting or date math. Use FORMAT only in SELECT for display.
  5. 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.
  6. Dividing by 0 without NULLIF

    • Always guard denominators that may be 0: value / NULLIF(den, 0).
  7. Not distinguishing NULL vs empty vs spaces

    • Treat ' ', '', and NULL separately if needed; use TRIM, NULLIF, COALESCE.
  8. 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.

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

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

  1. Write a query that outputs FirstName and a column Initial with only the first character (no leading spaces).
  2. How would you detect rows in customers where first_name has leading/trailing spaces?
  3. What is the difference between SUBSTRING(first_name, 2, 3) and RIGHT(first_name, 3)?

8.2 Numeric functions

  1. What does ROUND(3.516, 1) return? What about ROUND(3.516, 0)?
  2. Give an example where ABS() is helpful in a report.

8.3 Date & time

  1. Write a query to show, for each order, OrderDate and the year start date of that order (use DATETRUNC).
  2. How do DATEPART(month, OrderDate) and DATENAME(month, OrderDate) differ?
  3. How can you count how many orders were placed in each calendar year using YEAR()?
  4. Format CreationTime as "Wed 20-08-2025" using FORMAT.
  5. Convert CreationTime to DATE using CONVERT and also using CAST.
  6. Write a query to show OrderDate, ShipDate, and the number of days between them.
  7. How would you find only the rows where a text column might be an invalid date string, using ISDATE?

8.4 NULL handling

  1. Using Sales.Customers, show FullName composed of FirstName + space + LastName, even if LastName is NULL.
  2. What’s the difference between COALESCE(Score, 0) and ISNULL(Score, 0) conceptually?
  3. How can you safely compute Sales / Quantity when Quantity might be 0?
  4. Write an ORDER BY that sorts by Score ascending, but puts NULL scores last.
  5. In the NULL vs empty vs blank example, what does NULLIF(TRIM(Category), '') do?

8.5 CASE

  1. Write a CASE expression that labels each order as 'Big' when Sales > 100, 'Medium' when Sales between 50 and 100, else 'Small'.
  2. Convert country names 'Germany' and 'USA' to codes 'DE' and 'US' using quick form CASE.
  3. How would you use CASE inside SUM() to count how many orders have Sales > 30 per 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

  2. Calculation

  3. Substring Extraction

  4. Nesting Functions


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_name
  • len_name = length of first_name
  • len_trim_name = length of TRIM(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

  2. Absolute Value Function


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 as original_number)

  • Rounds it to:

    • 2 decimal places (alias: round_2)
    • 1 decimal place (alias: round_1)
    • 0 decimal places (alias: round_0)

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 -10 as original_number
  • Returns the absolute value of -10 as absolute_value_negative
  • Returns the absolute value of 10 as absolute_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

  1. GETDATE | Date Values
  2. Date Part Extractions (DATETRUNC, DATENAME, DATEPART, YEAR, MONTH, DAY)
  3. DATETRUNC – Data Aggregation
  4. EOMONTH
  5. Date Parts | Use Cases
  6. FORMAT
  7. CONVERT
  8. CAST
  9. DATEADD / DATEDIFF
  10. 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:

  • DATETRUNC for year, day, and minute
  • DATENAME for month, weekday, day, year
  • DATEPART for year, month, day, hour, quarter, week
  • YEAR, 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' as USA_Format
  • 'dd-MM-yyyy' as EURO_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' to INT
  • Convert '2025-08-20' to DATE
  • Convert CreationTime to DATE
  • Convert CreationTime to VARCHAR with 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' to INT
  • 123 to VARCHAR
  • '2025-08-20' to DATE and DATETIME2
  • CreationTime to DATE
💡 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:

  • OrderDate
  • TenDaysBefore = 10 days before OrderDate
  • ThreeMonthsLater = 3 months after OrderDate
  • TwoYearsLater = 2 years after OrderDate

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:

  • CurrentOrderDate
  • PreviousOrderDate
  • NrOfDays = 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 DATE and otherwise set them to '9999-01-01' in NewOrderDate

(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
  2. Date Format Specifiers
  3. All Date Parts
  4. All Culture Formats

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-DE and en-US

The result should show two columns:

  • FormatType – the format specifier used
  • FormattedValue – the output of FORMAT(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 to FORMAT
  • FormattedValue – the result of FORMAT(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 ALL query.

💡 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 ALL pattern 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 part
    • DatePart_OutputDATEPART(<datepart>, GETDATE())
    • DateName_OutputDATENAME(<datepart>, GETDATE())
    • DateTrunc_OutputDATETRUNC(<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'
  • FormattedNumberFORMAT(1234567.89, 'N', CultureCode)
  • FormattedDateFORMAT(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

  2. Handle NULL – Mathematical Operators

  3. Handle NULL – Sorting Data

  4. NULLIF – Division by Zero

  5. IS NULL – IS NOT NULL

  6. LEFT ANTI JOIN

  7. NULL vs Empty String vs Blank Spaces


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:

  • CustomerID
  • Score
  • Score2 = Score with NULL replaced by 0 using COALESCE
  • AvgScores = average of Score (standard AVG, ignores NULLs)
  • AvgScores2 = average of Score2 (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, LastName
  • FullName = FirstName + ' ' + LastName, but handle NULL LastName
  • Score
  • ScoreWithBonus = 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:

  • CustomerID
  • Score

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:

  • OrderID
  • Sales
  • Quantity
  • Price = Sales / Quantity, but avoid division by zero using NULLIF

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 JOIN on CustomerID
  • 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:

  1. Id = 1, Category = 'A'
  2. Id = 2, Category = NULL
  3. Id = 3, Category = '' (empty string)
  4. 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
  2. Mapping
  3. Quick Form of CASE Statement
  4. Handling Nulls
  5. Conditional Aggregation

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 of Sales per 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, using CASE 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:

  • CustomerID
  • LastName
  • Score
  • ScoreClean = Score, but NULL replaced with 0 using CASE
  • AvgCustomerClean = window AVG() of ScoreClean
  • AvgCustomer = window AVG() of the original Score (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:

  • CustomerID
  • TotalOrdersHighSales = number of orders where Sales > 30
  • TotalOrders = 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).
  • WINDOWadd 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

ConceptMeaningNotes / 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 valuesNumbers only. NULLs are ignored.
AVG(expr)Average of numeric valuesNumbers only. NULLs ignored by default. Use COALESCE to treat NULLs as 0.
MIN(expr)Smallest valueAny comparable type.
MAX(expr)Largest valueAny comparable type.

3.2 GROUP BY vs Window functions

FeatureGROUP BYWindow functions (OVER)
Output row countDecreases (rows collapsed per group)Same as input (row-level)
PurposeSimple aggregation reportsAggregation + detailed rows
Typical use“Total sales per product”“Total sales per product on each row”, running totals, rankings
Syntax ideaSELECT group_col, SUM(x) FROM t GROUP BY group_colSELECT 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:

CategoryFunctionsWhat they do
Window aggregatesCOUNT, SUM, AVG, MIN, MAXOne summary value per row (over a window)
RankingROW_NUMBER, RANK, DENSE_RANK, NTILE, CUME_DIST, PERCENT_RANKAssign positions / buckets based on ordering
Value / analyticLAG, LEAD, FIRST_VALUE, LAST_VALUEPull 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_id becomes 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 BY version 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

  1. 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 same OrderID.
    • Filtering for > 1 returns only duplicated orders.
  2. 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.
  3. 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.

  1. 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;
    
  2. 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;
    
  3. 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.
  4. 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.

  1. 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;
    
    • LAG peeks at the previous month’s aggregated sales.
  2. 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.
  3. 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

  1. Using window functions in WHERE or GROUP 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.
  2. Nesting window functions

    • Slides explicitly say: “Not allowed to nest window functions!” and show AVG( SUM(Sales) OVER() ) OVER(...) as invalid.
  3. Confusing COUNT(*) vs COUNT(col)

    • COUNT(*) / COUNT(1) counts rows including NULLs.
    • COUNT(col) ignores rows where col is NULL (example table with Gloves NULL only counted by COUNT(*) in the slides).
  4. Forgetting that AVG ignores NULLs

    • Slides and script show using COALESCE(Score,0) to include NULLs in average.
  5. 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).
  6. 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.
  7. Expecting ROW_NUMBER to handle ties “nicely”

    • ROW_NUMBER always gives unique numbers → ties are broken arbitrarily by the ORDER BY.
    • Use RANK (with gaps) or DENSE_RANK (no gaps) when ties should share the same rank, as highlighted in the ranking diagrams.
  8. 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)

  1. “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.
  2. “P-O-F inside OVER” – Window clause order

    • PARTITION BY → groups.
    • ORDER BY → sequence.
    • FRAME → slice of that sequence.
  3. “RowNumber, Rank, DenseRank – 1, gap, no-gap”

    • ROW_NUMBER1-2-3-4… always unique.
    • RANK1-2-2-4 (gap after tie).
    • DENSE_RANK1-2-2-3 (no gap).
  4. 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).
  5. 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

  1. What is the difference between COUNT(*) and COUNT(Sales) when some Sales values are NULL?
  2. Write a query to show total sales per customer using GROUP BY.
  3. Why does GROUP BY usually produce fewer rows than the original table?

8.2 Window basics & PARTITION / ORDER / FRAME

  1. Rewrite “total sales per product” using a window function instead of GROUP BY so that each order row still appears.
  2. What does PARTITION BY ProductID, OrderStatus do compared to PARTITION BY ProductID alone?
  3. What is the effect of ORDER BY inside OVER when computing a running total?

8.3 Window aggregates

  1. How would you compute a moving average of Sales per product, ordered by OrderDate?
  2. How can you use window functions to detect duplicate OrderID values?
  3. How could you calculate, for each order, the percentage of total sales?

8.4 Ranking functions

  1. When should you choose ROW_NUMBER vs RANK vs DENSE_RANK? Give a scenario for each.
  2. How would you find the top 1 order per product using ROW_NUMBER?
  3. Explain what NTILE(4) OVER (ORDER BY Sales DESC) does.
  4. In the CUME_DIST price example, what does a value of 0.4 mean?

8.5 Value functions

  1. How can LAG be used to calculate month-over-month sales change?
  2. How would you compute the average days between orders per customer using LEAD?
  3. Why does LAST_VALUE often 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 BY collapses 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 WHERE and cannot be nested. Put them in a subquery if you need to filter by them.
  • COUNT(*) vs COUNT(col) and default AVG behaviour with NULLs are classic gotchas—always check how NULLs are treated.

SQL Aggregate Functions

Table of Contents

  1. Basic Aggregate Functions

  2. Grouped Aggregations – GROUP BY


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

  2. SQL Window OVER Clause

  3. SQL Window PARTITION Clause

  4. SQL Window ORDER Clause

  5. SQL Window FRAME Clause

  6. SQL Window Rules

  7. SQL Window with GROUP BY


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

  2. SUM

  3. AVG

  4. MAX / MIN

  5. ROLLING SUM & AVERAGE Use Case


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

  2. NTILE

  3. CUME_DIST


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 appear
  • DENSE_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 latest CreationTime is 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() over Price DESC as DistRank
  • keep only rows where DistRank <= 0.4
  • also show DistRank as 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

  2. FIRST_VALUE & LAST_VALUE – Extremes within a partition


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, and vm-1
  • sudo or 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:

  1. 01. Platform Overview Use this next for the concrete architecture, node layout, reference settings, and design rules.

  2. 02. Rebuild Cluster Step by Step Build the private network and the Kubernetes cluster itself.

  3. 06. Platform Services Step by Step Install ingress, the edge firewall guardrail, TLS automation, and Argo CD.

  4. 12. Data and Apps Step by Step Add PostgreSQL, Keycloak, and the application delivery pattern used by the homelab.

  5. 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:

  1. Build the private network and Kubernetes cluster.
  2. Add ingress, edge hardening, TLS, and GitOps.
  3. Add PostgreSQL, Keycloak, and the application delivery pattern.
  4. 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 Ready and 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-1 is the control-plane node. It is the main cluster administration machine.
  • wk-1 is the database worker. PostgreSQL is intentionally kept away from the public edge.
  • wk-2 is the GitOps worker. Argo CD is pinned there to keep its placement predictable.
  • ctb-edge-1 is the public edge node. In local SSH usage, this same machine is reached as vm-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

NodeRoleLocationLAN IPWireGuard IPNotes
ms-1control planehome192.168.15.2172.27.15.12K3s server and main admin host
wk-1workerhome192.168.15.3172.27.15.11PostgreSQL node
wk-2workerhome192.168.15.4172.27.15.13Argo CD node
ctb-edge-1edge workercloudn/a172.27.15.31public 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.eu
  • dev.kakde.eu
  • notebook.kakde.eu
  • argocd.kakde.eu
  • keycloak.kakde.eu
  • whoami.kakde.eu
  • whoami-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, and vm-1
  • all four machines run Ubuntu 24.04
  • you have sudo or root access on every node
  • your home router can forward UDP ports for WireGuard
  • vm-1 is the SSH name of the public cloud node that will appear in Kubernetes as ctb-edge-1

Use these reference addresses throughout the build:

NodePurposeLAN IPWireGuard IP
ms-1K3s server192.168.15.2172.27.15.12
wk-1worker192.168.15.3172.27.15.11
wk-2worker192.168.15.4172.27.15.13
vm-1 / ctb-edge-1public edge workern/a172.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/udp
  • 203.0.113.10:51821/udp -> ms-1:51820/udp
  • 203.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.conf points 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.12 as 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.12
  • wk-1 -> 172.27.15.11
  • wk-2 -> 172.27.15.13
  • ctb-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 label
  • wk-1: worker role label
  • wk-2: worker role label
  • ctb-edge-1: edge role label plus kakde.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.

  1. ctb-edge-1
  2. ms-1
  3. wk-1
  4. wk-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
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

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.2
    • wk-1: 192.168.15.3
    • wk-2: 192.168.15.4

Public cloud edge (Contabo)

  • ctb-edge-1: public IP 198.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/32
  • ms-1 = 172.27.15.12/32
  • wk-2 = 172.27.15.13/32
  • ctb-edge-1 = 172.27.15.31/32

Router UDP forwards (required for edge → home)

  • 203.0.113.10:51820 -> wk-1:51820
  • 203.0.113.10:51821 -> ms-1:51820
  • 203.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:

  1. a routing rule (“to reach this IP, send it to that peer”), and
  2. 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 = 1420 on 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.key must 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 show shows nothing (or “interface: wg0” does not exist yet)
  • No k3s services running
  • /etc/cni, /var/lib/cni, /etc/rancher, /var/lib/rancher are empty or absent (depending on how you wiped)
  • ip rule show contains 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.conf How 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
  • /32 routes 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-1 ping 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) via wg0

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 show shows 3 peers with handshakes
  • AllowedIPs everywhere are /32 only

2.6 Debug playbook

A) No handshake at all

  1. Check service and UDP listener:
systemctl status wg-quick@wg0 --no-pager
sudo ss -lunp | grep 51820
  1. Confirm the peer public keys are correct on both sides:
sudo wg show
  1. 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
  1. 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.

  1. Check routing decision:
ip route get 172.27.15.31
ip route get 172.27.15.12
  1. Confirm AllowedIPs are strictly /32:
sudo wg show
  1. Confirm rp_filter is loose mode:
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter
  1. Verify nothing is dropping ICMP:
sudo iptables -S
sudo iptables -L -nv
sudo nft list ruleset 2>/dev/null | sed -n '1,160p' || true
  1. 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)

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

  1. What this document is
  2. Architecture
  3. Concepts
  4. Phase 3 implementation steps
  5. Troubleshooting
  6. 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, LAN 192.168.15.2, WireGuard 172.27.15.12
  • wk-1 = worker, LAN 192.168.15.3, WireGuard 172.27.15.11
  • wk-2 = worker, LAN 192.168.15.4, WireGuard 172.27.15.13

Public edge node

  • ctb-edge-1 = Contabo public edge, public IP 198.51.100.25, WireGuard 172.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-1 becomes the single K3s server
  • wk-1, wk-2, and ctb-edge-1 become 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-1 is treated as control-plane only
  • ctb-edge-1 is 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 = disabled
  • 1 = strict
  • 2 = 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 = disabled
  • 1 = strict
  • 2 = 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.x address

  • The kernel/sysctl basics were already checked:

    • net.ipv4.ip_forward = 1
    • net.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.conf
  • nameserver 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-1
  • wk-1
  • wk-2
  • ctb-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/k3s if 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.12 tells K3s to register this node with the WireGuard IP
  • --advertise-address=172.27.15.12 tells the API server which address to advertise
  • --tls-san=172.27.15.12 allows the cert to include that address
  • --flannel-backend=none disables Flannel because Calico will replace it
  • --disable-network-policy disables K3s embedded network policy because Calico will provide policy
  • --disable=traefik prevents packaged Traefik from being installed
  • --disable=servicelb disables K3s ServiceLB because the design will use a custom ingress strategy later
  • --resolv-conf=... gives K3s/kubelet a safe resolver file
  • --cluster-cidr / --service-cidr set 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

  • k3s service is running
  • TCP 6443 is listening
  • ms-1 appears in kubectl get nodes
  • it may still be NotReady until 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-operator is running
  • calico-node is running on ms-1
  • ms-1 becomes Ready
  • vxlan.calico exists
  • 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/tcp on public interface
  • block 4789/udp on public interface
  • block 30000-32767 on 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-1 should not carry normal workloads
  • ctb-edge-1 should accept workloads only if they explicitly opt in
  • wk-1 and wk-2 remain 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-1 so regular workloads are not scheduled there
  • taint ctb-edge-1 so 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-1 shows a control-plane style NoSchedule taint
  • ctb-edge-1 shows homelab.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-node is running on all nodes
  • coredns is 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-1
  • ctb-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 6443 from 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-apiserver
  • calico-kube-controllers
  • calico-node on all nodes
  • calico-typha
  • csi-node-driver
  • tigera-operator
  • coredns

all in Running state.

4. All nodes are Ready

A healthy kubectl get nodes -o wide in the document showed:

  • ctb-edge-1 Ready, INTERNAL-IP 172.27.15.31
  • ms-1 Ready, INTERNAL-IP 172.27.15.12
  • wk-1 Ready, INTERNAL-IP 172.27.15.11
  • wk-2 Ready, INTERNAL-IP 172.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=traefik and then later fighting the packaged Traefik deployment (docs.k3s.io)
  • forgetting --disable-network-policy when replacing K3s netpol with Calico netpol (docs.k3s.io)
  • forgetting --resolv-conf even though /etc/resolv.conf points to 127.0.0.53 (docs.k3s.io)
  • assuming iptables commands persist after reboot
  • assuming the public interface is always eth0
  • using rp_filter=1 in this overlay design instead of the corrected baseline 2 (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:

  1. Traefik deployment is not part of this phase. The design decision is clear, but the actual deployment belongs to Phase 4.

  2. Public interface name on ctb-edge-1 must be confirmed. The firewall examples use eth0, but the real name may differ.

  3. 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.

  4. A stronger cross-node pod test can be added later. The simple kubectl run smoke 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 80 and 443
  • 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 traefik namespace with the ingress controller pinned to ctb-edge-1
  • an active host firewall guardrail on the public edge node
  • a cert-manager namespace with working Cloudflare-backed ClusterIssuers
  • an argocd namespace with Argo CD pinned to wk-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-1 and vm-1 refer 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-1 exists and is tainted with kakde.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/tcp from your admin IP only
  • 80/tcp
  • 443/tcp
  • 51820/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/tcp is reachable only from approved admin source IPs
  • 80/tcp and 443/tcp are open
  • 51820/udp is open
  • 10250/tcp is 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 80 and 443 should belong to Traefik
  • 51820/udp belongs to WireGuard
  • Traefik may still expose its internal dashboard/API on 8080 locally
  • 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-manager namespace 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-dns01
  • letsencrypt-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-2 with workload=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 Application objects

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.io exists
  • the ingress for argocd.kakde.eu exists

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.eu
  • kakde.eu
  • notebook.kakde.eu
  • keycloak.kakde.eu once 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.eu ingress 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

  1. Goal and architecture
  2. Key concepts
  3. Design decisions
  4. Step-by-step deployment
  5. Validation checklist
  6. Troubleshooting
  7. 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 80 and 443
  • 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.12
  • wk-1 → K3s agent → 172.27.15.11
  • wk-2 → K3s agent → 172.27.15.13
  • ctb-edge-1 → public edge node + K3s agent → 172.27.15.31
  • public IP of ctb-edge-1198.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:

  • 22
  • 80
  • 443

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 80 and 443
  • 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:NoSchedule was 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:

  1. snapshot the edge node
  2. make sure ports 80 and 443 are free
  3. install the edge firewall guardrail
  4. label and taint the edge node properly
  5. deploy Traefik pinned to the edge
  6. capture logs if it fails
  7. fix privileged port binding
  8. fix rollout deadlock caused by host ports
  9. remove the unnecessary admin port
  10. 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 80 or 443.

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 input shows the rules
  • systemctl is-enabled returns enabled
  • systemctl is-active returns active.

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=true label exists
  • kakde.eu/edge=true:NoSchedule taint exists
  • homelab.kakde.eu/edge taint 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:

  1. capture events
  2. capture current logs
  3. capture previous logs
  4. 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 denied crash is gone.

Why this works:

  • drop: ["ALL"] removes unnecessary privileges
  • add: ["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 Redirect to 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 80 and 443
  • 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 9000 is 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 -f as 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-1 has kakde.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_SERVICE to 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:

  1. deploy whoami
  2. expose it through a ClusterIP service
  3. create an Ingress for whoami.kakde.eu
  4. validate HTTP routing
  5. 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
  • whoami returns 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

  1. Overview
  2. Architecture
  3. Design decisions
  4. Phase 4 workflow (end-to-end)
  5. Validation checklist
  6. Troubleshooting
  7. Glossary
  8. 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 (*:80 and *:443).

  • HTTP requests redirect to HTTPS (308 redirect).

  • A demo service whoami is deployed inside Kubernetes:

    • Exposed through Traefik with an Ingress for whoami.kakde.eu.
  • 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
  • ms-1 (home LAN, K3s server)

    • Runs the Kubernetes control-plane (K3s server)
    • Used for kubectl management
  • (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-*.json when using Traefik ACME).

5. Phase 4 workflow (end-to-end)

Prerequisites

  • You have working kubectl access (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 *:80 and *:443.

Confirm HTTP redirects to HTTPS

curl -I http://127.0.0.1/

Expected result:

  • HTTP/1.1 308 Permanent Redirect with a Location: 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), or
    • Recreate (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 tcpSocket rather 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:

  • whoami pod is Running
  • 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 Redirect to 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:

  • 404 on 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

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-dns01
    • server: 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 -lntp shows Traefik on *:80 and *:443
  • curl -I http://127.0.0.1/ returns 308 redirect to HTTPS

whoami routing validation

On ctb-edge-1:

  • curl -k -H 'Host: whoami.kakde.eu' https://127.0.0.1/ returns 200 and 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 pods all Running
  • kubectl get clusterissuer shows issuers present
  • kubectl -n apps get certificate shows Ready=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).
  • whoami was 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.25 exists.
  • 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.


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

  1. Overview
  2. What This Document Covers
  3. Goal
  4. Current Cluster Context
  5. Important Design Decisions
  6. Concepts Explained for Beginners
  7. Step-by-Step Implementation
  8. Validation Checklist
  9. Troubleshooting
  10. Mistakes and Corrections
  11. Operational and Security Notes
  12. Next Steps
  13. Glossary
  14. 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-2 node
  • 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-1 is the main server node where kubectl commands were being run

  • wk-2 is the node selected to host the Argo CD workloads

  • Traefik is the ingress controller

  • existing applications such as notebook and portfolio already use:

    • Ingress
    • ingressClassName: traefik
    • cert-manager
    • TLS secrets managed through cert-manager
  • DNS already points argocd.kakde.eu to 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: traefik
  • cert-manager.io/cluster-issuer: letsencrypt-prod-dns01
  • Traefik annotations for websecure and 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-1
  • wk-1
  • wk-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-server service

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:

  1. verify the cluster and nodes
  2. label wk-2
  3. install Argo CD into the argocd namespace
  4. confirm Argo CD pods started
  5. patch Argo CD workloads with nodeSelector
  6. restart them so they move to wk-2
  7. verify all pods now run on wk-2
  8. patch argocd-cmd-params-cm to set server.insecure: "true"
  9. restart argocd-server
  10. inspect existing notebook ingress
  11. create a matching Argo CD ingress
  12. verify DNS, certificate issuance, ingress health, and external access
  13. retrieve the initial admin password
  14. 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 wide shows cluster nodes and more details such as IPs
  • kubectl get pods -A shows pods in all namespaces

Good result

  • all expected nodes are present
  • wk-2 is in Ready state
  • 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=argocd on node wk-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 argocd namespace
  • 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-server
  • argocd-repo-server
  • argocd-dex-server
  • argocd-redis
  • argocd-applicationset-controller
  • argocd-notifications-controller
  • argocd-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: traefik
  • 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"
  • 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:

  • 200
  • 301
  • 302

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 password field
  • 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

  • argocd namespace exists
  • deployments and statefulset exist
  • services exist

Scheduling

  • wk-2 has label workload=argocd
  • Argo CD workloads contain nodeSelector
  • Argo CD pods are running on wk-2

Server configuration

  • argocd-cmd-params-cm contains server.insecure: "true"
  • argocd-server restarted 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.eu succeeds

Access

  • browser opens Argo CD UI
  • initial admin password can be retrieved
  • CLI works through --grpc-web or 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.eu should 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 admin password 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-dns01 is already working
  • DNS for argocd.kakde.eu is 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:

  1. log into the UI
  2. change the admin password
  3. connect a Git repository
  4. create the first Argo CD Application
  5. optionally create an app-of-apps bootstrap structure
  6. define projects, RBAC, and repo credentials
  7. 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:


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-server internal TLS: disabled with server.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/tcp for administration
  • 80/tcp and 443/tcp for web traffic
  • 51820/udp for 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-dev and apps-prod already 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 exec into the PostgreSQL pod
  • kubectl port-forward to the PostgreSQL service
  • an SSH tunnel to ms-1 plus a remote kubectl 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 work
  • whoami-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 resources
  • overlays/dev/ for development-specific changes
  • overlays/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:

  1. update the production overlay in the repo
  2. commit the change
  3. 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.yaml
  • k8s-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:

  1. copy an existing app layout such as k8s-cluster/apps/notebook/ or k8s-cluster/apps/portfolio/
  2. adapt the base/ resources
  3. create dev and prod overlays
  4. add a matching Argo CD Application under k8s-cluster/platform/argocd/applications/
  5. 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

  1. Overview
  2. What was built
  3. Design decisions
  4. Step-by-step deployment
  5. Manifest reference
  6. Validation and testing
  7. Troubleshooting
  8. Connecting from a Mac
  9. Backup and restore
  10. 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: 80Gi on the local-path storage class
  • Internal-only access through a ClusterIP Service named postgresql
  • 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 appdb was created
  • the application user appuser could 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 server
  • wk-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:

  1. postgresql-hl A headless service used by the StatefulSet

  2. postgresql A 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-password
  • app-db-name
  • app-db-user
  • app-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 creation
  • 2-secret.yaml — credentials
  • 3-init-configmap.yaml — bootstrap script for first initialization
  • 4-services.yaml — internal service definitions
  • 5-networkpolicy.yaml — ingress restrictions
  • 6-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 permitted
  • chown: changing ownership of '/var/lib/postgresql/data/pgdata': Operation not permitted

The fix was to remove:

  • pod fsGroup
  • pod fsGroupChangePolicy
  • container securityContext that 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-password in 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-1 shows kakde.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-prod as allowed by the NetworkPolicy

Good looks like:

  • apps-prod shows kakde.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.yaml through 6-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_PASSWORD
  • APP_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 created or configured

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:

  • postgresql StatefulSet exists
  • pod postgresql-0 reaches Running
  • 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-0 is Bound
  • 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:

  • postgresql is a ClusterIP service on 5432
  • 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 = postgres
  • current_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 = appdb
  • current_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 TABLE
  • INSERT 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:

  • ContainerCreating
  • Running
  • Error
  • CrashLoopBackOff

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 securityContext is removed
  • fsGroup is removed
  • pod-level seccompProfile: RuntimeDefault is 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 psql running 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 ROLE
  • ALTER ROLE
  • CREATE DATABASE
  • ALTER DATABASE
  • REVOKE
  • GRANT
  • ALTER SCHEMA
  • GRANT

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:

  • psql connects 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_dump inside 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.dump appears

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.sql appears

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:

  1. Better day-to-day access from the Mac without port-forward

    • likely through a private WireGuard-based admin path
    • not implemented in this document
  2. More resilient storage

    • local-path is simple but node-local
    • a replicated storage system would improve resilience
  3. Password rotation runbook

    • the document explains the principle
    • a dedicated operational script could make it easier
  4. 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)


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 ClusterIP works. (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-path works 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

  1. Overview
  2. Key decisions and patterns
  3. Notebook app deployment
  4. Certificate troubleshooting
  5. Portfolio app deployment
  6. Reusable app template
  7. Troubleshooting guide
  8. Lessons learned
  9. 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 server
  • wk-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
  • prod overlay adds:

    • namespace apps-prod
    • prod hostname
    • production cert issuer
    • optional replica increase

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-dev
  • notebook-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 yaml creates a namespace manifest without directly applying it
  • kubectl 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

  • -k means 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-tls became ready

Prod:

  • notebook-kakde-eu-tls stayed False

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:

  • kubectl operations
  • 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

  1. Do not use nameSuffix: -dev in this cluster’s app pattern.

  2. Stable internal names are good, but only if dev and prod live in different namespaces.

  3. Render first, apply second.

  4. Old resources in apps can silently interfere with newer deployments.

  5. A broken production certificate is not always a Kubernetes problem.

  6. Cloudflare CAA settings can block Let’s Encrypt issuance even when manifests look correct.

  7. Testing Traefik locally requires the correct hostname, not just 127.0.0.1.

  8. This cluster specifically needed both Ingress class mechanisms:

    • spec.ingressClassName
    • kubernetes.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

  1. Portfolio app container port was treated as needing confirmation.
  2. Notebook app image and port were already known from the rendered manifests.
  3. apps-dev and apps-prod are the long-term application namespaces going forward.

Unresolved gaps

  1. The final portfolio manifests still needed the real image and confirmed port when this document ended.
  2. Database deployment had not yet begun; only a prompt for the next document was prepared.
  3. 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.


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 under k8s-cluster/platform/argocd/applications/. Treat any later deploy/... examples in this document as historical path names.

Table of contents

  1. Overview
  2. GitOps flow and architecture
  3. Design decisions
  4. Repository structure
  5. Step-by-step implementation
  6. Validation and verification
  7. Troubleshooting
  8. Design trade-offs
  9. Next steps
  10. 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 infra repository
  • Argo CD
  • Kubernetes
  • Kustomize overlays
  • the namespaces apps-dev and apps-prod

What this setup is trying to achieve

The final system should do this automatically:

  1. Code is pushed to the note-book repository.
  2. GitHub Actions builds a Docker image.
  3. GitHub Actions pushes that image to Docker Hub.
  4. GitHub Actions updates the image tag inside the infra repository.
  5. Argo CD watches the infra repository and notices that change.
  6. 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-dev
    • apps-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-dev for development workloads
  • apps-prod for 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 infra repo

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 manifests
  • overlays/dev customizes development deployment
  • overlays/prod customizes production deployment

4. Do not hardcode namespace in base

This was a major correction.

The right pattern is:

  • base stays reusable
  • namespace belongs in overlays
  • overlays/dev sets apps-dev
  • overlays/prod sets apps-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-dev
  • apps-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 resources
  • overlays/dev/ = development-specific settings
  • overlays/prod/ = production-specific settings
  • argocd/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

  • git
  • kubectl
  • 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 argocd namespace

  • Traefik

  • cert-manager, if TLS ingress is being used

  • namespaces:

    • apps-dev
    • apps-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 created
  • namespace/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 includes ani2fun/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.yaml shows 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_USERNAME
  • DOCKERHUB_TOKEN
  • INFRA_REPO_TOKEN
  • INFRA_GIT_USER_NAME
  • INFRA_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.

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-book Application 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, or 302

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-prod deployment 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_TOKEN is missing
  • token has wrong permissions
  • token does not include the infra repository

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 kubectl and 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 infra repo
  • 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:

  1. Confirm apps-dev and apps-prod exist.
  2. Confirm the notebook Kustomize overlays use those namespaces.
  3. Confirm base has no hardcoded app namespace.
  4. Create or update argocd/apps/note-book.yaml.
  5. Render the prod overlay locally with kubectl kustomize.
  6. Commit and push the infra changes.
  7. Apply the Argo CD Application manifest.
  8. Create or update the GitHub Actions workflow in note-book.
  9. Add the required GitHub secrets.
  10. Trigger the workflow.
  11. Verify image push, infra commit, Argo CD sync, and deployment rollout.
  12. Clean up any old notebook resources in previous namespaces if needed.

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 wide and kubectl 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.sh if 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, or ImagePullBackOff
  • 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/tcp
  • 443/tcp
  • 51820/udp
  • 22/tcp for 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:

  1. WireGuard connectivity
  2. K3s node health
  3. Calico
  4. Traefik and the edge firewall guardrail
  5. cert-manager and TLS
  6. Argo CD
  7. PostgreSQL
  8. Keycloak
  9. 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:

LayerFirst commandsTypical failures
WireGuard / host networkwg show, ip -br addr, ip route, systemctl status wg-quick@wg0missing tunnel, bad peer config, routing drift
K3s control plane / agentskubectl get nodes -o wide, systemctl status k3s, systemctl status k3s-agentnode not ready, agent not joined, kubelet issues
Calicokubectl get pods -n calico-system, kubectl logs -n calico-system ds/calico-node --tail=50CNI not ready, pod networking broken
Traefik / edge hostkubectl get pods -n traefik -o wide, sudo ss -lntup, sudo systemctl status edge-guardrail.serviceingress pod down, wrong listeners, firewall inactive
cert-managerkubectl get certificate,challenge,order -A, kubectl logs -n cert-manager deploy/cert-manager --tail=100challenge stuck, issuer missing, bad DNS token
Argo CDkubectl get application -n argocd, kubectl get pods -n argocd -o wideapp out of sync, missing CRD, pod crash
PostgreSQLkubectl get pods,pvc,svc -n databases-prod, kubectl logs -n databases-prod statefulset/postgresqlpod restart, storage issue, auth mismatch
Keycloakkubectl get pods,svc,ingress -n identity, kubectl logs -n identity deploy/keycloak --tail=100DB 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 the apps-dev / apps-prod namespace split was adopted. Contains an older whoami deployment.
  • namespace default with a legacy dev.notebook.kakde.eu ingress — 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:

  1. current kubectl output
  2. version-controlled manifests and scripts in k8s-cluster/
  3. the main tutorial documents in _docs/k8s-homelab/
  4. 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.


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

COMMANDDESCRIPTION
docker run IMAGEStart a new container
docker run --name CONTAINER IMAGEStart a new container and set a name
docker run -p HOSTPORT:CONTAINERPORT IMAGEStart a new container with mapped ports
docker run -P IMAGEStart a new container and map all ports

Container Management:

COMMANDDESCRIPTION
docker create IMAGECreate a new container
docker start CONTAINERStart a container
docker stop CONTAINERGraceful stop a container
docker kill CONTAINERKill (SIGKILL) a container
docker restart CONTAINERGraceful stop and restart a container
docker pause CONTAINERSuspend a container
docker unpause CONTAINERResume a container
docker rm CONTAINERDestroy a container

Container Bulk Management

COMMANDDESCRIPTION
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 pruneTo delete all dangling and unused images, containers, cache and volumes
docker system prune -aTo delete all used and unused images
docker system prune --volumesTo delete all docker volumes

Inspect Containers:

COMMANDDESCRIPTION
docker psList running containers
docker ps -aList all containers, including stopped
docker logs CONTAINERShow a container output
docker logs -f CONTAINERFollow a container output
docker top CONTAINERList the processes running in a container
docker diffShow the differences with the image (modified files)
docker inspectShow information of a container (json formatted)

Run Commands:

COMMANDDESCRIPTION
docker attach CONTAINERAttach to a container
docker cp CONTAINER:PATH HOSTPATHCopy files from the container
docker cp HOSTPATH CONTAINER:PATHCopy files into the container
docker export CONTAINERExport the content of the container (tar archive)
docker exec CONTAINERRun a command inside a container
docker exec -it CONTAINER /bin/bashOpen an interactive shell inside a container (there is no bash in some images, use /bin/sh)
docker wait CONTAINERWait until the container terminates and return the exit code

Images:

COMMANDDESCRIPTION
docker imagesList all local images
docker history IMAGEShow the image history
docker inspect IMAGEShow information (json formatted)
docker tag IMAGE TAGTag an image
docker commit CONTAINER IMAGECreate an image (from a container)
docker import URLCreate an image (from a tarball)
docker rmi IMAGEDelete 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 TEXTSearch an image on the official registry
docker loginLogin to a registry
docker logoutLogout from a registry
docker save REPO:[TAG]Export an image/repo as a tarball
docker loadLoad images from a tarball

Volumes:

COMMANDDESCRIPTION
docker volume lsList all vol1umes
docker volume create VOLUMECreate a volume
docker volume inspect VOLUMEShow information (json formatted)
docker volume rm VOLUMEDestroy a volume
docker volume ls --filter="dangling=true"List all dangling volumes (not referenced by any container)
docker volume pruneDelete 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

CodeNameDescription
100ContinueEverything so far is OK and that the client should continue with the request or ignore it if it is already finished.
101Switching ProtocolsThe client has asked the server to change protocols and the server has agreed to do so.
102ProcessingThe server has received and is processing the request, but that it does not have a final response yet.
103Early HintsUsed to return some response headers before final HTTP message.
200OKSuccessful request.
201CreatedThe server acknowledged the created resource.
202AcceptedThe client’s request has been received but the server is still processing it.
203Non-Authoritative InformationThe response that the server sent to the client is not the same as it was when the server sent it.
204No ContentThere is no content to send for this request
205Reset ContentTells the user agent to reset the document which sent this request.
206Partial ContentThis response code is used when the range-header is sent from the client to request only part of a resource.
207Multi-StatusConveys information about multiple resources, for situations where multiple status codes might be appropriate.
208Already ReportedThe members of a DAV binding have already been enumerated in a preceding part of the multi-status response.
226IM UsedIM is a specific extension of the HTTP protocol. The extension allows a HTTP server to send diffs (changes) of resources to clients.
300Multiple ChoicesThe request has more than one possible response. The user agent should choose one.
301Moved PermanentlyThe URL of the requested resource has been changed permanently. The new URL is given in the response.
302FoundThis response code means that the URI of requested resource has been changed temporarily
303See OtherThe server sent this response to direct the client to get the requested resource at another URI with a GET request.
304Not ModifiedIt tells the client that the response has not been modified, so the client can continue to use the same cached version of the response.
305Use ProxyDefined in a previous version of the HTTP specification to indicate that a requested response must be accessed by a proxy. (discontinued)
307Temporary RedirectThe 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.
308Permanent RedirectThis means that the resource is now permanently located at another URI, specified by the Location: HTTP Response header.
400Bad RequestThe server could not understand the request
401UnauthorizedThe client didn’t authenticate himself.
402Payment RequiredThis 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.
403ForbiddenThe client does not have access rights to the content
404Not FoundThe server can not find the requested resource
405Method Not AllowedThe request method is known by the server but is not supported by the target resource
406Not AcceptableThe reponse doens’t conforms to the creteria given by the client
407Proxy Authentication RequiredThis is similar to 401 Unauthorized but authentication is needed to be done by a proxy.
408Request TimeoutThis response is sent on an idle connection by some servers, even without any previous request by the client.
409ConflictThis response is sent when a request conflicts with the current state of the server.
410GoneThis response is sent when the requested content has been permanently deleted from server, with no forwarding address.
411Length RequiredServer rejected the request because the Content-Length header field is not defined and the server requires it.
412Precondition FailedAccess to the target resource has been denied.
413Payload Too LargeRequest entity is larger than limits defined by server.
414Request-URI Too LongThe URI requested by the client is longer than the server is willing to interpret.
415Unsupported Media TypeThe media format is not supported by the server.
416Requested Range Not SatisfiableThe range specified by the Range header field in the request cannot be fulfilled.
417Expectation Failedthe expectation indicated by the Expect request header field cannot be met by the server.
418I’m a teapotThe server refuses the attempt to brew coffee with a teapot.
421Misdirected RequestThe request was directed at a server that is not able to produce a response.
422Unprocessable EntityThe request was well-formed but was unable to be followed due to semantic errors.
423LockedThe resource that is being accessed is locked.
424Failed DependencyThe request failed due to failure of a previous request.
426Upgrade RequiredThe 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.
428Precondition Requiredhis 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.
429Too Many RequestsThe user has sent too many requests in a given amount of time
431Request Header Fields Too LargeThe server is can’t process the request because its header fields are too large.
444Connection Closed Without ResponseThe connection opened, but no data was written.
451Unavailable For Legal ReasonsThe user agent requested a resource that cannot legally be provided (such as a web page censored by a government)
499Client Closed RequestThe client closed the connection, despite the server was processing the request already.
500Internal Server ErrorThe server has encountered a situation it does not know how to handle.
501Not ImplementedThe request method is not supported by the server and cannot be handled.
502Bad GatewayThis error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response.
503Service UnavailableThe server is not ready to handle the request.
504Gateway TimeoutThis error response is given when the server is acting as a gateway and cannot get a response in time.
505HTTP Version Not SupportedThe HTTP version used in the request is not supported by the server.
506Variant Also Negotiatesthe chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process.
507Insufficient StorageThe method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request.
508Loop DetectedThe server detected an infinite loop while processing the request.
510Not ExtendedFurther extensions to the request are required for the server to fulfill it.
511Network Authentication RequiredIndicates that the client needs to authenticate to gain network access.
599Network Connect Timeout ErrorThe 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.”
  • 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”
  • 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”
  • 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”
  • 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”
  • 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 between n and m occurrences.

    • Example:

      • Regex: a{2,4}
      • Matches: “aa”, “aaa”, “aaaa”
    • 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”
    • 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));
       }
  • Kleene Plus +: Match 1 or more occurrences.

    • Example:

      • Regex: ba+
      • Matches: “ba”, “baaaa”, but not “b”
    • 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”
    • 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”
  • 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: “.”
  • 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
  • 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}
  • Challenge 3: Write a regex to match an email address.

    • Solution: \w+@\w+\.\w+
  • 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”
  • 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”
  • 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 “$”
  • 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”
  • 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.
  • 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: \u00E9 matches “é”

  • Regex engines that support Unicode can also have specific flags (like /u in 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/i will match “abc”, “ABC”, “aBc”, etc.
  • 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: ^hello matches “hello” at the beginning of each line in multiline text.
  • 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:

network adapter

Select Bridged network wifi:

Bridged Network Wifi

After that start with Normal Installation process. Below screenshots are reference only. Select Configuration as per your need.

  • Use Graphical Install