Data Structures & Algorithms
Python solutions with time/space complexity, approach explanations, and best-to-worst comparisons. Click any card to expand.
Arrays & Strings
Trees
Graphs
Dynamic Programming
Linked Lists
Heaps
Binary Search
Stack / Queue
Matrix
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
Binary Search
Binary Search — Universal Template
TemplateBinary Search
# Classic binary search
def binarySearch(nums, target):
l, r = 0, len(nums) - 1
while l <= r:
mid = l + (r - l) // 2 # avoid overflow
if nums[mid] == target: return mid
elif nums[mid] < target: l = mid + 1
else: r = mid - 1
return -1
# Find first/last position
def searchRange(nums, target):
def find_bound(is_left):
l, r = 0, len(nums) - 1
bound = -1
while l <= r:
mid = (l + r) // 2
if nums[mid] == target:
bound = mid
if is_left: r = mid - 1
else: l = mid + 1
elif nums[mid] < target: l = mid + 1
else: r = mid - 1
return bound
return [find_bound(True), find_bound(False)]
# Search in Rotated Sorted Array
def search(nums, target):
l, r = 0, len(nums) - 1
while l <= r:
mid = (l + r) // 2
if nums[mid] == target: return mid
# Left half is sorted
if nums[l] <= nums[mid]:
if nums[l] <= target < nums[mid]: r = mid - 1
else: l = mid + 1
# Right half is sorted
else:
if nums[mid] < target <= nums[r]: l = mid + 1
else: r = mid - 1
return -1
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.