DSA — Python

Data Structures & Algorithms

Python solutions with time/space complexity, approach explanations, and best-to-worst comparisons. Click any card to expand.

Arrays & Strings

Two Sum

EasyHigh FreqHash Map

Given an array nums and integer target, return indices of two numbers that add to target.

Key Insight
For each number, check if its complement (target - num) exists in a hash map. Single pass O(n).
def twoSum(nums, target):
    seen = {}  # val -> index
    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return [seen[complement], i]
        seen[num] = i
    return []

# Time: O(n), Space: O(n)

Hash Map Pattern

  • Store values seen so far with their index
  • Check for complement in O(1)
  • Avoid brute-force O(n²) nested loop
Time O(n)
Space O(n)

3Sum

MediumHigh FreqTwo Pointers

Find all unique triplets that sum to zero.

Approach
Sort array. Fix first element, use two-pointer on rest. Skip duplicates carefully.
def threeSum(nums):
    nums.sort()
    result = []
    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i-1]:
            continue  # skip duplicates
        l, r = i + 1, len(nums) - 1
        while l < r:
            s = nums[i] + nums[l] + nums[r]
            if s == 0:
                result.append([nums[i], nums[l], nums[r]])
                while l < r and nums[l] == nums[l+1]: l += 1
                while l < r and nums[r] == nums[r-1]: r -= 1
                l += 1; r -= 1
            elif s < 0:
                l += 1
            else:
                r -= 1
    return result

# Time: O(n²), Space: O(1)
Time O(n²)
Space O(1)

