Skip to content

DSA Algorithms - Code Library

Runnable Python implementations of core algorithms, grouped by domain. Source lives in DSA/algorithms/.

Use the copy button on any block to grab the code. These embed the real source files, so they always match the repo.

Array

# ALGORITHM: Binary Search (classic)
# CATEGORY: Array / Search
# DIFFICULTY: Easy | FREQUENCY: Very High (fundamental building block at all companies)

# PROBLEM:
# Given a sorted array and a target, return the index of the target, or -1 if not present.

# INTUITION:
# Repeatedly halve the search range: compare the middle element with the target and discard
# the half that cannot contain it.

# TIME: O(log n)  |  SPACE: O(1)

# COMMON MISTAKES:
# - Using (l + r) / 2 in languages that overflow (Python ints are unbounded, but l + (r-l)//2 is a good habit).
# - Wrong loop condition: use while l <= r for exact-match search.
# - Forgetting the array must be sorted.

# RELATED PROBLEMS:
# - Binary Search (LC 704)
# - Search Insert Position (LC 35)
# - See DSA/patterns/binary_search.py for boundary/answer-space variants.


def binary_search(arr: list[int], target: int) -> int:
    l = 0
    r = len(arr) - 1

    while l <= r:
        mid = (l + r) >> 1
        if arr[mid] == target:
            return mid
        if arr[mid] < target:
            l = mid + 1
        else:
            r = mid - 1
    return -1


# more patterns in DSA/patterns/binary_search.py
if __name__ == "__main__":
    print(binary_search([1, 3, 5, 7, 9, 11], 7))  # 3
    print(binary_search([1, 3, 5, 7, 9, 11], 8))  # -1

Is Prime

# ALGORITHM: Primality Testing (Sieve of Eratosthenes + single check)
# CATEGORY: Array / Math
# DIFFICULTY: Easy | FREQUENCY: Medium (math-flavored problems, count-primes at Amazon/Microsoft)

# PROBLEM:
# Determine which numbers are prime. For many queries up to n, precompute with a sieve;
# for a single number, trial-divide up to sqrt(n).

# INTUITION:
# Sieve: every composite has a smallest prime factor; mark multiples of each prime starting at i*i
# (smaller multiples are already marked by smaller primes).

# TIME: sieve O(n log log n), single check O(sqrt(n))  |  SPACE: O(n) for the sieve

# COMMON MISTAKES:
# - Forgetting that 0 and 1 are not prime.
# - Starting the inner marking loop at 2*i instead of i*i (correct but redundant work).
# - Looping i while i <= n instead of i*i <= n in the outer sieve loop.

# RELATED PROBLEMS:
# - Count Primes (LC 204)
# - Ugly Number II (LC 264)


# For multiple queries: Sieve of Eratosthenes.
# If checking many numbers up to n:
def sieve_of_eratosthenes(n: int) -> list[bool]:
    is_prime = [True] * (n + 1)
    is_prime[0] = is_prime[1] = False

    i = 2
    while i * i <= n:
        if is_prime[i]:
            # Mark all multiples as not prime.
            # Start from i*i to avoid redundant work.
            for j in range(i * i, n + 1, i):
                is_prime[j] = False
        i += 1

    return is_prime  # list where is_prime[i] tells if i is prime


# For a single number: trial division up to sqrt(n).
def is_prime(n: int) -> bool:
    if n < 2:
        return False
    i = 2
    while i * i <= n:
        if n % i == 0:
            return False
        i += 1
    return True


if __name__ == "__main__":
    sieve = sieve_of_eratosthenes(20)
    print([i for i, p in enumerate(sieve) if p])  # [2, 3, 5, 7, 11, 13, 17, 19]
    print(is_prime(17))  # True
    print(is_prime(18))  # False

K Sum

# ALGORITHM: kSum (generalized 2Sum/3Sum/4Sum)
# CATEGORY: Array / Two Pointers / Recursion
# DIFFICULTY: Medium | FREQUENCY: Very High (2Sum/3Sum/4Sum family at Google/Meta/Amazon)

# PROBLEM:
# Find all unique k-sized combinations in an array that sum to a target value.
# This reduces the k-sum problem to a 2-sum problem via recursion.

# TIME: O(n^(k-1))  |  SPACE: O(k) for the recursion stack

# INTUITION / STEPS:
# 1. Sort the array to handle duplicates and enable the two-pointer technique.
# 2. Recurse to reduce k-sum down to 2-sum.
# 3. For k > 2, fix one element and recursively solve for k-1.
# 4. For k == 2, use two pointers to find pairs that sum to the (adjusted) target.
# 5. Skip duplicates to ensure unique combinations.

# COMMON MISTAKES:
# - Forgetting to sort first (duplicate-skipping and two-pointer both rely on it).
# - Not skipping duplicate starting numbers (i > start and nums[i] == nums[i-1]).
# - Not skipping duplicates on BOTH ends after finding a valid pair.

# RELATED PROBLEMS:
# - Two Sum II (LC 167)
# - 3Sum (LC 15)
# - 4Sum (LC 18)


def four_sum(nums: list[int], target: int) -> list[list[int]]:
    quads: list[list[int]] = []
    nums.sort()

    def k_sum(start: int, k: int, cur_arr: list[int], cur_sum: int) -> None:
        # BASE CASE: down to 2 numbers -> two-pointer technique
        if k == 2:
            i = start
            j = len(nums) - 1

            while i < j:
                total = nums[i] + nums[j] + cur_sum

                if total == target:
                    quads.append([*cur_arr, nums[i], nums[j]])
                    # Skip duplicates from both ends
                    while i < j and nums[i] == nums[i + 1]:
                        i += 1
                    while i < j and nums[j] == nums[j - 1]:
                        j -= 1
                    i += 1
                    j -= 1
                elif total < target:
                    i += 1  # need a bigger sum
                else:
                    j -= 1  # need a smaller sum
        # RECURSIVE CASE: fix one number, solve (k-1)-sum
        else:
            for i in range(start, len(nums) - k + 1):
                # Skip duplicate starting numbers
                if i > start and nums[i] == nums[i - 1]:
                    continue

                cur_arr.append(nums[i])
                k_sum(i + 1, k - 1, cur_arr, cur_sum + nums[i])
                cur_arr.pop()

    k_sum(0, 4, [], 0)
    return quads


# Example usage:
if __name__ == "__main__":
    # [[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]]
    print(four_sum([1, 0, -1, 0, -2, 2], 0))

Kadane

# ALGORITHM: Kadane's Algorithm (Maximum Subarray Sum)
# CATEGORY: Array / Dynamic Programming
# DIFFICULTY: Easy | FREQUENCY: Very High (classic array DP at Google/Amazon/Microsoft)

# PROBLEM:
# Find the maximum sum of a contiguous subarray within a one-dimensional array of numbers.

# INTUITION:
# - Maintain a running sum of the current subarray and the best sum found so far.
# - At each element, either extend the current subarray or start fresh at the current element:
#   cur = max(x, cur + x).
# - Track the best cur seen.

# TIME: O(n)  |  SPACE: O(1)

# When to use:
# - Maximum sum of a contiguous subarray.
# - Linear-time, constant-space subarray problems.
# - Variants: maximum product subarray, longest subarray with a given sum.

# COMMON MISTAKES:
# - Initializing best to 0 instead of -inf (fails on all-negative arrays).
# - Resetting cur to 0 instead of taking max(x, cur + x) (breaks all-negative case).

# RELATED PROBLEMS:
# - Maximum Subarray (LC 53)
# - Maximum Product Subarray (LC 152)
# - Best Time to Buy and Sell Stock (LC 121)


def kadane(arr: list[int]) -> int:
    best = float("-inf")
    cur = 0

    for x in arr:
        cur = max(x, cur + x)
        best = max(best, cur)
    return best


if __name__ == "__main__":
    print(kadane([-2, 1, -3, 4, -1, 2, 1, -5, 4]))  # 6 ([4, -1, 2, 1])
    print(kadane([-3, -1, -2]))                      # -1

Lis

# ALGORITHM: Longest Increasing Subsequence (LIS)
# CATEGORY: Array / DP / Binary Search (Patience Sort)
# DIFFICULTY: Medium | FREQUENCY: High (LIS family at Google/Amazon/Microsoft)

# PROBLEM STATEMENT:
# Given an array of integers, find the longest strictly increasing subsequence.
# The DP solution is O(n^2); we optimize to O(n log n) using Patience Sort.

# Patience Sort intuition:
# - Card game: deal cards into piles, each pile sorted (smaller on top).
# - Rule: place a card on the leftmost pile whose top >= card (or start a new pile).
# - Key insight: number of piles = LIS length.
# - Why? Each pile represents a "chain" in the subsequence.

# WHEN TO USE:
# - Longest increasing subsequence or similar problems.
# - When O(n^2) DP is too slow and you need O(n log n).

# CORE IDEA (INTUITION):
# 1. Maintain "tails" where tails[i] = smallest ending value among all LIS of length i+1.
# 2. For each number, binary-search the leftmost tail >= num.
# 3. Replace that tail with num (keeps endings as small as possible), or append if num extends the LIS.
# 4. Number of tails = LIS length. Track parents to reconstruct the actual subsequence.

# INVARIANTS:
# - tails is always sorted in increasing order.
# - len(tails) at the end is the LIS length.

# TIME: O(n log n)  |  SPACE: O(n)

# COMMON MISTAKES:
# - Forgetting binary search (falling back to O(n^2)).
# - Not maintaining the parent array when reconstructing the actual LIS.
# - Confusing the tails array with the actual subsequence (tails only track pile tops).

# RELATED PROBLEMS:
# - Longest Increasing Subsequence (LC 300)
# - Russian Doll Envelopes (LC 354)
# - Number of Longest Increasing Subsequence (LC 673)
# - Longest Decreasing Subsequence: reverse the comparison in binary search.


