One very painful aspect of searching for a job as an experienced software engineer is preparing for leetcode datastructures algorithms problems again from scratch. Your 🧠 brain cache can only hold so much information at a particular point of time and if you’re actually doing work at your job you’ll definitely forget solving DSA problems quickly. The keyword here is quickly because you’re expected to solve within a 30 minute or 45 minute time window. That’s why you need to learn to do pattern matching quickly.
This article refers heavily from these two links : Leetcode Cheatsheet and ByteByteGo Interview Patterns
Big O Complexity Chart

Remember that n! > 2^n because values > 2 get multiplied n times !
Problem Patterns

Look at the input and the question asked. Based on that you should be able to detect some pattern. Speak actively with the interviewer and ask questions and seek their confirmation to proceed down particular route. Also think aloud so your interviewer can know what’s going on in your mind. For coding assessment rounds, these patterns should give you a pointer in the right direction.
Pattern Decision Cheatsheet

Code Templates
Two Pointers : one input, opposite ends
def fn(arr):
left = ans = 0
right = len(arr) - 1
while left < right:
# do some logic here with left and right
if CONDITION:
left += 1
else:
right -= 1
# or do both same time: left += 1 and right -= 1
return ans
This two pointer opposite end inward pattern is useful for comparing elements at opposite ends like valid palindrome or largest container
Two Pointers : two inputs from same direction, exhaust both
def fn(arr1, arr2):
i = j = ans = 0
while i < len(arr1) and j < len(arr2):
# do some logic here
if CONDITION:
i += 1
else:
j += 1
while i < len(arr1):
# do logic
i += 1
while j < len(arr2):
# do logic
j += 1
return ans
If you have to compare first element of first array with last element of second array you’re better off reversing the second array and comparing first elements of both arrays.
Unidirectional Traversal
- One pointer finds information and other pointer keeps track of this information.
Staged Traversal
- Both pointers start at same end of the data structure i.e. beginning/end. One pointer is used to search for something, and once found, second pointer finds additional information concerning the first pointer. Things happen in stages.
Sliding Window
def sliding_window(arr):
left = ans = curr = 0
"You can use for loop or while loop for right pointer parsing through the array"
# for right in range(len(arr)):
while right < len(arr):
# do logic here to add arr[right] to curr
while WINDOW_CONDITION_BROKEN:
# remove arr[left] from curr
left += 1
# update ans
right += 1 # Not needed if using FOR loop
return ans
Dynamic Sliding Window
- To expand the window advance the right pointer
- To shrink the window advance the left pointer
- To slide the window advance both pointers
Eg. Longest substring with unique characters
Fixed Sliding Window
- Only slide by advancing both pointers. Use when problem asks find a subcomponent of a certain length.
Eg. Substring Anagrams
Prefix Sums / K Sum Subarrays
from collections import defaultdict
def compute_prefix_sums(nums):
# Start by adding the first number to the prefix sums array.
prefix_sum = [nums[0]]
# For all remaining indexes, add 'nums[i]' to the cumulative sum from the previous index.
for i in range(1, len(nums)):
# Running total can be a SUM or PRODUCT depends on problem
running = nums[i] + prefix[-1]
prefix_sum.append(running)
def compute_k_subarrays(arr, k):
counts = defaultdict(int)
counts[0] = 1
ans = curr = 0
for num in arr:
# do logic to change curr
ans += counts[curr - k]
counts[curr] += 1
return ans
There could be +ve or -ve values in the array.
Eg. Sum between range, K-Sum Subarrays, Product Array without Current Element
Linked List : Fast and Slow Pointer
class ListNode:
def __init__(self, val: int, next: ListNode):
self.val = val
self.next = next
def fn(head):
slow = head
fast = head
ans = 0
while fast and fast.next:
# do logic
slow = slow.next
fast = fast.next.next
return ans
This is used to detect cycles in a linked list. The distance of fast pointer from slow pointer at every iteration will keep increasing one more step eventually meeting the slow pointer again if there is a cycle.
Reverse Linked List
def fn(head):
curr = head
prev = None
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
When you reverse current node you need to store the next node as temp initially so you can reference it later to continue traversal.
Monotonic Increasing Stack
def monotonic_stack(arr):
stack = []
ans = 0
for num in arr:
# for monotonic decreasing, just flip the > to <
while stack and stack[-1] > num:
# do logic
stack.pop()
stack.append(num)
return ans
def basic_sliding_window(arr):
left = ans = curr = 0
for right in range(len(arr)):
# do logic here to add arr[right] to curr
while WINDOW_CONDITION_BROKEN:
# remove arr[left] from curr
left += 1
# update ans
return ans
Monotonic Stack is used to find the break in pattern i.e. pivot point for elements in a stack. This is used to find the largest or smallest index previously.
It’s pattern is similar to a basic sliding window where instead of shifting left pointer based on window condition we just pop elements from the stack till it’s empty or top of the stack is less than or greater than current element depending on our problem.
For loop -> Traverse the array
While loop inside -> Process logic wrt stack / left pointer
Largest Rectange/ Square Area in a Histogram is famous problem for this pattern.
Heap
import heapq
def fn(arr, k):
heap = []
for num in arr:
# do some logic to push onto heap according to problem's criteria
heapq.heappush(heap, (CRITERIA, num))
if len(heap) > k:
heapq.heappop(heap)
return [num for num in heap]
Min heap : prioritizes smallest element by keeping it at the top
Max heap : prioritizes max element by keeping it at the top
Insertion : O(log(n))
Deletion : O(log(n))
Peek : O(1)
Heapify : O(n)
If you already have an unsorted array you can convert it into a heap in O(n) but adding or removing element will take O(log(n))
A Priority queue is a special type of a heap that allows for customizations in how elements are prioritized.
Finding Median of an Integer Stream or Array is classic heap problem where you maintain a min heap and a max heap with equal or 1 difference values and get the median by popping the top elements in both heaps. Greater values are stored in min heap to get lowest of greater values. Lesser values are stored in max heap to get greatest of lesser values. Their mean is your median if both heaps equal in size.
Binary Tree : DFS Recursive
def dfs(root):
if not root:
return
ans = 0
# do logic
dfs(root.left)
dfs(root.right)
return ans
Depth first search is usually done recursively for coding rounds. We can reach a null node and check if null and return. No need to look ahead before next recursive call.
Postorder -> Current Node then Left & Right
Preorder -> Left then Right then Current Node. Useful when current node value is derived from it’s children.
Inorder -> Left then Current Node then Right
Think of naming as when to do ordered traversal wrt current node. Post means after current node, pre means before current node.
Binary Tree : DFS Iterative
def dfs(root):
stack = [root]
ans = 0
while stack:
node = stack.pop()
# do logic
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
return ans
A recursion call creates a stack. If we want to do DFS iteratively then we have to manually initialise and use a stack.
The order of insertion of elements in a stack for preorder or postorder will be as recursive calls made in previous function.
Order of execution for stack is First In Last Out so most recently added nodes will be processed first.
Remember that we first pop the stack to get the current node before adding it’s left and right nodes.
Binary Tree : BFS
from collections import deque
def binary_tree_naive_bfs(root):
queue = deque([root])
ans = 0
while queue:
current_length = len(queue)
# do logic naively for nearest
node = queue.popleft()
# do logic
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return ans
"Difference here is that we process all elements in a level in one loop hence a for inside while to track state"
def binary_tree_strict_level_bfs(root):
queue = deque([root])
ans = 0
while queue:
current_length = len(queue)
# do logic for current level
# THIS LINE IS DIFFERENCE WITH NAIVE BFS
for _ in range(current_length):
node = queue.popleft()
# do logic
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return ans
This is when we require level order traversal instead of depth order traversal. We explore neighbors then their neighbors and so on.
BFS uses a queue processed in FIFO pattern so that we explore neighbors first level wise.
We fill the queue initially with the first root node.
Based on our requirement for naive or strict level based processing we pop out the elements from the queue and process them and add their children to the queue.
Don’t use BFS for operations that require you find max depth as you’ll end up unnecessarily exploring large parts of the tree/ graph before finding the max depth. Use DFS instead.
Graphs
Graphs are like trees with nodes and edges but there is no strict constraint on their connections.
Any node can be connected to any other node via an edge to form a graph
class GraphNode:
def __init__(self, val):
self.val = val
self.neighbors = []
A node in a graph can have multiple neighbors.
Degree of graph node is number of edges connected to it.
Path is a sequence of nodes connected by edges.
To solve a graph problem we first need to generate an adjacency list or adjacency matrix which we need to use for traversal.
In an adjacency list, the neighbors of each node are stored as a list. Adjacency lists can be implemented using a hash map, where the key represents the node, and its corresponding value represents the list of that node’s neighbors.
In an adjacency matrix, the graph is represented as a 2D matrix where matrix[i][j] indicates an edge between nodes i and j.
Adjacency lists are most common choice as they consume less space especially for sparse graphs.
Adjacency matrices make sense when we handle dense graphs & need to check for existence of edges between two nodes frequently.
Verty important to keep track of visited nodes to prevent infinite loops in cyclic graphs and eliminate double work.
Graph algorithm flowchart :
Graph DFS
#############################################
"For graph node templates we need to traverse the node objects"
class GraphNode:
def __init__(self, val):
self.val = val
self.neighbors = []
def dfs(node: GraphNode, visited: Set[GraphNode]):
visited.add(node)
process(node)
for neighbor in node.neighbors:
if neighbor not in visited:
dfs(neighbor, visited)
#############################################
"For the graph templates, assume the nodes are numbered from 0 to n - 1 and the graph is given as an adjacency list."
def recursive_dfs(graph):
def dfs(node):
ans = 0
# do some logic
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
ans += dfs(neighbor)
return ans
seen = {START_NODE}
return dfs(START_NODE)
def iterative_dfs(graph):
stack = [START_NODE]
seen = {START_NODE}
ans = 0
while stack:
node = stack.pop()
# do some logic
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
stack.append(neighbor)
return ans
#############################################
Depending on the problem we have to process the input accordingly.
We can either traverse the graph nodes or convert them to an adjacency list and traverse accordingly.
Graph BFS
from collections import deque
#############################################
"For graph node templates we need to traverse the node objects"
class GraphNode:
def __init__(self, val):
self.val = val
self.neighbors = []
def bfs(node: GraphNode):
visited = set()
queue = deque([node])
while queue:
node = queue.popleft()
if node not in visited:
visited.add(node)
process(node)
for neighbor in node.neighbors:
queue.append(neighbor)
#############################################
"For the graph templates, assume the nodes are numbered from 0 to n - 1 and the graph is given as an adjacency list."
def bfs_adjacency_list(graph):
queue = deque([START_NODE])
seen = {START_NODE}
ans = 0
while queue:
node = queue.popleft()
# do some logic
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
queue.append(neighbor)
return ans
#############################################
Same as above when it comes to handling various inputs.
Backtracking
def backtrack(curr, OTHER_ARGUMENTS...):
if (BASE_CASE):
# modify the answer
return
ans = 0
for (ITERATE_OVER_INPUT):
# modify the current state
ans += backtrack(curr, OTHER_ARGUMENTS...)
# undo the modification of the current state
return ans
The code will seem similar to DFS recursive code. This is because recursive DFS is an example of backtracking.
We return once we hit the base case. Till then we iterate through the input / nodes / neighbors / children recursively and get individual answers and combine them into our result and return it to our parent.
State Space Tree
In backtracking, the state space tree, also known as the decision tree, is a conceptual tree constructed by considering every possible decision that can be made at each point in a process.
Here’s a simplified explanation of a state space tree:
- Edges: Each edge represents a possible decision, move, or action.
- Root node: The root node represents the initial state or position before any decisions are made.
- Intermediate nodes: Nodes representing partially completed states or intermediate positions.
- Leaf nodes: The leaf nodes represent complete or invalid solutions.
- Path: A path from the root to any leaf node represents a sequence of decisions that lead to a complete or invalid solution.
Drawing out the state space tree for a problem helps to visualize the entire solution space, and all possible decisions.
Backtracking Algorithm
Traversing the state space tree is typically done using recursive DFS. Let’s discuss how it’s implemented at a high level.
Termination condition: Define the condition that specifies when a path should end. This condition should define when we’ve found a valid and/or invalid solution.
Iterate through decisions: Iterate through every possible decision at the current node, which contains the current state of the problem. For each decision:
- Make that decision and update the current state accordingly.
- Recursively explore all paths that branch from this updated state by calling the DFS function on this state.
- Backtrack by undoing the decision we made and reverting the state.
Below is a crude template for backtracking:
def dfs(state):
# Termination condition.
if meets_termination_condition(state):
process_solution(state)
return
# Explore each possible decision that can be made at the current state.
for decision in possible_decisions(state):
make_decision(state, decision)
dfs(state) # USE DFS TO EXPLORE ALL CHILD STATES
undo_decision(state, decision) # Backtrack.
There is a lot of redundant work if we reach same state again and again!
Dynamic Programming : Top Down Memoization
def top_down_dp(arr):
def dp(STATE):
if BASE_CASE:
return 0
if STATE in memo:
return memo[STATE]
ans = RECURRENCE_RELATION(STATE)
memo[STATE] = ans
return ans
memo = {}
return dp(STATE_FOR_WHOLE_INPUT)
Top Down DP is basically Backtracking + Memoization
Explore the states with recurrence relation and cache any intermediate results to avoid double work if you come across the same state again.
Bottom Up DP
def bottom_up_dp(arr):
# Initialize DP table
dp = {} # or array of appropriate size
# Set base cases
dp[BASE_STATE] = 0
# Iterate through states in topological order
# (from smaller subproblems to larger)
for STATE in TOPOLOGICAL_ORDER:
dp[STATE] = RECURRENCE_RELATION(STATE)
return dp[STATE_FOR_WHOLE_INPUT]
Write a for-loop(s) that iterate over your state variables. If you have multiple state variables, you will need nested for-loops.
Start iterating from the base cases and end at the answer state.
Copy-paste the logic from your function into the for-loop and change the function calls to accessing your array. All dp(…)dp(…) changes into dp[…]dp[…].
Example for Fibonacci Series DP
# Top-down
def fib_top_down(n):
def dp(i):
if i <= 1:
return i
if i in memo:
return memo[i]
memo[i] = dp(i-1) + dp(i-2)
return memo[i]
memo = {}
return dp(n)
# Bottom-up
def fib_bottom_up(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[0], dp[1] = 0, 1 # base cases
for i in range(2, n + 1): # topological order
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
When to use While loop instead of For loop?
Use while when:
- Step size varies
- Early termination is possible
- Termination condition is complex
- Processing states from a queue/stack
# Variable step sizes
i = 0
while i < n:
dp[i] = ...
i += step[i] # non-uniform jumps
# Early termination
while i < n and not converged:
dp[i] = ...
i += 1
# Complex termination conditions
while queue: # BFS-style DP
state = queue.pop()
...
In practice, 90%+ of bottom-up DP uses for loops because most problems have predictable, sequential state transitions.
Trie
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for c in word:
# For each character in the word, if it’s not a child of the current node,
# create a new TrieNode for that character.
if c not in node.children:
node.children[c] = TrieNode()
node = node.children[c] # CHANGE NODE EVERY FOR ITERATION LOOP
# Mark the last node as the end of a word.
node.is_word = True
def search(self, word: str) -> bool:
node = self.root
for c in word:
# For each character in the word, if it’s not a child of the current node,
# the word doesn't exist in the Trie.
if c not in node.children:
return False
node = node.children[c] # CHANGE NODE EVERY FOR ITERATION LOOP
# Return whether the current node is marked as the end of the word.
return node.is_word
def has_prefix(self, prefix: str) -> bool:
node = self.root
for c in prefix:
if c not in node.children:
return False
node = node.children[c] # CHANGE NODE EVERY FOR ITERATION LOOP
# Once we’ve traversed the nodes corresponding to each character in the
# prefix, return True.
return True
We build and use Tries to find String Prefix
We change node in every for loop iteration as you can see in above code to continue searching remainder prefix among children.
All operation be it Insert, Search Word, Search Prefix, Delete take same time complexity of O(n)
Matrix Operation
import copy
def flatten_matrix(matrix):
# To 1D list
flat = [elem for row in matrix for elem in row]
# 1D index to 2D
row, col = index // cols, index % cols
# 2D to 1D index
index = row * cols + col
def copy_matrix(matrix):
# Shalow copy
new_matrix = [row[:] for row in matrix]
# Deep copy
new_matrix = copy.deepcopy(matrix)
def traverse(matrix, row, col):
rows, cols = len(matrix), len(matrix[0])
# Up, Down, Left, Right
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# 8 Direction with Diagonals
directions = [(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1)]
for dr, dc in directions:
nr, nc = row + dr, col + dc
# Boundary check
if 0 <= nr < rows and 0 <= nc < cols:
# Process neighbor
neighbor = matrix[nr][nc]
# ... do something
def get_row_and_col_elements(matrix, row, col):
rows, cols = len(matrix), len(matrix[0])
row_elements = [matrix[row][c] for c in range(cols) if c != col]
col_elements = [matrix[r][col] for r in range(rows) if r != row]
return row_elements, col_elements
While traversing a matrix we need to be comfortable with various operations like getting neighbors of a cell, boundary condition check, get all row or column elements of a cell etc.
Dijkstra’s algorithm
from math import inf
import heapq
from collections import defaultdict
edges = [(source, nei_1, w1), (source, nei_2, w2), (source, nei_3, w3)]
graph = defaultdict(list)
# u = source node, v = neighbor node, w = edge distance weight
for u, v, w in edges:
graph[u].append((v, w))
graph[v].append((u, w))
distances = [inf] * n
distances[source] = 0
heap = [(0, source)]
while heap:
curr_dist, node = heappop(heap)
# THIS PREVENTS MULTIPLE PROCESSING OF VISITED NODES
if curr_dist > distances[node]:
continue
for nei, weight in graph[node]:
dist = curr_dist + weight
if dist < distances[nei]:
distances[nei] = dist
heappush(heap, (dist, nei))
This algorithm is to find the Shortest Path from source node to each node in the graph if the graph has only non-negative weighted edges.
Initially the distance from source node to all other nodes is infinity.
This is a greedy algorithm where we use a min heap to get closest unvisited nodes. We don’t need to maintain a visited set to prevent double processing. This is because we check current distance from source to the node and skip if it’s more than through intermediate nodes. That way we process it again only if we encounter an even shorter path.
Our greedy assumption is that the closest paths for one node will lead to closest paths for other nodes. That’s why min heap greedy solution to get the nodes with minimum distance first.
Greedy assumption will fail if we have negative edges .