Maximum Subarray (Kadane's)

MediumHigh FreqKadane's DP
def maxSubArray(nums):
    max_sum = nums[0]
    curr_sum = nums[0]
    for num in nums[1:]:
        # Either start fresh or extend current subarray
        curr_sum = max(num, curr_sum + num)
        max_sum = max(max_sum, curr_sum)
    return max_sum

# Time: O(n), Space: O(1)

Kadane's Algorithm

  • curr_sum = max(num, curr_sum + num)
  • If current element alone is bigger → restart
  • Track global max throughout

Product of Array Except Self

MediumPrefix/Suffix
def productExceptSelf(nums):
    n = len(nums)
    result = [1] * n
    # Left pass: result[i] = product of all elements left of i
    prefix = 1
    for i in range(n):
        result[i] = prefix
        prefix *= nums[i]
    # Right pass: multiply by product of all elements right of i
    suffix = 1
    for i in range(n-1, -1, -1):
        result[i] *= suffix
        suffix *= nums[i]
    return result

# Time: O(n), Space: O(1) — no division!
No Division Needed
Use prefix product left pass + suffix product right pass. O(n) time, O(1) extra space.

Longest Substring Without Repeating Characters

MediumHigh FreqSliding Window
def lengthOfLongestSubstring(s):
    char_index = {}  # char -> last seen index
    max_len = 0
    left = 0
    for right, char in enumerate(s):
        if char in char_index and char_index[char] >= left:
            left = char_index[char] + 1  # shrink window
        char_index[char] = right
        max_len = max(max_len, right - left + 1)
    return max_len

# Time: O(n), Space: O(min(m,n)) where m = charset size
Time O(n)
Space O(min(m,n))

Minimum Window Substring

HardSliding Window
from collections import Counter

def minWindow(s, t):
    need = Counter(t)
    have, total_needed = 0, len(need)
    window = {}
    result = ""
    min_len = float('inf')
    left = 0

    for right, c in enumerate(s):
        window[c] = window.get(c, 0) + 1
        if c in need and window[c] == need[c]:
            have += 1

        while have == total_needed:
            if right - left + 1 < min_len:
                min_len = right - left + 1
                result = s[left:right+1]
            # Shrink window from left
            window[s[left]] -= 1
            if s[left] in need and window[s[left]] < need[s[left]]:
                have -= 1
            left += 1

    return result

# Time: O(|s|+|t|), Space: O(|s|+|t|)

Group Anagrams

MediumHash Map
from collections import defaultdict

def groupAnagrams(strs):
    groups = defaultdict(list)
    for word in strs:
        key = tuple(sorted(word))  # sorted chars as key
        groups[key].append(word)
    return list(groups.values())

# Alternative: use Counter as key (need frozenset)
# Time: O(n * k log k) where k = max string len
# Space: O(n * k)

Container With Most Water

MediumTwo Pointers
def maxArea(height):
    l, r = 0, len(height) - 1
    max_water = 0
    while l < r:
        water = min(height[l], height[r]) * (r - l)
        max_water = max(max_water, water)
        # Move the shorter pointer inward (greedy)
        if height[l] < height[r]:
            l += 1
        else:
            r -= 1
    return max_water

# Time: O(n), Space: O(1)

Trapping Rain Water

HardTwo Pointers
def trap(height):
    l, r = 0, len(height) - 1
    left_max, right_max = 0, 0
    water = 0
    while l <= r:
        if height[l] <= height[r]:
            if height[l] >= left_max:
                left_max = height[l]
            else:
                water += left_max - height[l]
            l += 1
        else:
            if height[r] >= right_max:
                right_max = height[r]
            else:
                water += right_max - height[r]
            r -= 1
    return water

# Time: O(n), Space: O(1)
# Key: water at position = min(left_max, right_max) - height[i]
Trees

Binary Tree Level Order Traversal

MediumBFS
from collections import deque

def levelOrder(root):
    if not root: return []
    result = []
    queue = deque([root])
    while queue:
        level_size = len(queue)
        level = []
        for _ in range(level_size):
            node = queue.popleft()
            level.append(node.val)
            if node.left:  queue.append(node.left)
            if node.right: queue.append(node.right)
        result.append(level)
    return result

# Template for ALL level-order problems
# Time: O(n), Space: O(n)

Lowest Common Ancestor of BST / Binary Tree

MediumHigh FreqDFS
# Binary Tree LCA (general)
def lowestCommonAncestor(root, p, q):
    if not root or root == p or root == q:
        return root
    left  = lowestCommonAncestor(root.left,  p, q)
    right = lowestCommonAncestor(root.right, p, q)
    # If both subtrees return non-null, root is LCA
    if left and right:
        return root
    return left or right

# BST LCA (use BST property)
def lcaBST(root, p, q):
    while root:
        if p.val < root.val and q.val < root.val:
            root = root.left
        elif p.val > root.val and q.val > root.val:
            root = root.right
        else:
            return root  # split point = LCA

Validate Binary Search Tree

MediumDFSBounds
def isValidBST(root, min_val=float('-inf'), max_val=float('inf')):
    if not root:
        return True
    if not (min_val < root.val < max_val):
        return False
    return (isValidBST(root.left, min_val, root.val) and
            isValidBST(root.right, root.val, max_val))

# Key: pass bounds down the recursion
# NOT just checking left.val < root.val < right.val (wrong!)

Serialize and Deserialize Binary Tree

HardBFS
from collections import deque

class Codec:
    def serialize(self, root):
        if not root: return "null"
        result = []
        queue = deque([root])
        while queue:
            node = queue.popleft()
            if node:
                result.append(str(node.val))
                queue.append(node.left)
                queue.append(node.right)
            else:
                result.append("null")
        return ",".join(result)

    def deserialize(self, data):
        vals = data.split(",")
        if vals[0] == "null": return None
        root = TreeNode(int(vals[0]))
        queue = deque([root])
        i = 1
        while queue:
            node = queue.popleft()
            if vals[i] != "null":
                node.left = TreeNode(int(vals[i]))
                queue.append(node.left)
            i += 1
            if vals[i] != "null":
                node.right = TreeNode(int(vals[i]))
                queue.append(node.right)
            i += 1
        return root

Path Sum II (All root-to-leaf paths)

MediumDFS + Backtrack
def pathSum(root, targetSum):
    result = []
    def dfs(node, remaining, path):
        if not node: return
        path.append(node.val)
        # Leaf node and sum matches
        if not node.left and not node.right and remaining == node.val:
            result.append(path[:])  # snapshot of path
        else:
            dfs(node.left,  remaining - node.val, path)
            dfs(node.right, remaining - node.val, path)
        path.pop()  # backtrack
    dfs(root, targetSum, [])
    return result
Graphs

Number of Islands

MediumHigh FreqDFS/BFS
def numIslands(grid):
    if not grid: return 0
    rows, cols = len(grid), len(grid[0])
    count = 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'  # mark visited (in-place)
        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':
                count += 1
                dfs(r, c)
    return count

# Time: O(m*n), Space: O(m*n) recursion stack

Clone Graph

MediumHigh FreqBFS + Hash Map
from collections import deque

def cloneGraph(node):
    if not node: return None
    cloned = {}  # original -> clone
    cloned[node] = Node(node.val)
    queue = deque([node])

    while queue:
        curr = queue.popleft()
        for neighbor in curr.neighbors:
            if neighbor not in cloned:
                cloned[neighbor] = Node(neighbor.val)
                queue.append(neighbor)
            cloned[curr].neighbors.append(cloned[neighbor])

    return cloned[node]

# Key: map original node to its clone
# Time: O(V+E), Space: O(V)

Course Schedule (Cycle Detection / Topological Sort)

MediumHigh FreqTopological Sort
from collections import deque

def canFinish(numCourses, prerequisites):
    # Build adjacency list + in-degree array
    graph = [[] for _ in range(numCourses)]
    in_degree = [0] * numCourses
    for a, b in prerequisites:
        graph[b].append(a)
        in_degree[a] += 1

    # Kahn's Algorithm (BFS topological sort)
    queue = deque([i for i in range(numCourses) if in_degree[i] == 0])
    completed = 0
    while queue:
        course = queue.popleft()
        completed += 1
        for next_course in graph[course]:
            in_degree[next_course] -= 1
            if in_degree[next_course] == 0:
                queue.append(next_course)

    return completed == numCourses  # False if cycle exists

# DFS approach (also valid):
# State: 0=unvisited, 1=visiting(in stack), 2=visited
# If visit a node with state=1 → cycle detected

Word Ladder (BFS Shortest Path)

HardBFSString
from collections import deque

def ladderLength(beginWord, endWord, wordList):
    word_set = set(wordList)
    if endWord not in word_set: return 0

    queue = deque([(beginWord, 1)])  # (word, steps)
    visited = {beginWord}

    while queue:
        word, steps = queue.popleft()
        for i in range(len(word)):
            for c in 'abcdefghijklmnopqrstuvwxyz':
                new_word = word[:i] + c + word[i+1:]
                if new_word == endWord:
                    return steps + 1
                if new_word in word_set and new_word not in visited:
                    visited.add(new_word)
                    queue.append((new_word, steps + 1))
    return 0

# BFS guarantees shortest path

Pacific Atlantic Water Flow

MediumMulti-Source BFS
def pacificAtlantic(heights):
    rows, cols = len(heights), len(heights[0])
    pacific = set(); atlantic = set()

    def bfs(starts, visited):
        queue = deque(starts)
        visited.update(starts)
        while queue:
            r, c = queue.popleft()
            for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                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))

    pacific_starts = [(0,c) for c in range(cols)] + [(r,0) for r in range(rows)]
    atlantic_starts = [(rows-1,c) for c in range(cols)] + [(r,cols-1) for r in range(rows)]
    bfs(pacific_starts, pacific)
    bfs(atlantic_starts, atlantic)
    return list(pacific & atlantic)  # intersection