def get_lis(nums: list[int]) -> list[int]:
    n = len(nums)
    if n == 0:
        return []

    # tails[i] = smallest ending value among all LIS of length i+1 (always sorted)
    tails: list[int] = []
    # To reconstruct: track which element created each tail
    indices: list[int] = []          # indices[i] = array index of tails[i]
    parent = [-1] * n                # link to previous element in the chain

    for i in range(n):
        num = nums[i]

        # Binary search: leftmost pile whose top >= num
        left, right = 0, len(tails)
        while left < right:
            mid = (left + right) // 2
            if tails[mid] < num:
                left = mid + 1  # num is bigger, try a longer LIS
            else:
                right = mid     # num can improve this length

        # Connect to previous element in the chain
        if left > 0:
            parent[i] = indices[left - 1]

        # Update: extend the LIS or replace with a smaller ending
        if left == len(tails):
            tails.append(num)     # new longest LIS
            indices.append(i)
        else:
            tails[left] = num     # better (smaller) ending for this length
            indices[left] = i

    # Trace back the actual sequence
    lis: list[int] = []
    idx = indices[-1]  # start from the longest LIS
    while idx != -1:
        lis.append(nums[idx])
        idx = parent[idx]

    return lis[::-1]


# EXAMPLE:
# Input: [10, 9, 2, 5, 3, 7, 101, 18]
# Output length: 4 (an LIS is [2, 3, 7, 18])
if __name__ == "__main__":
    print(get_lis([10, 9, 2, 5, 3, 7, 101, 18]))  # [2, 3, 7, 18]

Dp

Climbing Stairs

# ALGORITHM: Climbing Stairs
# CATEGORY: 1D DP (Linear)
# DIFFICULTY: Easy | FREQUENCY: Very High (intro DP question at all companies)

# PROBLEM:
# You are climbing a staircase that takes n steps to reach the top.
# Each time you can climb 1 or 2 steps. In how many distinct ways can you climb to the top?

# INTUITION:
# To reach step i, you either came from step i-1 (1 step) or step i-2 (2 steps).
# dp[i] = dp[i-1] + dp[i-2]  (same as Fibonacci).

# TIME: O(n)  |  SPACE: O(1) with optimization

# COMMON MISTAKES:
# - Off-by-one on base cases (dp[1] = 1, dp[2] = 2).
# - Recomputing Fibonacci recursively without memoization (exponential blow-up).

# RELATED PROBLEMS:
# - Min Cost Climbing Stairs (LC 746)
# - Fibonacci Number (LC 509)
# - Decode Ways (LC 91)
# - House Robber (LC 198)


# DP approach (O(n) space)
def climb_stairs(n: int) -> int:
    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]

    return dp[n]


# Space-optimized (O(1) space)
def climb_stairs_optimized(n: int) -> int:
    if n <= 2:
        return n

    prev2 = 1  # dp[i-2]
    prev1 = 2  # dp[i-1]

    for _ in range(3, n + 1):
        cur = prev1 + prev2
        prev2 = prev1
        prev1 = cur

    return prev1


if __name__ == "__main__":
    print(climb_stairs(5))            # 8
    print(climb_stairs_optimized(5))  # 8
    print(climb_stairs(10))           # 89

Coin Change

# ALGORITHM: Coin Change
# CATEGORY: Unbounded Knapsack / 1D DP
# DIFFICULTY: Medium | FREQUENCY: Very High (DP staple at Google/Amazon/Microsoft)

# PROBLEM:
# Given coins of different denominations and a total amount, find the fewest number of coins
# needed to make up that amount. Return -1 if it's not possible.

# INTUITION:
# dp[amount] = minimum coins to make 'amount'.
# For each amount, try every coin: dp[a] = min(dp[a], dp[a - coin] + 1).
# This is unbounded knapsack - each coin can be used unlimited times.

# TIME: O(amount * len(coins))  |  SPACE: O(amount)

# COMMON MISTAKES:
# - Initializing dp with 0 instead of infinity (for min problems).
# - Iterating coins in the inner loop for Coin Change II (gives permutations, not combinations).
# - Forgetting dp[0] = 0 (or dp[0] = 1 for counting).

# RELATED PROBLEMS:
# - Coin Change (LC 322)
# - Coin Change II (LC 518)
# - Perfect Squares (LC 279)
# - Minimum Cost For Tickets (LC 983)


def coin_change(coins: list[int], amount: int) -> int:
    dp = [float("inf")] * (amount + 1)
    dp[0] = 0

    for a in range(1, amount + 1):
        for coin in coins:
            if coin <= a and dp[a - coin] != float("inf"):
                dp[a] = min(dp[a], dp[a - coin] + 1)

    return -1 if dp[amount] == float("inf") else dp[amount]


# Coin Change II - count the number of combinations that make up amount.
# dp[a] = number of ways to make amount 'a'.
# Key: iterate coins in the OUTER loop to avoid counting permutations.
def coin_change_ii(coins: list[int], amount: int) -> int:
    dp = [0] * (amount + 1)
    dp[0] = 1

    for coin in coins:              # outer: coins (combinations, not permutations)
        for a in range(coin, amount + 1):
            dp[a] += dp[a - coin]

    return dp[amount]


if __name__ == "__main__":
    print(coin_change([1, 5, 10, 25], 30))  # 2 (25 + 5)
    print(coin_change([2], 3))               # -1
    print(coin_change_ii([1, 2, 5], 5))      # 4 ways

Edit Distance

# ALGORITHM: Edit Distance (Levenshtein Distance)
# CATEGORY: 2D DP (String DP)
# DIFFICULTY: Hard | FREQUENCY: High (classic string DP at Google/Amazon/Microsoft)

# PROBLEM:
# Given two strings word1 and word2, return the minimum number of operations to convert word1
# into word2. Operations: insert, delete, replace.

# INTUITION:
# dp[i][j] = min operations to convert word1[0..i-1] to word2[0..j-1].
# If chars match: dp[i][j] = dp[i-1][j-1] (no operation needed).
# If not: dp[i][j] = 1 + min(
#   dp[i-1][j],     # delete from word1
#   dp[i][j-1],     # insert into word1
#   dp[i-1][j-1]    # replace in word1
# )

# TIME: O(m * n)  |  SPACE: O(m * n), optimizable to O(n)

# COMMON MISTAKES:
# - Forgetting base cases (empty-string conversions fill row 0 and column 0).
# - Wrong index mapping (dp is 1-indexed, strings are 0-indexed).
# - Confusing which operation maps to which dp transition.

# RELATED PROBLEMS:
# - Edit Distance (LC 72)
# - One Edit Distance (LC 161)
# - Delete Operation for Two Strings (LC 583)
# - Minimum ASCII Delete Sum for Two Strings (LC 712)


def min_distance(word1: str, word2: str) -> int:
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # Base cases: converting an empty string
    for i in range(m + 1):
        dp[i][0] = i  # delete all chars
    for j in range(n + 1):
        dp[0][j] = j  # insert all chars

    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 - 1][j],      # delete
                    dp[i][j - 1],      # insert
                    dp[i - 1][j - 1],  # replace
                )

    return dp[m][n]


# Space-optimized (O(n) space)
def min_distance_optimized(word1: str, word2: str) -> int:
    m, n = len(word1), len(word2)
    prev = list(range(n + 1))

    for i in range(1, m + 1):
        cur = [0] * (n + 1)
        cur[0] = i
        for j in range(1, n + 1):
            if word1[i - 1] == word2[j - 1]:
                cur[j] = prev[j - 1]
            else:
                cur[j] = 1 + min(prev[j], cur[j - 1], prev[j - 1])
        prev = cur

    return prev[n]


if __name__ == "__main__":
    print(min_distance("horse", "ros"))               # 3
    print(min_distance("intention", "execution"))     # 5
    print(min_distance_optimized("horse", "ros"))     # 3

House Robber

# ALGORITHM: House Robber
# CATEGORY: 1D DP (Linear)
# DIFFICULTY: Medium | FREQUENCY: Very High (DP staple at Google/Amazon/Microsoft)

# PROBLEM:
# Given an array of non-negative integers representing money at each house, determine the maximum
# amount you can rob without robbing two adjacent houses.

# INTUITION:
# At each house i you either:
#   1. Rob it: nums[i] + dp[i-2] (skip the previous house).
#   2. Skip it: dp[i-1].
# dp[i] = max(dp[i-1], dp[i-2] + nums[i]).

# TIME: O(n)  |  SPACE: O(1) with optimization

# COMMON MISTAKES:
# - Robbing adjacent houses by mixing up prev1/prev2 updates.
# - For House Robber II: forgetting that first and last are adjacent (run two linear passes).

# RELATED PROBLEMS:
# - House Robber (LC 198)
# - House Robber II (LC 213) - circular
# - House Robber III (LC 337) - tree DP
# - Delete and Earn (LC 740)


def rob(nums: list[int]) -> int:
    if len(nums) == 0:
        return 0
    if len(nums) == 1:
        return nums[0]

    prev2 = 0         # dp[i-2]
    prev1 = nums[0]   # dp[i-1]

    for i in range(1, len(nums)):
        cur = max(prev1, prev2 + nums[i])
        prev2 = prev1
        prev1 = cur

    return prev1


# House Robber II (circular arrangement)
# First and last houses are adjacent, so we can't rob both.
# Solution: max(rob(0..n-2), rob(1..n-1)).
def rob_circular(nums: list[int]) -> int:
    if len(nums) == 1:
        return nums[0]

    def rob_range(start: int, end: int) -> int:
        prev2 = 0
        prev1 = 0
        for i in range(start, end + 1):
            cur = max(prev1, prev2 + nums[i])
            prev2 = prev1
            prev1 = cur
        return prev1

    return max(
        rob_range(0, len(nums) - 2),  # exclude last
        rob_range(1, len(nums) - 1),  # exclude first
    )


if __name__ == "__main__":
    print(rob([1, 2, 3, 1]))          # 4 (rob house 0 and 2)
    print(rob([2, 7, 9, 3, 1]))       # 12 (rob house 0, 2, 4)
    print(rob_circular([2, 3, 2]))     # 3
    print(rob_circular([1, 2, 3, 1]))  # 4

Knapsack

# ALGORITHM: 0/1 Knapsack
# CATEGORY: 2D DP / Knapsack
# DIFFICULTY: Medium | FREQUENCY: High (knapsack family + subset-sum at Google/Amazon)

# PROBLEM:
# Given n items with weights and values, and a knapsack of capacity W, find the maximum value you
# can carry. Each item can be used at most once.