Dynamic Programming

Climbing Stairs

EasyDP
def climbStairs(n):
    if n <= 2: return n
    dp_prev2, dp_prev1 = 1, 2
    for _ in range(3, n+1):
        dp_prev2, dp_prev1 = dp_prev1, dp_prev1 + dp_prev2
    return dp_prev1
# Fibonacci pattern: f(n) = f(n-1) + f(n-2)

Coin Change

MediumHigh FreqBottom-Up DP
def coinChange(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0  # base case: 0 coins to make 0
    for amt in range(1, amount + 1):
        for coin in coins:
            if coin <= amt:
                dp[amt] = min(dp[amt], dp[amt - coin] + 1)
    return dp[amount] if dp[amount] != float('inf') else -1

# Time: O(amount * len(coins)), Space: O(amount)
# dp[i] = min coins to make amount i

Longest Common Subsequence

Medium2D DP
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] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

# Time: O(m*n), Space: O(m*n)
# Recurrence:
# if match: dp[i][j] = dp[i-1][j-1] + 1
# else:     dp[i][j] = max(dp[i-1][j], dp[i][j-1])

Word Break

MediumDP + Set
def wordBreak(s, wordDict):
    word_set = set(wordDict)
    n = len(s)
    dp = [False] * (n+1)
    dp[0] = True  # empty string is always segmentable

    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]

# Time: O(n^2), Space: O(n)

Longest Palindromic Substring

MediumExpand Around Center
def longestPalindrome(s):
    result = ""
    def expand(l, r):
        while l >= 0 and r < len(s) and s[l] == s[r]:
            l -= 1; r += 1
        return s[l+1:r]  # valid palindrome

    for i in range(len(s)):
        odd  = expand(i, i)    # odd length
        even = expand(i, i+1)  # even length
        if len(odd)  > len(result): result = odd
        if len(even) > len(result): result = even
    return result

# Time: O(n^2), Space: O(1)
Linked Lists

Reverse Linked List

EasyIterative + Recursive
# Iterative
def reverseList(head):
    prev, curr = None, head
    while curr:
        nxt = curr.next
        curr.next = prev
        prev = curr
        curr = nxt
    return prev

# Recursive
def reverseListRec(head):
    if not head or not head.next:
        return head
    new_head = reverseListRec(head.next)
    head.next.next = head
    head.next = None
    return new_head

Linked List Cycle II (Floyd's Algorithm)

MediumFloyd's Tortoise & Hare
def detectCycle(head):
    slow = fast = head
    # Phase 1: Detect cycle
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            break
    else:
        return None  # no cycle

    # Phase 2: Find cycle start
    # Move one pointer to head, advance both 1 step at a time
    slow = head
    while slow != fast:
        slow = slow.next
        fast = fast.next
    return slow  # meeting point = cycle start

Merge K Sorted Lists

HardMin Heap
import heapq

def mergeKLists(lists):
    heap = []
    for i, node in enumerate(lists):
        if node:
            heapq.heappush(heap, (node.val, i, node))

    dummy = ListNode(0)
    curr = dummy
    while heap:
        val, i, node = heapq.heappop(heap)
        curr.next = node
        curr = curr.next
        if node.next:
            heapq.heappush(heap, (node.next.val, i, node.next))
    return dummy.next

# Time: O(N log k) where N=total nodes, k=number of lists
Heaps & Priority Queues

Top K Frequent Elements

MediumHigh FreqHeap / Bucket Sort
import heapq
from collections import Counter

def topKFrequent(nums, k):
    count = Counter(nums)
    # nlargest: uses heap internally O(n log k)
    return heapq.nlargest(k, count.keys(), key=count.get)

# Alternative: Bucket Sort O(n)
def topKFrequentBucket(nums, k):
    count = Counter(nums)
    buckets = [[] for _ in range(len(nums)+1)]
    for num, freq in count.items():
        buckets[freq].append(num)
    result = []
    for i in range(len(buckets)-1, -1, -1):
        result.extend(buckets[i])
        if len(result) >= k:
            return result[:k]
    return result

K Closest Points to Origin

MediumMax Heap
import heapq

def kClosest(points, k):
    # Use max-heap of size k (negate distance)
    heap = []
    for x, y in points:
        dist = -(x*x + y*y)
        heapq.heappush(heap, (dist, x, y))
        if len(heap) > k:
            heapq.heappop(heap)
    return [[x, y] for _, x, y in heap]

# Or simpler: sort by distance
def kClosestSimple(points, k):
    points.sort(key=lambda p: p[0]**2 + p[1]**2)
    return points[:k]

Find Median from Data Stream

HardTwo Heaps
import heapq

class MedianFinder:
    def __init__(self):
        self.small = []  # max-heap (negate) — left half
        self.large = []  # min-heap — right half

    def addNum(self, num):
        heapq.heappush(self.small, -num)
        # Ensure max of small <= min of large
        if self.small and self.large and (-self.small[0] > self.large[0]):
            heapq.heappush(self.large, -heapq.heappop(self.small))
        # Balance sizes
        if len(self.small) > len(self.large) + 1:
            heapq.heappush(self.large, -heapq.heappop(self.small))
        if len(self.large) > len(self.small):
            heapq.heappush(self.small, -heapq.heappop(self.large))

    def findMedian(self):
        if len(self.small) > len(self.large):
            return -self.small[0]
        return (-self.small[0] + self.large[0]) / 2
Stack / Queue / Design

Valid Parentheses

EasyStack
def isValid(s):
    stack = []
    mapping = {')': '(', '}': '{', ']': '['}
    for char in s:
        if char in mapping:
            top = stack.pop() if stack else '#'
            if mapping[char] != top:
                return False
        else:
            stack.append(char)
    return not stack

LRU Cache ⭐ (Very Common at Alation)

MediumHigh FreqOrderedDict / DLL+Map
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.cache = OrderedDict()

    def get(self, key):
        if key not in self.cache: return -1
        self.cache.move_to_end(key)  # mark as recently used
        return self.cache[key]

    def put(self, key, value):
        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 (front)
class DLLNode:
    def __init__(self, key=0, val=0):
        self.key = key; self.val = val
        self.prev = self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.cache = {}  # key -> node
        # Doubly linked list with sentinel head/tail
        self.head = DLLNode(); self.tail = DLLNode()
        self.head.next = self.tail; self.tail.prev = self.head

    def _remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _insert(self, node):  # insert at tail (MRU position)
        prev = self.tail.prev
        prev.next = node; node.prev = prev
        node.next = self.tail; self.tail.prev = node

    def get(self, key):
        if key not in self.cache: return -1
        node = self.cache[key]
        self._remove(node); self._insert(node)
        return node.val

    def put(self, key, value):
        if key in self.cache: self._remove(self.cache[key])
        node = DLLNode(key, value)
        self.cache[key] = node; self._insert(node)
        if len(self.cache) > self.cap:
            lru = self.head.next
            self._remove(lru); del self.cache[lru.key]

Sliding Window Maximum

HardMonotonic Deque
from collections import deque

def maxSlidingWindow(nums, k):
    dq = deque()  # stores indices, front = max element's index
    result = []
    for i, n in enumerate(nums):
        # Remove elements outside window
        while dq and dq[0] < i - k + 1:
            dq.popleft()
        # Maintain decreasing order (remove smaller elements from back)
        while dq and nums[dq[-1]] < n:
            dq.pop()
        dq.append(i)
        if i >= k - 1:
            result.append(nums[dq[0]])
    return result

# Time: O(n), Space: O(k)
# Key: monotonic decreasing deque
Trees — Binary Trees, BST, BFS, DFS

Tree Traversals — BFS & DFS (Inorder / Preorder / Postorder)

MediumHigh FreqTree
Optimal
Iterative BFS (Level Order) / Iterative DFS
Time O(n) · Space O(w) for BFS, O(h) for DFS
Good
Recursive DFS
Time O(n) · Space O(h) call stack — risk of stack overflow on skewed trees
from collections import deque

def levelOrder(root):
    if not root: return []
    result, queue = [], deque([root])
    while queue:
        level = []
        for _ in range(len(queue)):       # process one full level
            node = queue.popleft()
            level.append(node.val)
            if node.left:  queue.append(node.left)
            if node.right: queue.append(node.right)
        result.append(level)
    return result
# Time O(n) · Space O(w)  where w = max width (at most n/2 for last level)
def inorder(root):    # Left → Root → Right  → sorted order for BST
    return inorder(root.left) + [root.val] + inorder(root.right) if root else []

def preorder(root):   # Root → Left → Right  → serialize tree
    return [root.val] + preorder(root.left) + preorder(root.right) if root else []

def postorder(root):  # Left → Right → Root  → delete tree, evaluate expr
    return postorder(root.left) + postorder(root.right) + [root.val] if root else []

# Iterative preorder (useful when recursion depth is a concern)
def preorder_iter(root):
    if not root: return []
    stack, result = [root], []
    while stack:
        node = stack.pop()
        result.append(node.val)
        if node.right: stack.append(node.right)   # right first (LIFO)
        if node.left:  stack.append(node.left)
    return result
def inorderIterative(root):
    """Morris traversal alternative: O(1) space"""
    result, stack, curr = [], [], root
    while curr or stack:
        while curr:            # go as far left as possible
            stack.append(curr)
            curr = curr.left
        curr = stack.pop()     # process node
        result.append(curr.val)
        curr = curr.right      # move to right subtree
    return result

LCA, Max Depth, Diameter, Balanced Check — Tree Classics

MediumDFSTree
def lowestCommonAncestor(root, p, q):
    """
    LCA of Binary Tree (not necessarily BST)
    Key insight: if root is p or q, root IS the LCA.
    If p found in left and q in right (or vice versa), root is LCA.
    """
    if not root or root == p or root == q:
        return root
    left  = lowestCommonAncestor(root.left,  p, q)
    right = lowestCommonAncestor(root.right, p, q)
    if left and right: return root   # p and q on opposite sides
    return left or right             # both on same side

# BST LCA — faster using BST property
def lcaBST(root, p, q):
    while root:
        if p.val < root.val and q.val < root.val:
            root = root.left
        elif p.val > root.val and q.val > root.val:
            root = root.right
        else:
            return root   # split point = LCA
# Time O(h) · Space O(1) for BST, O(h) for binary tree
def maxDepth(root):
    if not root: return 0
    return 1 + max(maxDepth(root.left), maxDepth(root.right))
# Time O(n) · Space O(h)

# Min depth — first leaf reached via BFS
from collections import deque
def minDepth(root):
    if not root: return 0
    q = deque([(root, 1)])
    while q:
        node, depth = q.popleft()
        if not node.left and not node.right:
            return depth                        # first leaf
        if node.left:  q.append((node.left,  depth + 1))
        if node.right: q.append((node.right, depth + 1))
def diameterOfBinaryTree(root):
    """
    Diameter = longest path through any node (may not pass root).
    For each node: diameter candidate = left_height + right_height
    """
    max_d = [0]

    def height(node):
        if not node: return 0
        l, r = height(node.left), height(node.right)
        max_d[0] = max(max_d[0], l + r)   # update global max
        return 1 + max(l, r)

    height(root)
    return max_d[0]
# Time O(n) · Space O(h)
def isBalanced(root):
    """
    Balanced: for every node, |height(left) - height(right)| <= 1
    Return -1 as sentinel for 'unbalanced' to avoid second pass.
    """
    def check(node):
        if not node: return 0
        l = check(node.left)
        if l == -1: return -1
        r = check(node.right)
        if r == -1: return -1
        if abs(l - r) > 1: return -1
        return 1 + max(l, r)

    return check(root) != -1
# Time O(n) · Space O(h) — better than naive O(n²)

BST — Validate, Insert, Kth Smallest, Serialize

MediumBST
def isValidBST(root, lo=float('-inf'), hi=float('inf')):
    """
    Pass valid range [lo, hi] down. Every node must satisfy lo < val < hi.
    Common mistake: only checking parent — misses grandparent constraints.
    """
    if not root: return True
    if not (lo < root.val < hi): return False
    return (isValidBST(root.left,  lo, root.val) and
            isValidBST(root.right, root.val, hi))
# Time O(n) · Space O(h)
def kthSmallest(root, k):
    """Inorder traversal of BST gives sorted order. Stop at kth."""
    stack, curr = [], root
    while True:
        while curr:
            stack.append(curr); curr = curr.left
        curr = stack.pop()
        k -= 1
        if k == 0: return curr.val
        curr = curr.right
import collections

def serialize(root):
    """BFS serialization with None markers."""
    if not root: return '[]'
    q, vals = collections.deque([root]), []
    while q:
        node = q.popleft()
        if node:
            vals.append(str(node.val))
            q.append(node.left); q.append(node.right)
        else:
            vals.append('null')
    return '[' + ','.join(vals) + ']'

def deserialize(data):
    vals = data.strip('[]').split(',')
    if vals[0] == 'null': return None
    root = TreeNode(int(vals[0]))
    q, i = collections.deque([root]), 1
    while q:
        node = q.popleft()
        if i < len(vals) and vals[i] != 'null':
            node.left = TreeNode(int(vals[i])); q.append(node.left)
        i += 1
        if i < len(vals) and vals[i] != 'null':
            node.right = TreeNode(int(vals[i])); q.append(node.right)
        i += 1
    return root
Graphs — BFS, DFS, Shortest Path, Union-Find

Number of Islands — BFS / DFS / Union-Find (Best to Worst)

MediumHigh FreqGraph
Optimal
BFS / DFS flood-fill (in-place mark)
Time O(m·n) · Space O(min(m,n)) — BFS queue at most min(m,n)
Good
Union-Find
Time O(m·n · α) ≈ O(m·n) · Space O(m·n) — better for dynamic updates
Avoid
Recursive DFS without visited set on large grids
Stack overflow risk on m·n = 300×300+
from collections import deque

def numIslands(grid):
    if not grid: return 0
    rows, cols, count = len(grid), len(grid[0]), 0

    def bfs(r, c):
        queue = deque([(r, c)])
        grid[r][c] = '0'           # mark visited in-place
        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
def numIslandsDFS(grid):
    rows, cols, count = len(grid), len(grid[0]), 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'
        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':
                dfs(r, c); count += 1
    return count
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank   = [0] * n
        self.count  = 0

    def find(self, x):                   # path compression
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):               # union by rank
        px, py = self.find(x), self.find(y)
        if px == py: return
        if self.rank[px] < self.rank[py]: px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]: self.rank[px] += 1
        self.count -= 1