# INTUITION:
# dp[i][w] = max value using the first i items with capacity w.
# For each item: take it (if it fits) or skip it.
# dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i]).

# TIME: O(n * W)  |  SPACE: O(n * W), optimizable to O(W)

# COMMON MISTAKES:
# - Wrong iteration direction in the 1D optimization (must go right-to-left for 0/1).
# - Confusing 0/1 knapsack (right-to-left) with unbounded (left-to-right).
# - Off-by-one between 1-indexed dp and 0-indexed items.

# RELATED PROBLEMS:
# - Partition Equal Subset Sum (LC 416)
# - Target Sum (LC 494)
# - Last Stone Weight II (LC 1049)
# - Ones and Zeroes (LC 474)


# 2D approach
def knapsack(weights: list[int], values: list[int], W: int) -> int:
    n = len(weights)
    dp = [[0] * (W + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(W + 1):
            dp[i][w] = dp[i - 1][w]  # skip item i
            if weights[i - 1] <= w:
                dp[i][w] = max(
                    dp[i][w],
                    dp[i - 1][w - weights[i - 1]] + values[i - 1],  # take item i
                )

    return dp[n][W]


# Space-optimized 1D approach (O(W) space)
# Key: iterate w from RIGHT to LEFT so we don't overwrite values we still need.
def knapsack_optimized(weights: list[int], values: list[int], W: int) -> int:
    n = len(weights)
    dp = [0] * (W + 1)

    for i in range(n):
        for w in range(W, weights[i] - 1, -1):  # RIGHT to LEFT
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

    return dp[W]


# Subset Sum - can we make target sum from the array?
# Special case of 0/1 knapsack where value == weight.
def subset_sum(nums: list[int], target: int) -> bool:
    dp = [False] * (target + 1)
    dp[0] = True

    for num in nums:
        for t in range(target, num - 1, -1):  # RIGHT to LEFT
            dp[t] = dp[t] or dp[t - num]

    return dp[target]


# Partition Equal Subset Sum - can we split the array into two equal-sum subsets?
# Reduces to: subset_sum(nums, total // 2).
def can_partition(nums: list[int]) -> bool:
    total = sum(nums)
    if total % 2 != 0:
        return False
    return subset_sum(nums, total // 2)


if __name__ == "__main__":
    print(knapsack([1, 3, 4, 5], [1, 4, 5, 7], 7))            # 9
    print(knapsack_optimized([1, 3, 4, 5], [1, 4, 5, 7], 7))  # 9
    print(subset_sum([3, 34, 4, 12, 5, 2], 9))                # True
    print(can_partition([1, 5, 11, 5]))                       # True
    print(can_partition([1, 2, 3, 5]))                        # False

Lcs

# ALGORITHM: Longest Common Subsequence (LCS)
# CATEGORY: 2D DP (Two Sequences)
# DIFFICULTY: Medium | FREQUENCY: High (two-sequence DP staple at Google/Amazon/Microsoft)

# PROBLEM:
# Given two strings, find the length of their longest common subsequence. A subsequence is derived
# by deleting some (or no) elements without changing the order of the remaining elements.

# INTUITION:
# dp[i][j] = LCS length of text1[0..i-1] and text2[0..j-1].
# If chars match: dp[i][j] = dp[i-1][j-1] + 1.
# If not: dp[i][j] = max(dp[i-1][j], dp[i][j-1]).

# TIME: O(m * n)  |  SPACE: O(m * n), optimizable to O(min(m, n))

# COMMON MISTAKES:
# - Confusing subsequence (order preserved, gaps allowed) with substring (contiguous).
# - Wrong index mapping (dp is 1-indexed, strings are 0-indexed).
# - Backtracking in the wrong direction when reconstructing the actual LCS.

# RELATED PROBLEMS:
# - Longest Common Subsequence (LC 1143)
# - Longest Palindromic Subsequence (LC 516) - LCS(s, reverse(s))
# - Shortest Common Supersequence (LC 1092)
# - Edit Distance (LC 72) - related recurrence


def longest_common_subsequence(text1: str, text2: str) -> int:
    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] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

    return dp[m][n]


# Print the actual LCS string (backtrack through the DP table).
def print_lcs(text1: str, text2: str) -> str:
    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] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

    # Backtrack to find the actual subsequence
    i, j = m, n
    lcs = ""
    while i > 0 and j > 0:
        if text1[i - 1] == text2[j - 1]:
            lcs = text1[i - 1] + lcs
            i -= 1
            j -= 1
        elif dp[i - 1][j] > dp[i][j - 1]:
            i -= 1
        else:
            j -= 1

    return lcs


# Space-optimized (O(n) space) - only need the previous row.
def lcs_optimized(text1: str, text2: str) -> int:
    m, n = len(text1), len(text2)
    prev = [0] * (n + 1)

    for i in range(1, m + 1):
        cur = [0] * (n + 1)
        for j in range(1, n + 1):
            if text1[i - 1] == text2[j - 1]:
                cur[j] = prev[j - 1] + 1
            else:
                cur[j] = max(prev[j], cur[j - 1])
        prev = cur

    return prev[n]


if __name__ == "__main__":
    print(longest_common_subsequence("abcde", "ace"))  # 3
    print(print_lcs("abcde", "ace"))                    # "ace"
    print(lcs_optimized("abcde", "ace"))                # 3

Unique Paths

# ALGORITHM: Unique Paths
# CATEGORY: 2D DP (Grid)
# DIFFICULTY: Medium | FREQUENCY: High (grid DP staple at Google/Amazon/Microsoft)

# PROBLEM:
# A robot is at the top-left corner of an m x n grid. It can only move right or down.
# How many unique paths exist to reach the bottom-right corner?

# INTUITION:
# dp[i][j] = number of ways to reach cell (i, j).
# dp[i][j] = dp[i-1][j] + dp[i][j-1]  (from above + from left).

# TIME: O(m * n)  |  SPACE: O(n) with optimization

# COMMON MISTAKES:
# - Forgetting to zero out cells blocked by obstacles.
# - Wrong initialization for the min-path-sum variant (use +inf, seed dp[0]).
# - Mixing up row (m) and column (n) dimensions.

# RELATED PROBLEMS:
# - Unique Paths (LC 62)
# - Unique Paths II (LC 63)
# - Minimum Path Sum (LC 64)
# - Dungeon Game (LC 174)
# - Triangle (LC 120)


def unique_paths(m: int, n: int) -> int:
    dp = [1] * n  # first row is all 1s

    for _ in range(1, m):
        for j in range(1, n):
            dp[j] += dp[j - 1]

    return dp[n - 1]


# Unique Paths II - with obstacles (grid[i][j] == 1 means obstacle).
def unique_paths_with_obstacles(grid: list[list[int]]) -> int:
    m, n = len(grid), len(grid[0])
    if grid[0][0] == 1 or grid[m - 1][n - 1] == 1:
        return 0

    dp = [0] * n
    dp[0] = 1

    for i in range(m):
        for j in range(n):
            if grid[i][j] == 1:
                dp[j] = 0
            elif j > 0:
                dp[j] += dp[j - 1]

    return dp[n - 1]


# Minimum Path Sum - path with the minimum sum from top-left to bottom-right.
def min_path_sum(grid: list[list[int]]) -> int:
    m, n = len(grid), len(grid[0])
    dp = [float("inf")] * n
    dp[0] = 0

    for i in range(m):
        dp[0] += grid[i][0]
        for j in range(1, n):
            if i == 0:
                dp[j] = dp[j - 1] + grid[i][j]
            else:
                dp[j] = min(dp[j], dp[j - 1]) + grid[i][j]

    return dp[n - 1]


if __name__ == "__main__":
    print(unique_paths(3, 7))                                          # 28
    print(unique_paths_with_obstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]]))  # 2
    print(min_path_sum([[1, 3, 1], [1, 5, 1], [4, 2, 1]]))             # 7

Graph

Bellman Ford

# ALGORITHM: Bellman-Ford (Single Source Shortest Path with negative weights)
# CATEGORY: Graph / Dynamic Programming / Shortest Path
# DIFFICULTY: Medium | FREQUENCY: Medium (negative weights, cheapest-flights at Amazon/Google)

# WHEN TO USE:
# - Shortest path in a graph that may contain negative-weight edges.
# - When the graph is given as an edge list.
# - When you need to detect negative-weight cycles.

# Dijkstra vs Bellman-Ford:
# - Dijkstra is faster for non-negative weights: O((V + E) log V) with a heap.
# - Bellman-Ford is O(V * E) but handles negative weights and detects negative cycles.
# - Dijkstra is greedy; Bellman-Ford is DP (relax all edges V-1 times).

# CORE IDEA (INTUITION):
# Relax every edge V-1 times. After the i-th pass, the shortest path using at most i edges is known.
# A V-th pass that still relaxes an edge proves a negative cycle exists.

# INVARIANTS:
# - After pass i, shortest paths using at most i edges are correct.
# - If any edge still relaxes after V-1 passes, a negative-weight cycle exists.

# TIME: O(V * E)  |  SPACE: O(V)

# COMMON MISTAKES:
# - Relaxing an edge from an unreachable node (skip when dist[u] is infinity).
# - Running V passes instead of V-1 for the main loop.
# - Forgetting the extra pass that detects negative cycles.

# RELATED PROBLEMS:
# - Cheapest Flights Within K Stops (LC 787)
# - Network Delay Time (LC 743)


def bellman_ford(V: int, edges: list[list[int]], src: int) -> list[float]:
    INF = float("inf")
    dist: list[float] = [INF] * V
    dist[src] = 0

    for _ in range(V - 1):
        for u, v, w in edges:
            if dist[u] == INF:  # must reach u before relaxing v
                continue
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w

    # Negative-cycle detection
    for u, v, w in edges:
        if dist[u] == INF:
            continue
        if dist[u] + w < dist[v]:
            return [-1]

    return dist


# Example usage:
if __name__ == "__main__":
    V = 5
    edges = [
        [0, 1, 100],
        [1, 2, 100],
        [0, 2, 500],
        [2, 3, 100],
        [3, 4, 100],
        [1, 4, 600],
    ]
    print(bellman_ford(V, edges, 0))  # [0, 100, 200, 300, 400]

Bfs