def numIslandsUF(grid):
    rows, cols = len(grid), len(grid[0])
    uf = UnionFind(rows * cols)
    uf.count = sum(grid[r][c]=='1' for r in range(rows) for c in range(cols))
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                for dr, dc in [(1,0),(0,1)]:   # only right & down (avoid double-union)
                    nr, nc = r+dr, c+dc
                    if 0<=nr

Dijkstra's Shortest Path — Min-Heap Implementation

MediumGraphHeap
Optimal
Min-heap (heapq) — lazy deletion
Time O((V+E) log V) · Space O(V) — standard for sparse graphs
Good
Bellman-Ford
Time O(V·E) · Space O(V) — use when negative weights exist (Dijkstra fails)
Avoid
Floyd-Warshall for single-source
Time O(V³) — only needed for all-pairs shortest path
import heapq
from collections import defaultdict

def dijkstra(graph, src, n):
    """
    graph: {node: [(neighbor, weight), ...]}
    Returns dist[] array from src to all nodes.
    """
    dist = [float('inf')] * n
    dist[src] = 0
    heap = [(0, src)]     # (cost, node)

    while heap:
        cost, u = heapq.heappop(heap)
        if cost > dist[u]: continue    # stale entry — skip

        for v, w in graph[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                heapq.heappush(heap, (dist[v], v))

    return dist

# Example: LeetCode 743 — Network Delay Time
def networkDelayTime(times, n, k):
    g = defaultdict(list)
    for u, v, w in times:
        g[u].append((v, w))
    dist = dijkstra(g, k, n + 1)
    ans = max(dist[1:])
    return -1 if ans == float('inf') else ans
Why Dijkstra Fails with Negative Weights
Once a node is popped from the heap as "settled", its distance is assumed final. A negative edge could later provide a shorter path, but we never re-examine settled nodes.

Topological Sort — Kahn's BFS (Recommended) + DFS

MediumGraphDAG
Preferred
Kahn's Algorithm (BFS + in-degree)
Time O(V+E) · Space O(V) — naturally detects cycles (incomplete result)
Alternative
DFS with finish-time ordering
Time O(V+E) · Space O(V) — harder to implement, same complexity
from collections import deque, defaultdict

def topoSort(numCourses, prerequisites):
    """LeetCode 207 — Course Schedule (cycle detection)"""
    graph   = defaultdict(list)
    indegree = [0] * numCourses

    for a, b in prerequisites:
        graph[b].append(a)      # b must come before a
        indegree[a] += 1

    queue = deque(i for i in range(numCourses) if indegree[i] == 0)
    order = []

    while queue:
        node = queue.popleft()
        order.append(node)
        for nxt in graph[node]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                queue.append(nxt)

    return order if len(order) == numCourses else []
    # If len(order) < numCourses → cycle detected!
def topoSortDFS(n, edges):
    graph = defaultdict(list)
    for a, b in edges:
        graph[b].append(a)

    WHITE, GRAY, BLACK = 0, 1, 2   # unvisited, in-progress, done
    color = [WHITE] * n
    result = []
    has_cycle = [False]

    def dfs(u):
        if has_cycle[0]: return
        color[u] = GRAY              # mark in-progress
        for v in graph[u]:
            if color[v] == GRAY:     # back edge = cycle
                has_cycle[0] = True; return
            if color[v] == WHITE:
                dfs(v)
        color[u] = BLACK
        result.append(u)             # add to result after all descendants

    for i in range(n):
        if color[i] == WHITE: dfs(i)

    return [] if has_cycle[0] else result[::-1]

Union-Find — Path Compression + Union by Rank

MediumUnion-Find
Use Union-Find When
You need to efficiently answer: "Are X and Y in the same connected component?" and dynamically merge components. Classic problems: Number of Islands II, Redundant Connection, Accounts Merge.
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank   = [0] * n        # use rank (or size) to keep tree flat

    def find(self, x):
        """Path compression: flatten the tree during find."""
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]   # path halving
            x = self.parent[x]
        return x

    def union(self, x, y) -> bool:
        """Returns True if x and y were in different sets."""
        px, py = self.find(x), self.find(y)
        if px == py: return False
        if self.rank[px] < self.rank[py]: px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]: self.rank[px] += 1
        return True

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