# ALGORITHM: Breadth-First Search (BFS)
# CATEGORY: Graph / Traversal
# DIFFICULTY: Easy | FREQUENCY: Very High (shortest path in unweighted graphs at all companies)

# PROBLEM:
# Traverse a graph level by level. BFS finds the shortest path (fewest edges) in an unweighted graph.

# INTUITION:
# Use a FIFO queue. Visit a node, enqueue all unvisited neighbors, mark them visited on enqueue
# (not on dequeue) to avoid processing a node twice.

# TIME: O(V + E)  |  SPACE: O(V)

# COMMON MISTAKES:
# - Marking visited on dequeue instead of enqueue (can enqueue the same node many times).
# - Using a list as a queue with pop(0) (O(n)); use collections.deque.
# - Forgetting the state key for multi-dimensional BFS (row, col, remaining).

# RELATED PROBLEMS:
# - Rotting Oranges (LC 994)
# - Word Ladder (LC 127)
# - Shortest Path in a Grid with Obstacles Elimination (LC 1293)

from collections import deque


# useful for shortest path in an unweighted graph
def bfs(start: int, graph: dict[int, list[int]]) -> set[int]:
    q: deque[int] = deque([start])
    visited = {start}

    while q:
        node = q.popleft()
        for nei in graph[node]:
            if nei not in visited:
                visited.add(nei)
                q.append(nei)

    return visited


# example usage:
# shortest path in a grid with obstacles elimination (state = (row, col, remaining))
def shortest_path(grid: list[list[int]], k: int) -> int:
    m, n = len(grid), len(grid[0])

    q: deque[tuple[int, int, int, int]] = deque()
    q.append((0, 0, k, 0))  # (row, col, remaining, distance)

    visited = {(0, 0, k)}

    directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]

    while q:
        row, col, remaining, distance = q.popleft()
        if row == m - 1 and col == n - 1:
            return distance

        for dr, dc in directions:
            new_r = row + dr
            new_c = col + dc

            if new_r < 0 or new_c < 0 or new_r >= m or new_c >= n:
                continue

            new_remaining = remaining - grid[new_r][new_c]
            key = (new_r, new_c, new_remaining)
            if new_remaining >= 0 and key not in visited:
                visited.add(key)
                q.append((new_r, new_c, new_remaining, distance + 1))

    return -1


if __name__ == "__main__":
    graph = {0: [1, 2], 1: [0, 3], 2: [0, 3], 3: [1, 2]}
    print(sorted(bfs(0, graph)))  # [0, 1, 2, 3]

    grid = [[0, 0, 0], [1, 1, 0], [0, 0, 0], [0, 1, 1], [0, 0, 0]]
    print(shortest_path(grid, 1))  # 6

Bipartite

# ALGORITHM: Bipartite Graph Check (BFS + DFS 2-coloring)
# CATEGORY: Graph / Coloring
# DIFFICULTY: Medium | FREQUENCY: High (bipartition / conflict-grouping at Google/Amazon/Meta)

# A bipartite graph's vertices split into two disjoint sets U and V such that every edge connects a
# vertex in U to one in V (no edges within a set).
# Equivalent condition: a graph is bipartite iff it has NO odd-length cycle.

# Algorithm (2-coloring):
#   1. Start from an uncolored node, assign color 0.
#   2. Traverse (BFS or DFS), coloring each neighbor the opposite color.
#      If a neighbor already has the SAME color as the current node -> not bipartite.
#   3. Repeat for all components (graph may be disconnected).

# TIME: O(V + E)  |  SPACE: O(V)

# COMMON MISTAKES:
# - Not handling disconnected components (must try every uncolored node).
# - Coloring on dequeue instead of when discovering a neighbor.
# - Treating an already-correctly-colored neighbor as a conflict.

# RELATED PROBLEMS:
# - Is Graph Bipartite? (LC 785)
# - Possible Bipartition (LC 886)

from collections import deque


def is_bipartite(graph: dict[int, list[int]]) -> bool:
    colors: dict[int, int] = {node: -1 for node in graph}  # -1 = uncolored

    def bfs_check(start: int) -> bool:
        queue: deque[int] = deque([start])
        colors[start] = 0

        while queue:
            current = queue.popleft()
            for nei in graph[current]:
                if colors[nei] == -1:
                    colors[nei] = 1 - colors[current]
                    queue.append(nei)
                elif colors[nei] == colors[current]:
                    return False  # coloring conflict
        return True

    for node in graph:
        if colors[node] == -1 and not bfs_check(node):
            return False

    return True


# Bipartite check using DFS
def is_bipartite_dfs(graph: dict[int, list[int]]) -> bool:
    colors: dict[int, int] = {node: -1 for node in graph}

    def dfs_check(node: int, color: int) -> bool:
        colors[node] = color
        for nei in graph[node]:
            if colors[nei] == -1:
                if not dfs_check(nei, 1 - color):
                    return False
            elif colors[nei] == colors[node]:
                return False
        return True

    for node in graph:
        if colors[node] == -1 and not dfs_check(node, 0):
            return False

    return True


if __name__ == "__main__":
    graph = {  # not bipartite (odd-length cycle)
        0: [1, 2],
        1: [0, 3],
        2: [0, 4],
        3: [4, 1],
        4: [3, 2],
    }
    graph2 = {  # bipartite
        0: [1, 2],
        1: [0, 3],
        2: [0, 4],
        3: [5, 1],
        4: [5, 2],
        5: [3, 4],
    }
    print("Graph  - BFS:", is_bipartite(graph))    # False
    print("Graph2 - BFS:", is_bipartite(graph2))   # True
    print("Graph  - DFS:", is_bipartite_dfs(graph))   # False
    print("Graph2 - DFS:", is_bipartite_dfs(graph2))  # True

Cycle Detection Directed

# ALGORITHM: Cycle Detection in a Directed Graph (DFS, 3-state)
# CATEGORY: Graph / DFS
# DIFFICULTY: Medium | FREQUENCY: Very High (course schedule / deadlock detection at all companies)

# PROBLEM:
# Determine whether a directed graph contains a cycle.

# INTUITION:
# Track two sets: `visited` (fully processed) and `path_visited` (nodes on the current DFS path,
# i.e. the recursion stack). Revisiting a node that's on the current path means a back edge -> cycle.
# This is the "white/gray/black" 3-coloring idea.

# TIME: O(V + E)  |  SPACE: O(V)

# COMMON MISTAKES:
# - Using a single visited set (works for undirected, NOT directed cycle detection).
# - Forgetting to remove the node from path_visited when backtracking.
# - Not iterating over all nodes for disconnected graphs.

# RELATED PROBLEMS:
# - Course Schedule (LC 207)
# - Find Eventual Safe States (LC 802)
# - Course Schedule II (LC 210)


def has_cycle(graph: dict[int, list[int]]) -> bool:
    visited: set[int] = set()
    path_visited: set[int] = set()  # nodes on the current DFS path

    def dfs(node: int) -> bool:
        if node in path_visited:
            return True   # cycle found
        if node in visited:
            return False  # already processed, no cycle from here

        visited.add(node)
        path_visited.add(node)

        for nei in graph[node]:
            if dfs(nei):
                return True

        path_visited.remove(node)
        return False

    for node in graph:
        if node not in visited and dfs(node):
            return True

    return False


if __name__ == "__main__":
    graph = {
        0: [1],
        1: [2],
        2: [3],
        3: [0, 4],
        4: [],
    }
    print(has_cycle(graph))  # True

Cycle Detection Undirected

# ALGORITHM: Cycle Detection in an Undirected Graph (DFS)
# CATEGORY: Graph / DFS
# DIFFICULTY: Medium | FREQUENCY: High (redundant-connection / cycle checks at Google/Amazon)

# PROBLEM:
# Determine whether an undirected graph contains a cycle.

# INTUITION:
# DFS while tracking the parent of each node. If we reach an already-visited neighbor that is NOT
# the parent, we've found a back edge -> cycle. (Union-Find is an alternative approach.)

# TIME: O(V + E)  |  SPACE: O(V)

# COMMON MISTAKES:
# - Forgetting to pass/skip the parent (every edge looks like a "cycle" otherwise).
# - Not handling disconnected components (iterate over all nodes).
# - Confusing this with directed cycle detection (which needs the recursion-stack set).

# RELATED PROBLEMS:
# - Graph Valid Tree (LC 261)
# - Redundant Connection (LC 684)
# - Number of Connected Components in an Undirected Graph (LC 323)


def has_cycle(graph: dict[int, list[int]]) -> bool:
    visited: set[int] = set()

    def dfs(node: int, parent: int) -> bool:
        visited.add(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                if dfs(neighbor, node):
                    return True
            elif neighbor != parent:
                # back edge -> cycle detected
                return True
        return False

    # Check all nodes to handle disconnected components
    for node in graph:
        if node not in visited:
            if dfs(node, -1):
                return True
    return False


if __name__ == "__main__":
    graph = {
        0: [1, 2],
        1: [0, 3],
        2: [0, 3],
        3: [1, 2],
    }
    print(has_cycle(graph))  # True

Dfs

# ALGORITHM: Depth-First Search (DFS)
# CATEGORY: Graph / Traversal
# DIFFICULTY: Easy | FREQUENCY: Very High (connected components, flood fill at all companies)

# PROBLEM:
# Traverse all nodes of a graph depth-first. Foundation for connected components, cycle detection,
# topological sort, flood fill, and tree recursion.

# INTUITION:
# Visit a node, mark it, then recurse into each unvisited neighbor. On a grid, treat cells as nodes
# and the 4 (or 8) directions as edges.

# TIME: O(V + E)  |  SPACE: O(V) for the visited set + recursion stack

# COMMON MISTAKES:
# - Forgetting to mark a node visited before recursing (infinite loop on cycles).
# - Grid DFS: missing bounds/visited/obstacle checks at the top of the function.
# - Hitting Python's recursion limit on very deep graphs (use an explicit stack if needed).

# RELATED PROBLEMS:
# - Number of Islands (LC 200)
# - Max Area of Island (LC 695)
# - Clone Graph (LC 133)


# useful for traversing all nodes in a graph depth-wise
def dfs(node: int, graph: dict[int, list[int]], visited: set[int] | None = None) -> None:
    if visited is None:
        visited = set()
    if node in visited:
        return
    visited.add(node)

    for nei in graph[node]:
        dfs(nei, graph, visited)


# example usage:
# number of islands in a grid
def num_islands(grid: list[list[str]]) -> int:
    res = 0
    m, n = len(grid), len(grid[0])

    visited = [[False] * n for _ in range(m)]

    directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]

    def island_dfs(i: int, j: int) -> None:
        if i < 0 or j < 0 or i >= m or j >= n or visited[i][j] or grid[i][j] == "0":
            return

        visited[i][j] = True

        for dr, dc in directions:
            island_dfs(i + dr, j + dc)

    for i in range(m):
        for j in range(n):
            if grid[i][j] == "1" and not visited[i][j]:
                res += 1
                island_dfs(i, j)
    return res


# example usage:
# get the subtree size for each node in a tree
def get_subtree_sizes(node: int, graph: dict[int, list[int]],
                      visited: set[int] | None = None, sizes: dict[int, int] | None = None) -> int:
    if visited is None:
        visited = set()
    if sizes is None:
        sizes = {}
    if node in visited:
        return 0
    visited.add(node)

    size = 1  # count the current node
    for child in graph[node]:
        if child not in visited:
            size += get_subtree_sizes(child, graph, visited, sizes)

    sizes[node] = size
    return size


if __name__ == "__main__":
    grid = [
        ["1", "1", "0", "0"],
        ["1", "0", "0", "1"],
        ["0", "0", "1", "1"],
    ]
    print(num_islands(grid))  # 2

Dijkstra

# ALGORITHM: Dijkstra's Algorithm (Single Source Shortest Path)
# CATEGORY: Graph / Greedy / Shortest Path
# DIFFICULTY: Medium | FREQUENCY: Very High (graph shortest-path staple at Google/Amazon/Uber)

# Can solve for cyclic and acyclic graphs with non-negative weights.
# Greedy Algorithm.
# Time Complexity: O((V + E) log V) where V is the number of vertices and E is the number of edges.

# Greedy choice: always expand the least costly node first.
# Finds the shortest path from source to all other nodes in the graph.
# Can't find shortest paths with constraints on number of edges or negative weights (Bellman-Ford can).

# NOTE: The JavaScript version used a custom MinPriorityQueue. In Python, the standard library `heapq`
# is the idiomatic min-priority-queue, so we use it directly. Entries are (distance, node) tuples,
# which heapq orders by distance first.

import heapq


def dijkstra(graph: dict[int, list[tuple[int, int]]], src: int) -> list[float]:
    # Size the distance array by the highest node label seen (as a key OR a neighbor), so a sink
    # node that never appears as a key still gets a slot. Assumes nodes are labeled 0..n-1.
    max_node = src
    for node, edges in graph.items():
        max_node = max(max_node, node)
        for nei, _ in edges:
            max_node = max(max_node, nei)
    dist: list[float] = [float("inf")] * (max_node + 1)
    dist[src] = 0

    pq: list[tuple[float, int]] = [(0, src)]  # (dist, node)

    while pq:
        d, node = heapq.heappop(pq)
        if d > dist[node]:
            continue

        # graph.get(node, []) is safe for sink nodes that have no outgoing-edge entry.
        for nei, w in graph.get(node, []):
            nd = d + w
            if nd < dist[nei]:
                dist[nei] = nd
                heapq.heappush(pq, (nd, nei))

    return dist


# COMMON MISTAKES:
# - Using Dijkstra with negative edge weights (use Bellman-Ford instead).
# - Forgetting the "if d > dist[node]: continue" stale-entry check (heapq has no decrease-key).
# - Initializing distances to 0 instead of infinity.
# - Pushing (node, dist) instead of (dist, node) so heapq orders by the wrong field.
# - Indexing graph[node] directly: a sink node with no outgoing edges may be absent as a key
#   (KeyError). Use graph.get(node, []). This code assumes nodes are labeled 0..n-1.

# RELATED PROBLEMS:
# - Network Delay Time (LC 743)
# - Path with Maximum Probability (LC 1514)
# - Cheapest Flights Within K Stops (LC 787)
# - Swim in Rising Water (LC 778)


# === DIJKSTRA WITH STATE: Cheapest Flights Within K Stops (LC 787) ===
# When the answer depends on MORE than distance (here: number of stops used), add that dimension to
# the heap state. The heap holds (cost, node, stops_remaining). We may revisit a node with more
# stops remaining even at higher cost, so the usual "visited"/stale-entry pruning does NOT apply the
# same way - we prune only when we reach a node with fewer stops remaining than a prior better state.
def find_cheapest_price(n: int, flights: list[list[int]], src: int, dst: int, k: int) -> int:
    graph: dict[int, list[tuple[int, int]]] = {i: [] for i in range(n)}
    for u, v, w in flights:
        graph[u].append((v, w))

    # best[node] = max stops_remaining seen at this node (prune worse states)
    best_stops: dict[int, int] = {}
    pq: list[tuple[int, int, int]] = [(0, src, k + 1)]  # (cost, node, stops_remaining)

    while pq:
        cost, node, stops = heapq.heappop(pq)
        if node == dst:
            return cost
        if stops == 0:
            continue
        # Prune: if we've already reached this node with >= stops remaining, skip.
        if node in best_stops and best_stops[node] >= stops:
            continue
        best_stops[node] = stops
        for nei, w in graph[node]:
            heapq.heappush(pq, (cost + w, nei, stops - 1))

    return -1


# Example usage:
if __name__ == "__main__":
    graph: dict[int, list[tuple[int, int]]] = {
        0: [(1, 4), (2, 1)],
        1: [(3, 1)],
        2: [(1, 2), (3, 5)],
        3: [],
    }

    print(dijkstra(graph, 0))  # [0, 3, 1, 4]

    # Cheapest Flights Within K Stops
    flights = [[0, 1, 100], [1, 2, 100], [0, 2, 500]]
    print(find_cheapest_price(3, flights, 0, 2, 1))  # 200 (0->1->2)
    print(find_cheapest_price(3, flights, 0, 2, 0))  # 500 (direct only)

Floyd Warshall

# ALGORITHM: Floyd-Warshall (All-Pairs Shortest Path)
# CATEGORY: Graph / Dynamic Programming / Shortest Path
# DIFFICULTY: Medium | FREQUENCY: Medium (all-pairs distances, transitive closure at Google/Amazon)

# Solves cyclic and acyclic graphs with negative weights (but no negative cycles).
# Time Complexity: O(V^3)  |  Space Complexity: O(V^2)
# For multiple sources with non-negative weights, running Dijkstra per source can be faster.

# CORE IDEA (INTUITION):
# DP over intermediate vertices: for each k, a path i->j may improve by going i->k->j.
# dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]).

# INVARIANTS:
# - After considering intermediate vertex k, dist reflects shortest paths using only {0..k} as intermediates.
# - Updates happen in place.

# STEP-BY-STEP:
# 1. Init dist with direct edge weights; inf for no edge, 0 on the diagonal.
# 2. For each intermediate k, for each pair (i, j): relax through k.
# 3. A negative diagonal entry signals a negative-weight cycle.

# COMMON MISTAKES:
# - Wrong loop order: the intermediate k MUST be the outermost loop.
# - Not initializing the diagonal to 0.
# - Ignoring negative-cycle detection (dist[i][i] < 0).

# RELATED PROBLEMS:
# - Find the City With the Smallest Number of Neighbors at a Threshold Distance (LC 1334)
# - Course Schedule IV (LC 1462) - transitive closure variant


def floyd_warshall(graph: dict[int, dict[int, int]]) -> list[list[float]]:
    # Build the distance matrix from an adjacency-map representation.
    node_keys = list(graph.keys())
    V = len(node_keys)
    dist: list[list[float]] = [[float("inf")] * V for _ in range(V)]

    for i in range(V):
        for j in range(V):
            src, dst = node_keys[i], node_keys[j]
            if i == j:
                dist[i][j] = 0
            elif dst in graph.get(src, {}):
                dist[i][j] = graph[src][dst]

    # Core Floyd-Warshall
    for intermediate in range(V):
        for source in range(V):
            for destination in range(V):
                new_path = dist[source][intermediate] + dist[intermediate][destination]
                if new_path < dist[source][destination]:
                    dist[source][destination] = new_path

    # Negative-cycle check
    for i in range(V):
        if dist[i][i] < 0:
            raise ValueError("Graph contains a negative weight cycle")

    return dist


# Example usage:
if __name__ == "__main__":
    graph = {
        0: {1: 3, 2: 6},
        1: {2: 2, 3: 1},
        2: {3: 1},
        3: {0: 4},
    }
    print(floyd_warshall(graph))
    # [[0, 3, 5, 4], [5, 0, 2, 1], [5, 8, 0, 1], [4, 7, 9, 0]]

Kosaraju

# ALGORITHM: Kosaraju's Algorithm (Strongly Connected Components)
# CATEGORY: Graph / SCC / DFS
# DIFFICULTY: Hard | FREQUENCY: Medium (SCC / dependency-cycle analysis at Google/competitive)

# WHEN TO USE:
# - Find strongly connected components (SCCs) in a directed graph.
# - Analyze structure in social/web/dependency graphs, or condense a graph into its SCCs.
# - Identify cycles in a directed graph (each non-trivial SCC is a cycle).

# CORE IDEA (INTUITION):
# 1. First DFS on the original graph, pushing nodes onto a stack in finishing-time (post) order.
# 2. Transpose the graph (reverse every edge).
# 3. Second DFS on the transposed graph, popping nodes in decreasing finishing time.
#    Each DFS tree in this pass is one SCC.

# INVARIANTS:
# - Each vertex is visited exactly once per DFS pass.
# - The stack yields vertices in decreasing finishing-time order.
# - The transposed graph has the same vertices with reversed edges.

# TIME: O(V + E)  |  SPACE: O(V)

# COMMON MISTAKES:
# - Reversing the graph incorrectly.
# - Not processing nodes in finishing-time order during the second DFS.
# - Failing to reset the visited marks between the two passes.

# RELATED PROBLEMS:
# - Critical Connections in a Network (LC 1192) - related (Tarjan bridges)
# - Course Schedule II (LC 210) - ordering / cycle detection