# Amortized O(α(n)) ≈ O(1) per operation
# α = inverse Ackermann function — effectively constant for all practical n

# --- Application: Redundant Connection ---
def findRedundantConnection(edges):
    uf = UnionFind(len(edges) + 1)
    for u, v in edges:
        if not uf.union(u, v):   # already connected → this edge creates cycle
            return [u, v]
FindO(α(n)) amortized
UnionO(α(n)) amortized
SpaceO(n)
Matrix Problems

Matrix Classics — Spiral, Rotate 90°, Set Zeroes, Search

MediumMatrixArray
def spiralOrder(matrix):
    """Peel the outer ring, move inward."""
    result = []
    top, bottom = 0, len(matrix) - 1
    left, right  = 0, len(matrix[0]) - 1

    while top <= bottom and left <= right:
        for c in range(left, right + 1):   result.append(matrix[top][c])
        top += 1
        for r in range(top, bottom + 1):   result.append(matrix[r][right])
        right -= 1
        if top <= bottom:
            for c in range(right, left - 1, -1): result.append(matrix[bottom][c])
            bottom -= 1
        if left <= right:
            for r in range(bottom, top - 1, -1): result.append(matrix[r][left])
            left += 1
    return result
# Time O(m·n) · Space O(1) excluding output
def rotate(matrix):
    """
    Rotate 90° clockwise IN-PLACE.
    Step 1: Transpose  (swap matrix[i][j] with matrix[j][i])
    Step 2: Reverse each row
    """
    n = len(matrix)
    # Transpose
    for i in range(n):
        for j in range(i + 1, n):
            matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
    # Reverse rows
    for row in matrix:
        row.reverse()