def kosaraju(adj: list[list[int]]) -> int:
    n = len(adj)
    stack: list[int] = []
    vis = [False] * n

    # First pass: order by finishing time
    for i in range(n):
        if not vis[i]:
            _dfs(i, adj, vis, stack)

    # Transpose / reverse edges
    adj_t: list[list[int]] = [[] for _ in range(n)]
    for i in range(n):
        vis[i] = False
        for nei in adj[i]:
            adj_t[nei].append(i)

    # Second pass: count SCCs
    scc = 0
    while stack:
        node = stack.pop()
        if not vis[node]:
            scc += 1
            _dfs(node, adj_t, vis, None)
    return scc


def _dfs(i: int, adj: list[list[int]], vis: list[bool], stack: list[int] | None) -> None:
    vis[i] = True
    for nei in adj[i]:
        if not vis[nei]:
            _dfs(nei, adj, vis, stack)
    if stack is not None:
        stack.append(i)


# EXAMPLE:
if __name__ == "__main__":
    adj = [[2, 3], [0], [1], [4], []]
    print(kosaraju(adj))  # 3

Kruskals

# ALGORITHM: Kruskal's Minimum Spanning Tree (MST)
# CATEGORY: Graph / Greedy / MST
# DIFFICULTY: Medium | FREQUENCY: High (MST / connecting-cities problems at Google/Amazon)

# Solves undirected, connected, cyclic graphs with non-negative weights.
# Greedy: always add the smallest edge that doesn't form a cycle.
# Time Complexity: O(E log E) (dominated by sorting the edges).

# CORE IDEA (INTUITION):
# Sort all edges by weight, then add them one by one, skipping any edge that would connect two
# vertices already in the same component (a cycle). Union-Find tracks components efficiently.

# INVARIANTS:
# - The chosen edges never form a cycle.
# - Edges are considered in non-decreasing weight order.

# Requires the Union-Find (Disjoint Set Union) data structure.

# COMMON MISTAKES:
# - Forgetting to sort edges by weight first.
# - Sizing Union-Find for the wrong number of vertices.
# - Not using union()'s boolean result to skip cycle-forming edges.

# RELATED PROBLEMS:
# - Min Cost to Connect All Points (LC 1584)
# - Connecting Cities With Minimum Cost (LC 1135)
# - Optimize Water Distribution in a Village (LC 1168)

from union_find import UnionFind


def spanning_tree(V: int, edges: list[list[int]]) -> int:
    edges.sort(key=lambda e: e[2])
    uf = UnionFind(V)

    total = 0
    for u, v, w in edges:
        if not uf.connected(u, v):
            uf.union(u, v)
            total += w
    return total


# Example usage:
if __name__ == "__main__":
    V = 5
    edges = [
        [0, 1, 2],
        [0, 3, 6],
        [1, 2, 3],
        [1, 3, 8],
        [1, 4, 5],
        [2, 4, 7],
        [3, 4, 9],
    ]
    print(spanning_tree(V, edges))  # 16

Prims

# ALGORITHM: Prim's Minimum Spanning Tree (MST)
# CATEGORY: Graph / Greedy / MST
# DIFFICULTY: Medium | FREQUENCY: High (MST / connecting-points problems at Google/Amazon)

# Solves undirected, connected, cyclic graphs with non-negative weights.
# Greedy: always expand the least-costly edge that connects a new vertex to the growing MST.
# Time Complexity: O(E log V) with a min-heap.

# CORE IDEA (INTUITION):
# Start from an arbitrary vertex. Repeatedly pull the cheapest edge crossing from the MST to a
# vertex not yet included, add that vertex, and push its outgoing edges.

# INVARIANTS:
# - The included vertices always form a connected subtree of minimum total weight so far.
# - The heap holds candidate edges from the MST to outside vertices.

# NOTE: The JavaScript version used a custom MinPriorityQueue; here we use the standard library
# `heapq` with (weight, node) tuples so the heap orders by weight.

import heapq


def spanning_tree(V: int, edges: list[list[int]]) -> int:
    graph: list[list[tuple[int, int]]] = [[] for _ in range(V)]
    for u, v, w in edges:
        graph[u].append((v, w))
        graph[v].append((u, w))

    visited = [False] * V
    pq: list[tuple[int, int]] = [(0, 0)]  # (weight, node)
    total = 0

    while pq:
        weight, node = heapq.heappop(pq)

        if visited[node]:
            continue
        visited[node] = True
        total += weight

        for nei, wei in graph[node]:
            if not visited[nei]:
                heapq.heappush(pq, (wei, nei))

    return total


# Example usage:
if __name__ == "__main__":
    V = 5
    edges = [
        [0, 1, 2],
        [0, 3, 6],
        [1, 2, 3],
        [1, 3, 8],
        [1, 4, 5],
        [2, 4, 7],
        [3, 4, 9],
    ]
    print(spanning_tree(V, edges))  # 16

Tarjan

# ALGORITHM: Tarjan's Algorithm for Bridges (Critical Connections)
# CATEGORY: Graph / DFS / Low-Link
# DIFFICULTY: Hard | FREQUENCY: Medium (critical-connections / network-reliability at Google/Meta)

# PROBLEM STATEMENT:
# Given a graph with n vertices and a list of connections (edges), find all critical connections.
# A critical connection (bridge) is an edge whose removal disconnects some vertices.

# WHEN TO USE:
# - Find all bridges in an undirected graph.
# - Analyze network reliability / critical connections.
# - Connectivity problems (articulation points are a close variant).

# CORE IDEA (INTUITION):
# 1. DFS assigning each vertex a discovery time.
# 2. Track low[v] = earliest discovery time reachable from v's subtree (via tree + back edges).
# 3. Edge (u, v) is a bridge iff low[v] > disc[u]: v cannot reach u or an ancestor without (u, v).

# INVARIANTS:
# - Each vertex is visited once during DFS.
# - Discovery times increase as vertices are first visited.
# - low values are updated from back edges and child subtrees.

# TIME: O(V + E)  |  SPACE: O(V) (+ recursion stack)

# COMMON MISTAKES:
# - Not updating low correctly from back edges.
# - Forgetting the bridge check after returning from a child DFS.
# - Mishandling the parent edge in an undirected graph.

# RELATED PROBLEMS:
# - Critical Connections in a Network (LC 1192)
# - Articulation points (classic variant)


def critical_connections(n: int, connections: list[list[int]]) -> list[list[int]]:
    # Build adjacency list
    graph: list[list[int]] = [[] for _ in range(n)]
    for u, v in connections:
        graph[u].append(v)
        graph[v].append(u)

    disc = [-1] * n  # discovery time
    low = [-1] * n   # lowest reachable discovery time
    time = 0
    result: list[list[int]] = []

    def dfs(node: int, parent: int) -> None:
        nonlocal time
        disc[node] = time
        low[node] = time
        time += 1

        for neighbor in graph[node]:
            if neighbor == parent:
                continue  # skip the edge we came from

            if disc[neighbor] == -1:
                # tree edge
                dfs(neighbor, node)
                low[node] = min(low[node], low[neighbor])
                # bridge condition
                if low[neighbor] > disc[node]:
                    result.append([node, neighbor])
            else:
                # back edge
                low[node] = min(low[node], disc[neighbor])

    dfs(0, -1)  # graph assumed connected
    return result


if __name__ == "__main__":
    print(critical_connections(4, [[0, 1], [1, 2], [2, 0], [1, 3]]))  # [[1, 3]]

Topological Sort

# ALGORITHM: Topological Sort (Kahn's BFS + DFS) + DAG shortest path
# CATEGORY: Graph / Ordering
# DIFFICULTY: Medium | FREQUENCY: Very High (course schedule, build order at Google/Amazon/Meta)

# Kahn's Algorithm (BFS-based):
# - Detects cycles in a directed graph; works only for DAGs to produce a valid ordering.
# Steps:
#   1. Compute indegrees (incoming edge count) for each node.
#   2. Start from nodes with indegree 0 (no dependencies).
#   3. Repeatedly remove an indegree-0 node, decrement its neighbors' indegrees, enqueue new zeros.
#   4. If all nodes are processed, return the order; otherwise a cycle exists.
# Time: O(V + E)  |  Space: O(V)

# COMMON MISTAKES:
# - Using list.pop(0) as a queue (O(n)); use collections.deque.
# - Forgetting to detect a cycle (result length != number of nodes).
# - For DAG shortest path: relaxing in arbitrary order instead of topological order.

# RELATED PROBLEMS:
# - Course Schedule (LC 207)
# - Course Schedule II (LC 210)
# - Alien Dictionary (LC 269)
# - Parallel Courses (LC 1136)

from collections import deque


def topo_sort(graph: dict[int, list[int]]) -> list[int] | None:
    # Step 1: compute indegrees
    indegree: dict[int, int] = {node: 0 for node in graph}
    for node in graph:
        for neighbor in graph[node]:
            indegree[neighbor] = indegree.get(neighbor, 0) + 1

    # Step 2: enqueue all indegree-0 nodes
    queue: deque[int] = deque(node for node in graph if indegree[node] == 0)

    # Step 3: process
    result: list[int] = []
    while queue:
        current = queue.popleft()
        result.append(current)
        for neighbor in graph[current]:
            indegree[neighbor] -= 1
            if indegree[neighbor] == 0:
                queue.append(neighbor)

    # Step 4: cycle check
    if len(result) != len(graph):
        return None  # cycle detected

    return result