# Counter-clockwise: reverse rows first, then transpose
# Time O(n²) · Space O(1)
def setZeroes(matrix):
    """
    If cell is 0, set entire row and column to 0.
    Naive: O(m·n) space with copy. Better: use first row/col as markers.
    """
    m, n = len(matrix), len(matrix[0])
    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 i in range(1, m):
        for j in range(1, n):
            if matrix[i][j] == 0:
                matrix[i][0] = 0    # mark row
                matrix[0][j] = 0    # mark col

    # Zero out 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

    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
# Time O(m·n) · Space O(1)
def searchMatrix(matrix, target):
    """
    Search in m×n matrix where each row and column is sorted.
    Start from top-right corner — binary-search-like O(m+n).
    """
    row, col = 0, len(matrix[0]) - 1
    while row < len(matrix) and col >= 0:
        if matrix[row][col] == target:
            return True
        elif matrix[row][col] > target:
            col -= 1     # current too large → move left
        else:
            row += 1     # current too small → move down
    return False
# Time O(m+n) — much better than O(m·n) brute force or O(m log n) row-by-row

Word Search — Backtracking DFS on Matrix

MediumBacktrackingDFS
Only Way
DFS Backtracking — mark visited in-place
Time O(m·n·4^L) where L = word length · Space O(L) call stack
def exist(board, word):
    rows, cols = len(board), len(board[0])

    def dfs(r, c, i):
        if i == len(word): return True          # found entire word
        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) or dfs(r-1,c,i+1) or
                 dfs(r,c+1,i+1) or dfs(r,c-1,i+1))
        board[r][c] = tmp                        # restore
        return found

    for r in range(rows):
        for c in range(cols):
            if dfs(r, c, 0): return True
    return False
Optimization
Check if the board has enough letters for the word first. Also start DFS from the rarer character end of the word to prune earlier.