# Alternative approach: DFS-based topological sort
def topo_sort_dfs(graph: dict[int, list[int]]) -> list[int]:
    visited: set[int] = set()
    stack: list[int] = []

    def dfs(node: int) -> None:
        visited.add(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                dfs(neighbor)
        stack.append(node)

    for node in graph:
        if node not in visited:
            dfs(node)

    return stack[::-1]


# Shortest Path in a DAG using topological sort.
# - Handles negative weights on acyclic graphs (unlike Dijkstra).
# - Time: O(V + E)
def shortest_path_dag(graph: dict[int, list[tuple[int, int]]], src: int) -> list[float]:
    n = len(graph)
    dist: list[float] = [float("inf")] * n
    dist[src] = 0

    stack: list[int] = []
    visited: set[int] = set()

    def t_sort(node: int) -> None:
        visited.add(node)
        for neighbor, _weight in graph[node]:
            if neighbor not in visited:
                t_sort(neighbor)
        stack.append(node)

    for node in graph:
        if node not in visited:
            t_sort(node)

    while stack:
        node = stack.pop()
        if dist[node] != float("inf"):  # relax only reachable nodes
            for neighbor, weight in graph[node]:
                if dist[node] + weight < dist[neighbor]:
                    dist[neighbor] = dist[node] + weight

    return dist


# Course Schedule II using DFS-based topological sort with cycle detection.
def find_order(num_courses: int, prerequisites: list[list[int]]) -> list[int]:
    res: list[int] = []

    pre_set: list[list[int]] = [[] for _ in range(num_courses)]
    for crs, pre in prerequisites:
        pre_set[crs].append(pre)

    visit: set[int] = set()
    cycle: set[int] = set()

    def dfs(crs: int) -> bool:
        if crs in cycle:
            return False
        if crs in visit:
            return True

        cycle.add(crs)
        for pre in pre_set[crs]:
            if not dfs(pre):
                return False
        cycle.remove(crs)
        visit.add(crs)
        res.append(crs)
        return True

    for c in range(num_courses):
        if not dfs(c):
            return []
    return res


if __name__ == "__main__":
    graph = {
        0: [1],
        1: [4],
        2: [3, 5],
        3: [4],
        4: [],
        5: [1],
        6: [0],
    }
    print("output with BFS:", topo_sort(graph))  # e.g. [2, 6, 3, 5, 0, 1, 4]
    print("output with DFS:", topo_sort_dfs(graph))  # e.g. [6, 2, 5, 3, 0, 1, 4]

    weighted_graph = {
        0: [(1, 2), (4, 1)],
        1: [(2, 3)],
        2: [(3, 6)],
        3: [],
        4: [(5, 4), (2, 2)],
        5: [(3, 1)],
    }
    print("Shortest paths from source 0:", shortest_path_dag(weighted_graph, 0))

    # [0, 2, 1, 3] or [0, 1, 2, 3]
    print("Course Schedule II order:", find_order(4, [[1, 0], [2, 0], [3, 1], [3, 2]]))

Union Find

# ALGORITHM: Union-Find (Disjoint Set Union) with path compression + union by rank
# CATEGORY: Graph / Data Structure
# DIFFICULTY: Medium | FREQUENCY: Very High (connectivity, MST, grouping at Google/Amazon/Meta)

# Detect cycles in an undirected graph, track connected components, and back Kruskal's MST.
# Algorithm steps:
# 1. Initialize each element as its own parent (self root) with rank 0.
# 2. For each union, find the roots of both elements.
# 3. If roots differ, union them by rank.
# 4. If roots are the same, a cycle is detected.

# Time Complexity: O(alpha(N)) per operation (inverse Ackermann - effectively O(1) amortized).
# Space Complexity: O(N) for parent and rank arrays.

# Uses of Union-Find:
# 1. Cycle detection in undirected graphs
# 2. Connected components in graphs
# 3. Kruskal's algorithm for Minimum Spanning Tree
# 4. Network connectivity problems

# COMMON MISTAKES:
# - Skipping path compression / union by rank (degrades to O(n) per op).
# - Forgetting union() should report whether a merge happened (useful for cycle detection).
# - Sizing parent/rank arrays incorrectly for the number of nodes.

# RELATED PROBLEMS:
# - Number of Connected Components in an Undirected Graph (LC 323)
# - Redundant Connection (LC 684)
# - Accounts Merge (LC 721)
# - Checking Existence of Edge Length Limited Paths (LC 1697)


class UnionFind:
    def __init__(self, n: int) -> None:
        self.parent = list(range(n))  # each element is its own parent
        self.rank = [0] * n           # initial rank is 0

    def find(self, x: int) -> int:
        # Path compression
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x: int, y: int) -> bool:
        root_x = self.find(x)
        root_y = self.find(y)

        if root_x == root_y:
            return False  # already in the same set

        # Union by rank
        if self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        elif self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1
        return True

    def connected(self, x: int, y: int) -> bool:
        return self.find(x) == self.find(y)


# Example usage:
if __name__ == "__main__":
    uf = UnionFind(7)  # nodes 0-6
    uf.union(1, 2)
    uf.union(2, 3)
    uf.union(4, 5)
    uf.union(5, 6)

    print(uf.connected(1, 3))  # True
    print(uf.connected(1, 4))  # False
    uf.union(3, 4)
    print(uf.connected(1, 4))  # True

Sorting

Merge Sort

# ALGORITHM: Merge Sort (+ count-smaller-after-self, sort-list)
# CATEGORY: Sorting / Divide and Conquer
# DIFFICULTY: Medium | FREQUENCY: High (stable sort + merge-based counting at Google/Amazon)

# A divide-and-conquer algorithm: split the array in half, sort each half, then merge.

# Time Complexity: O(n log n)
# Space Complexity: O(n) for the temporary arrays used during merging.

# Steps:
# 1. An array of 0 or 1 elements is already sorted.
# 2. Split the array into two halves.
# 3. Recursively merge-sort each half.
# 4. Merge the two sorted halves.

# COMMON MISTAKES:
# - Allocating new lists on every merge without freeing (memory), or merging in the wrong order.
# - For count-smaller: incrementing the count at the wrong pointer (must credit the LEFT element).
# - For sort-list: not cutting the list at the midpoint (prev.next = None) causing infinite recursion.

# RELATED PROBLEMS:
# - Sort an Array (LC 912)
# - Count of Smaller Numbers After Self (LC 315)
# - Sort List (LC 148)
# - Merge Sorted Array (LC 88)

from __future__ import annotations


# === CLASSIC MERGE SORT ===
def merge_sort(arr: list[int]) -> list[int]:
    if len(arr) <= 1:
        return arr

    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])

    return _merge(left, right)


def _merge(left: list[int], right: list[int]) -> list[int]:
    merged: list[int] = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            merged.append(left[i])
            i += 1
        else:
            merged.append(right[j])
            j += 1
    merged.extend(left[i:])
    merged.extend(right[j:])
    return merged


# === COUNT OF SMALLER NUMBERS AFTER SELF (LC 315) ===
# counts[i] = number of elements to the right of nums[i] that are smaller than nums[i].
def count_smaller(nums: list[int]) -> list[int]:
    if not nums:
        return []

    counts = [0] * len(nums)
    # Pair each value with its original index to track positions during merge.
    elements = list(enumerate(nums))  # (original_index, value)

    def sort_and_count(left: int, right: int) -> list[tuple[int, int]]:
        if left == right:
            return [elements[left]]

        mid = (left + right) // 2
        sorted_left = sort_and_count(left, mid)
        sorted_right = sort_and_count(mid + 1, right)
        return merge_and_count(sorted_left, sorted_right)

    def merge_and_count(left_arr: list[tuple[int, int]],
                        right_arr: list[tuple[int, int]]) -> list[tuple[int, int]]:
        merged: list[tuple[int, int]] = []
        i = j = 0
        while i < len(left_arr) and j < len(right_arr):
            # If a left element is greater, all remaining right elements are smaller.
            if left_arr[i][1] > right_arr[j][1]:
                counts[left_arr[i][0]] += len(right_arr) - j
                merged.append(left_arr[i])
                i += 1
            else:
                merged.append(right_arr[j])
                j += 1
        merged.extend(left_arr[i:])
        merged.extend(right_arr[j:])
        return merged

    sort_and_count(0, len(nums) - 1)
    return counts


# === SORT LIST (LC 148) - merge sort on a linked list ===
class ListNode:
    def __init__(self, val: int = 0, next: ListNode | None = None) -> None:
        self.val = val
        self.next = next


def sort_list(head: ListNode | None) -> ListNode | None:
    if not head or not head.next:
        return head

    # Split at the middle using slow/fast pointers.
    slow, fast, prev = head, head, head
    while fast and fast.next:
        prev = slow
        slow = slow.next
        fast = fast.next.next
    prev.next = None  # cut the list

    left = sort_list(head)
    right = sort_list(slow)
    return _merge_lists(left, right)


def _merge_lists(left: ListNode | None, right: ListNode | None) -> ListNode | None:
    dummy = ListNode(-1)
    cur = dummy
    while left and right:
        if left.val < right.val:
            cur.next = left
            left = left.next
        else:
            cur.next = right
            right = right.next
        cur = cur.next
    cur.next = left if left else right
    return dummy.next


# Helpers for the linked-list demo
def _build(arr: list[int]) -> ListNode | None:
    dummy = ListNode(0)
    cur = dummy
    for v in arr:
        cur.next = ListNode(v)
        cur = cur.next
    return dummy.next


def _to_list(head: ListNode | None) -> list[int]:
    out: list[int] = []
    while head:
        out.append(head.val)
        head = head.next
    return out


# Example usage:
if __name__ == "__main__":
    print(merge_sort([5, 2, 6, 1]))       # [1, 2, 5, 6]
    print(count_smaller([5, 2, 6, 1]))    # [2, 1, 1, 0]
    print(count_smaller([-1]))            # [0]
    print(_to_list(sort_list(_build([4, 2, 1, 3]))))  # [1, 2, 3, 4]

Quick Select

# ALGORITHM: Quick Select (Kth smallest/largest in O(n) average)
# CATEGORY: Sorting / Selection / Divide and Conquer
# DIFFICULTY: Medium | FREQUENCY: Very High (Kth-largest, K-closest at Google/Meta/Amazon)

# Quick Select finds the k-th smallest (or largest) element without fully sorting.
# It partitions around a pivot like Quick Sort, then recurses only into the side containing the target.

# Time Complexity: O(n) average, O(n^2) worst case (mitigated by good pivot choice).
# Space Complexity: O(1) iterative, O(log n) recursive.

# Steps (Hoare's partitioning):
# 1. Choose a pivot.
# 2. Partition into < pivot and > pivot.
# 3. Recurse only into the partition containing the target index.

# COMMON MISTAKES:
# - Converting k-th largest to the wrong index (k-th largest = index n-k, 0-indexed).
# - Recursing into both partitions (that's Quick Sort, defeating the O(n) benefit).
# - Comparing distances with sqrt (unnecessary; compare squared distances).

# RELATED PROBLEMS:
# - Kth Largest Element in an Array (LC 215)
# - K Closest Points to Origin (LC 973)
# - Top K Frequent Elements (LC 347)


def quick_select(low: int, high: int, nums: list[int], target: int) -> int:
    i, j = low, high
    pivot = nums[(i + j) // 2]

    while i <= j:
        while i <= j and nums[i] < pivot:
            i += 1
        while i <= j and nums[j] > pivot:
            j -= 1
        if i <= j:
            nums[i], nums[j] = nums[j], nums[i]
            i += 1
            j -= 1

    # After partitioning: [low..j] <= pivot, [i..high] >= pivot, and j < i.
    if i <= target:
        return quick_select(i, high, nums, target)
    if j >= target:
        return quick_select(low, j, nums, target)
    return nums[target]


def find_kth_largest(nums: list[int], k: int) -> int:
    # k-th largest = (n-k)-th smallest (0-indexed)
    target = len(nums) - k
    return quick_select(0, len(nums) - 1, nums, target)


# Problem: K closest points to origin.
# Distance from origin compared as x^2 + y^2 (no sqrt needed for ordering).
def k_closest(points: list[list[int]], k: int) -> list[list[int]]:
    def distance(point: list[int]) -> int:
        return point[0] * point[0] + point[1] * point[1]

    def select(low: int, high: int, target: int) -> list[list[int]]:
        i, j = low, high
        pivot_dist = distance(points[(i + j) // 2])
        while i <= j:
            while i <= j and distance(points[i]) < pivot_dist:
                i += 1
            while i <= j and distance(points[j]) > pivot_dist:
                j -= 1
            if i <= j:
                points[i], points[j] = points[j], points[i]
                i += 1
                j -= 1

        if i <= target:
            return select(i, high, target)
        if j >= target:
            return select(low, j, target)
        return points[:target + 1]

    return select(0, len(points) - 1, k - 1)


# Example usage:
if __name__ == "__main__":
    print("quick select:")
    print(find_kth_largest([3, 2, 1, 5, 6, 4], 2))              # 5
    print(find_kth_largest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4))     # 4

    print("k closest points to origin:")
    print(k_closest([[1, 3], [-2, 2]], 1))                      # [[-2, 2]]
    print(k_closest([[3, 3], [5, -1], [-2, 4]], 2))             # [[3, 3], [-2, 4]]

Quick Sort

# ALGORITHM: Quick Sort (Hoare-style partitioning)
# CATEGORY: Sorting / Divide and Conquer
# DIFFICULTY: Medium | FREQUENCY: High (in-place sort fundamentals at Google/Amazon/Microsoft)

# Time Complexity: O(n log n) average, O(n^2) worst case.
# Space Complexity: O(log n) due to the recursion stack.

# Steps (Hoare's partitioning):
# 1. Choose a pivot (here, the middle element).
# 2. Partition: move smaller elements left, larger elements right.
# 3. Recurse on both partitions until sorted.

# COMMON MISTAKES:
# - Always picking the first/last element as pivot (worst case on sorted input).
# - Off-by-one in the partition loop leading to infinite recursion.
# - Recursing on the wrong sub-ranges after pointers cross.

# RELATED PROBLEMS:
# - Sort an Array (LC 912)
# - Sort Colors (LC 75) - Dutch National Flag partitioning
# - Kth Largest Element (LC 215) - see quick_select.py


def quick_sort(nums: list[int]) -> list[int]:
    def sort(low: int, high: int) -> None:
        if low >= high:
            return

        i, j = low, high
        pivot = nums[(i + j) // 2]

        while i <= j:
            while i <= j and nums[i] < pivot:
                i += 1
            while i <= j and nums[j] > pivot:
                j -= 1
            if i <= j:
                nums[i], nums[j] = nums[j], nums[i]
                i += 1
                j -= 1

        sort(low, j)   # left partition (elements <= pivot)
        sort(i, high)  # right partition (elements >= pivot)

    sort(0, len(nums) - 1)
    return nums


# Example usage:
if __name__ == "__main__":
    print("quick sort:")
    print(quick_sort([3, 6, 8, 10, 1, 2, 1]))  # [1, 1, 2, 3, 6, 8, 10]

String

Kmp

# ALGORITHM: KMP (Knuth-Morris-Pratt) String Search
# CATEGORY: String / Pattern Matching
# DIFFICULTY: Hard | FREQUENCY: Medium (substring search, repeated-pattern at Google/Amazon)

# PROBLEM STATEMENT:
# Given a text and a pattern, find the starting index of the pattern's first occurrence using KMP.

# WHEN TO USE:
# - Efficient substring search within a larger string.
# - Avoid re-checking characters already matched.
# - Long patterns/texts where naive O(n*m) search is too slow.

# Rabin-Karp vs KMP:
# - Rabin-Karp hashes windows; good for multiple-pattern search.
# - KMP preprocesses the pattern to skip comparisons; O(n + m) guaranteed in all cases.

# CORE IDEA (INTUITION):
# Precompute the LPS (Longest Prefix which is also Suffix) array for the pattern. On a mismatch,
# jump the pattern pointer using LPS instead of re-scanning the text.

# INVARIANTS:
# - lps[i] = length of the longest proper prefix of pattern[0..i] that is also a suffix.
# - On mismatch, the text pointer never moves backward.

# TIME: O(n + m)  |  SPACE: O(m) for the LPS array

# COMMON MISTAKES:
# - Building the LPS array incorrectly (mishandling the fallback while-loop).
# - Moving the text pointer back on a mismatch.
# - Not handling empty pattern/text edge cases.

# RELATED PROBLEMS:
# - Find the Index of the First Occurrence in a String (LC 28)
# - Repeated Substring Pattern (LC 459)
# - Shortest Palindrome (LC 214)


# Build the Longest-Proper-Prefix-which-is-also-Suffix array.
def build_lps(s: str) -> list[int]:
    lps = [0] * len(s)

    for i in range(1, len(lps)):
        prev_idx = lps[i - 1]  # start with the previous LPS length

        # Jump back until a match or the start
        while prev_idx > 0 and s[i] != s[prev_idx]:
            prev_idx = lps[prev_idx - 1]

        # Extend the length if characters match; otherwise it stays 0
        lps[i] = prev_idx + (1 if s[i] == s[prev_idx] else 0)

    return lps


# Search for 'pattern' in 'text'; return the starting index or -1 if not found.
def kmp(text: str, pattern: str) -> int:
    if not pattern:
        return 0

    lps = build_lps(pattern)
    i = 0  # text pointer
    j = 0  # pattern pointer

    while i < len(text):
        if text[i] == pattern[j]:
            i += 1
            j += 1
            if j == len(pattern):
                return i - j  # found; starting index
        elif j > 0:
            j = lps[j - 1]  # mismatch: reuse previous match info (don't move i)
        else:
            i += 1  # no match at the pattern start; advance text pointer

    return -1  # not found


# EXAMPLE USAGE:
# text:    ababcabcabababd
# pattern:            ababd  -> first occurrence starts at index 10
if __name__ == "__main__":
    print(build_lps("ababd"))               # [0, 0, 1, 2, 0]
    print(kmp("ababcabcabababd", "ababd"))  # 10

Rabin Karp

# ALGORITHM: Rabin-Karp String Search (rolling hash)
# CATEGORY: String / Pattern Matching / Hashing
# DIFFICULTY: Medium | FREQUENCY: Medium (substring search, plagiarism/dup detection at Google/Amazon)

# PROBLEM STATEMENT:
# Given a text and a pattern, find the starting index of the pattern's first occurrence using Rabin-Karp.

# WHEN TO USE:
# - Efficient substring search, especially for multiple patterns in the same text.
# - When a rolling hash can turn window comparisons into O(1) updates.

# CORE IDEA (INTUITION):
# Hash the pattern and each text window of the same length. Compare hashes first; only on a hash
# match do a direct character comparison to rule out collisions. A rolling hash updates the window
# hash in O(1) as it slides.

# INVARIANTS:
# - The hash function minimizes collisions (large prime modulus).
# - The rolling update correctly removes the outgoing char and adds the incoming char.

# TIME: O(n + m) average, O(n * m) worst case (many collisions)  |  SPACE: O(1)

# COMMON MISTAKES:
# - Skipping the character-by-character verification (hash collisions cause false positives).
# - Incorrect rolling-hash update (wrong power weight or missing modulo).
# - Using a modulus small enough to overflow / collide often.

# RELATED PROBLEMS:
# - Find the Index of the First Occurrence in a String (LC 28)
# - Repeated DNA Sequences (LC 187)
# - Longest Duplicate Substring (LC 1044)


def rabin_karp(text: str, pattern: str) -> int:
    BASE = 256          # treat chars as base-256 (ASCII range)
    MOD = 10**9 + 7     # large prime to avoid overflow / reduce collisions
    m = len(pattern)
    n = len(text)

    if m > n:
        return -1

    # STEP 1: hash of the pattern
    pattern_hash = 0
    for i in range(m):
        pattern_hash = (pattern_hash * BASE + ord(pattern[i])) % MOD

    # STEP 2: precompute BASE^(m-1) % MOD (weight of the leftmost char)
    power = 1
    for _ in range(m - 1):
        power = (power * BASE) % MOD

    # STEP 3: hash of the first window
    current_hash = 0
    for i in range(m):
        current_hash = (current_hash * BASE + ord(text[i])) % MOD

    def verify_match(start: int) -> bool:
        return text[start:start + m] == pattern

    # STEP 4: check the first window
    if current_hash == pattern_hash and verify_match(0):
        return 0

    # STEP 5: slide the window with a rolling hash
    for i in range(m, n):
        old_char = text[i - m]  # leaving the window
        current_hash = (current_hash - (ord(old_char) * power) % MOD + MOD) % MOD

        new_char = text[i]      # entering the window
        current_hash = (current_hash * BASE + ord(new_char)) % MOD

        # STEP 6: hash match + verification
        if current_hash == pattern_hash and verify_match(i - m + 1):
            return i - m + 1

    return -1


# EXAMPLE USAGE:
if __name__ == "__main__":
    print(rabin_karp("ababcabcabababd", "ababd"))  # 10