Templates for Data Structures and Algorithms
Contents
Symbol | Designation |
---|---|
✓ | This is a problem from LeetCode's interview crash course. |
✠ | This is a problem stemming from work done through Interviewing IO. |
★ | A right-aligned ★ (one or more) indicates my own personal designation as to the problem's relevance, importance, priority in review, etc. |
- Backtracking
- Binary search
- Dynamic programming
- Graphs
- Heaps
- Linked lists
- Matrices
- Sliding window
- Stacks and queues
- Trees
- Manually determine order of nodes visited ("tick trick")
- Pre-order traversal
- Post-order traversal
- In-order traversal
- Level-order traversal
- Level-order (BFS)
- Induction (solve subtrees recursively, aggregate results at root)
- Traverse-and-accumulate (visit nodes and accumulate information in nonlocal variables)
- Combining templates: induction and traverse-and-accumulate
- Two pointers
- Miscellaneous
Backtracking
Remarks
TBD
def fn(curr, OTHER_ARGUMENTS...):
if (BASE_CASE):
# modify the answer
return
ans = 0
for (ITERATE_OVER_INPUT):
# modify the current state
ans += fn(curr, OTHER_ARGUMENTS...)
# undo the modification of the current state
return ans
Examples
TBD
Binary search
Find first target index (if it exists)
Remarks
This template will return the first index where target
is encountered. If duplicates are present, then the index returned is effectively random (i.e., the target
matched/identified by means of the search could be neither the leftmost occurrence nor the rightmost occurrence but somewhere in between). If no match is found, then the return value, left
, will point to the insertion point where target
would need to be inserted in order to maintain the sorted property of arr
.
def binary_search(arr, target):
left = 0
right = len(arr) - 1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] > target:
right = mid - 1
elif arr[mid] < target:
left = mid + 1
else:
return mid
return left
Examples
LC 704. Binary Search
Given an array of integers nums
which is sorted in ascending order, and an integer target
, write a function to search target
in nums
. If target
exists, then return its index. Otherwise, return -1
.
class Solution:
def search(self, nums: List[int], target: int) -> int:
left = 0
right = len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
val = nums[mid]
if target < val:
right = mid - 1
elif target > val:
left = mid + 1
else:
return mid
return -1
LC 74. Search a 2D Matrix
Write an efficient algorithm that searches for a value in an m x n
matrix. This matrix has the following properties:
- Integers in each row are sorted from left to right.
- The first integer of each row is greater than the last integer of the previous row.
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
def index_to_mat_val(index):
row = index // n
col = index % n
return matrix[row][col]
m = len(matrix)
n = len(matrix[0])
left = 0
right = m * n - 1
while left <= right:
mid = left + (right - left) // 2
val = index_to_mat_val(mid)
if target < val:
right = mid - 1
elif target > val:
left = mid + 1
else:
return True
return False
Find leftmost target index (or insertion point)
Remarks
This template ensures we find the leftmost occurrence (i.e., minimum index value) of target
(if it exists). If target
does not exist in the input array, arr
, then this template will return the index at which target
should be inserted to maintain the ordered property of arr
.
How does this work? What happens if it's ever the case that arr[mid] == target
in the template function above? It's the right half that gets collapsed, by means of right = mid
, thus pushing the search space as far left as possible.
The left
value returned by the function in the template above is also the number of elements in arr
that are less than target
.
This should make sense upon some reflection — if the function in our template returns the left-most occurrence of the target
value as well as the insertion point of target
to keep the sorted property of arr
, then it must be the case that all values to the left of the returned value are less than target
. The fact that arrays are 0-indexed helps here; for example, if our template function returns 3
, then this means the three elements at index 0
, 1
, and 2
are all less than target
.
def binary_search_leftmost(arr, target):
left = 0
right = len(arr)
while left < right:
mid = left + (right - left) // 2
if target <= arr[mid]:
right = mid
else:
left = mid + 1
return left
Examples
LC 2300. Successful Pairs of Spells and Potions
You are given two positive integer arrays spells
and potions
, of length n
and m
, respectively, where spells[i]
represents the strength of the i
th spell and potions[j]
represents the strength of the j
th potion.
You are also given an integer success
. A spell and potion pair is considered successful if the product of their strengths is at least success
.
Return an integer array pairs
of length n
where pairs[i]
is the number of potions that will form a successful pair with the i
th spell.
class Solution:
def successfulPairs(self, spells: List[int], potions: List[int], success: int) -> List[int]:
def successful_potions(spell_strength):
# need spell * potion >= success (i.e., potion >= threshold)
threshold = success / spell_strength
left = 0
right = len(potions)
while left < right:
mid = left + (right - left) // 2
if threshold <= potions[mid]:
right = mid
else:
left = mid + 1
return len(potions) - left
potions.sort()
return [ successful_potions(spell) for spell in spells ]
Find rightmost target index
Remarks
This template ensures we find the rightmost occurrence (i.e., maximum index value) of target
(if it exists). If target
does not exist in the input array, arr
, then this template will return the index before which target
should be inserted to maintain the ordered property of arr
(i.e., left
will return the proper insertion point as opposed to left - 1
).
How does this work? What happens if it's ever the case that arr[mid] == target
in the template function above? It's the left half that gets collapsed, by means of left = mid + 1
, thus pushing the search space as far right as possible.
Consider the following sorted array: [4, 6, 8, 8, 8, 10, 12, 13]
. If we applied the function in the template above to this array with a target value of 8
, then the index returned would be 4
, the index of the right-most target value 8
in the input array. This simple example illustrates two noteworthy observations:
-
Insertion point: Add a value of
1
to the return value of the template function to find the appropriate insertion point — this assumes the desired insertion point is meant to keep the input array sorted as well as for the inserted element to be as far-right as possible.In the context of the simple example, this means the insertion point for another
8
would be4 + 1 = 5
. What if the element to be added is not present? If we wanted to add9
, then our template function would return4
. Again, adding1
to this result gives us our desired insertion point:4 + 1 = 5
. The correct insertion point will always be the returned value plus1
. -
Number of elements greater than target: If
x
is the return value of our template function, then computinglen(arr) - x + 1
will give us the total number of elements in the input array that are greater than the target.In the context of the simple example, how many elements are greater than
8
? There are three such elements, namely10
,12
, and13
; hence, the answer we want is3
. How can we reliably find the value we desire for all sorted input arrays and target values?If our template function returns the right-most index of the target element if it exists or where it would need to be inserted if it doesn't exist, then this means all elements in the input array to the right of this index are greater than the target value. How can we reliably calculate this number? Well, if you're tasked with reading pages 23-27, inclusive, then how many pages are you tasked with reading? It's not
27 - 23 = 4
. You have to read pages 23, 24, 25, 26, and 27 or simply27 - 23 + 1 = 5
. The same reasoning, albeit slightly nuanced, applies here: If indexx
is the index returned by our function, then how many elements exist from the right of this element to the end of the array, inclusive? The last element in the array has an index oflen(arr) - 1
and the element to the right of the returned value has an index ofx + 1
. Hence, the total number of values shakes out to be(len(arr) - 1) - (x + 1) + 1 = len(arr) - x + 1
def binary_search_rightmost(arr, target):
left = 0
right = len(arr)
while left < right:
mid = left + (right - left) // 2
if target < arr[mid]:
right = mid
else:
left = mid + 1
return left - 1
Greedy (looking for minimum)
Remarks
TBD
TBD
Examples
TBD
Greedy (looking for maximum)
Remarks
TBD
TBD
Examples
TBD
Dynamic programming
Memoization (top-down)
Remarks
TBD
def fn(arr):
# 1. define a function that will compute/contain
# the answer to the problem for any given state
def dp(STATE):
# 3. use base cases to make the recurrence relation useful
if BASE_CASE:
return 0
if STATE in memo:
return memo[STATE]
# 2. define a recurrence relation to transition between states
ans = RECURRENCE_RELATION(STATE)
memo[STATE] = ans
return ans
memo = {}
return dp(STATE_FOR_WHOLE_INPUT)
Examples
LC 746. Min Cost Climbing Stairs
You are given an integer array cost
where cost[i]
is the cost of ith
step on a staircase. Once you pay the cost, you can either climb one or two steps.
You can either start from the step with index 0
, or the step with index 1
.
Return the minimum cost to reach the top of the floor.
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
def dp(step):
if step in memo:
return memo[step]
step_cost = min(dp(step - 1) + cost[step - 1], dp(step - 2) + cost[step - 2])
memo[step] = step_cost
return step_cost
memo = {}
memo[0] = memo[1] = 0
return dp(len(cost))
Tabulation (bottom-up)
Remarks
TBD
def fn(arr):
# 1. initialize a table (array, list, etc.)
# to store solutions of subproblems.
dp_table = INITIALIZE_TABLE()
# 2. fill the base cases into the table.
dp_table = FILL_BASE_CASES(dp_table)
# 3. iterate over the table in a specific order
# to fill in the solutions of larger subproblems.
for STATE in ORDER_OF_STATES:
dp_table[STATE] = CALCULATE_STATE_FROM_PREVIOUS_STATES(dp_table, STATE)
# 4. the answer to the whole problem is now in the table,
# typically at the last entry or a specific position.
return dp_table[FINAL_STATE_OR_POSITION]
# example usage
arr = [INPUT_DATA]
result = fn(arr)
Examples
TBD
Graphs
DFS (recursive)
Remarks
Assume the nodes are numbered from 0
to n - 1
and the graph is given as an adjacency list. Depending on the problem, you may need to convert the input into an equivalent adjacency list before using the templates.
def fn(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)
Examples
LC 547. Number of Provinces (✓)
There are n
cities. Some of them are connected, while some are not. If city a
is connected directly with city b
, and city b
is connected directly with city c
, then city a
is connected indirectly with city c
.
A province is a group of directly or indirectly connected cities and no other cities outside of the group.
You are given an n x n
matrix isConnected
where isConnected[i][j] = 1
if the ith
city and the jth
city are directly connected, and isConnected[i][j] = 0
otherwise.
Return the total number of provinces.
class Solution:
def findCircleNum(self, isConnected: List[List[int]]) -> int:
def build_adj_list(adj_mat):
graph = defaultdict(list)
n = len(adj_mat)
for node in range(n):
for neighbor in range(node + 1, n):
if isConnected[node][neighbor] == 1:
graph[node].append(neighbor)
graph[neighbor].append(node)
return graph
def dfs(node):
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
dfs(neighbor)
graph = build_adj_list(isConnected)
seen = set()
provinces = 0
for city in range(len(isConnected)):
if city not in seen:
provinces += 1
seen.add(city)
dfs(city)
return provinces
Cities are nodes, connected cities are provinces (i.e., connected components). The idea here is to explore all provinces by starting with each city and seeing how many cities we can explore from that city — every time we have to start a search again from a new city, we increment the number of overall provinces encountered thus far.
LC 200. Number of Islands (✓)
Given an m x n
2D binary grid grid
which represents a map of '1'
s (land) and '0'
s (water), return the number of islands.
An island is surrounded by water and is formed by connecting adjacent lands horizontally or vertically. You may assume all four edges of the grid are all surrounded by water.
class Solution:
def numIslands(self, grid: List[List[str]]) -> int:
def valid(row, col):
return 0 <= row < m and 0 <= col < n and grid[row][col] == '1'
def dfs(row, col):
for dr, dc in dirs:
next_row, next_col = row + dr, col + dc
neighbor = (next_row, next_col)
if neighbor not in seen and valid(*neighbor):
seen.add(neighbor)
dfs(*neighbor)
m = len(grid)
n = len(grid[0])
dirs = [(-1,0),(1,0),(0,-1),(0,1)]
seen = set()
islands = 0
for i in range(m):
for j in range(n):
node = (i, j)
if node not in seen and grid[i][j] == '1':
islands += 1
seen.add(node)
dfs(*node)
return islands
Each "island" is a connected component — our job is to count the total number of connected components. The traversal is a fairly standard DFS traversal on a grid-like graph.
LC 1466. Reorder Routes to Make All Paths Lead to the City Zero (✓)
There are n
cities numbered from 0
to n-1
and n-1
roads such that there is only one way to travel between two different cities (this network form a tree). Last year, The ministry of transport decided to orient the roads in one direction because they are too narrow.
Roads are represented by connections
where connections[i] = [a, b]
represents a road from city a
to b
.
This year, there will be a big event in the capital (city 0
), and many people want to travel to this city.
Your task consists of reorienting some roads such that each city can visit the city 0
. Return the minimum number of edges changed.
It's guaranteed that each city can reach the city 0
after reorder.
class Solution:
def minReorder(self, n: int, connections: List[List[int]]) -> int:
roads = set()
def build_adj_list(edge_arr):
graph = defaultdict(list)
for node, neighbor in edge_arr:
roads.add((node, neighbor))
graph[node].append(neighbor)
graph[neighbor].append(node)
return graph
def dfs(node):
ans = 0
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
if (node, neighbor) in roads:
ans += 1
ans += dfs(neighbor)
return ans
graph = build_adj_list(connections)
seen = {0}
return dfs(0)
This is a tough one to come up with if you haven't seen it before. The solution approach above is quite clever. The idea is to build an undirected graph in the form of an adjacency list and then to conduct a DFS from node 0
, which means every edge we encounter is necessarily leading away from 0
; hence, if that edge appeared in the original road configuration, roads
, then we know that road's direction must be changed so that it faces toward node 0
instead of away.
LC 841. Keys and Rooms (✓)
There are N
rooms and you start in room 0
. Each room has a distinct number in 0, 1, 2, ..., N-1
, and each room may have some keys to access the next room.
Formally, each room i
has a list of keys rooms[i]
, and each key rooms[i][j]
is an integer in [0, 1, ..., N-1]
where N = rooms.length
. A key rooms[i][j] = v
opens the room with number v
.
Initially, all the rooms start locked (except for room 0
).
You can walk back and forth between rooms freely.
Return true
if and only if you can enter every room.
class Solution:
def canVisitAllRooms(self, rooms: List[List[int]]) -> bool:
def dfs(node):
for neighbor in rooms[node]:
if neighbor not in seen:
seen.add(neighbor)
dfs(neighbor)
seen = {0}
dfs(0)
return len(seen) == len(rooms)
It's quite nice that the given input, rooms
, is already in the form of an adjacency list (as an index array). The key insight is to realize that we can use our seen
set to determine whether or not all rooms have been visited after conducting a DFS from node 0
(i.e., room 0
); that is, if seen
is the same length as rooms
after the DFS from node 0
, then we can say it's possible to visit all rooms (otherwise it's not).
LC 1971. Find if Path Exists in Graph (✓)
There is a bi-directional graph with n
vertices, where each vertex is labeled from 0
to n - 1
(inclusive). The edges in the graph are represented as a 2D integer array edges
, where each edges[i] = [ui, vi]
denotes a bi-directional edge between vertex ui
and vertex vi
. Every vertex pair is connected by at most one edge, and no vertex has an edge to itself.
You want to determine if there is a valid path that exists from vertex start
to vertex end
.
Given edges
and the integers n
, start
, and end
, return true
if there is a valid path from start
to end
, or false
otherwise.
class Solution:
def validPath(self, n: int, edges: List[List[int]], source: int, destination: int) -> bool:
def build_adj_list(edge_arr):
graph = defaultdict(list)
for node, neighbor in edge_arr:
graph[node].append(neighbor)
graph[neighbor].append(node)
return graph
def dfs(node):
if node == destination:
return True
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
if dfs(neighbor):
return True
return False
graph = build_adj_list(edges)
seen = {source}
return dfs(source)
The solution above is a direct application of a DFS traversal. The hardest part is arguably coming up with an effective way of writing the dfs
function. It's better not to rely on a nonlocal variable unless we really need to. The idea is that we should stop searching if we encounter a node whose value is equal to the destination. If that is not the case, then we try to explore further. If our DFS comes up empty, then we return False
, and that will propagate back up the recursion chain.
LC 323. Number of Connected Components in an Undirected Graph (✓)
You have a graph of n
nodes. You are given an integer n
and an array edges
where edges[i] = [ai, bi]
indicates that there is an edge between ai
and bi
in the graph.
Return the number of connected components in the graph.
class Solution:
def countComponents(self, n: int, edges: List[List[int]]) -> int:
def build_adj_list(edge_arr):
graph = defaultdict(list)
for node, neighbor in edge_arr:
graph[node].append(neighbor)
graph[neighbor].append(node)
return graph
def dfs(node):
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
dfs(neighbor)
graph = build_adj_list(edges)
seen = set()
cc = 0
for node in range(n):
if node not in seen:
cc += 1
seen.add(node)
dfs(node)
return cc
Counting the number of connected components in a graph via DFS traversal is a very common task. Sometimes the nature of the connected components may be obfuscated at first (i.e., we have to come up with a way to first model the connections and then determine the number of connected components), but that is not the case here.
One thing worth noting in the solution above is how we deftly take care of the case where a node is not represented in the original edge list we're provided. We simply increment the count of the number of connected components, cc
, as soon as we encounter a node we have not seen before, and we do this for all n
nodes. For nodes that are not connected to any other nodes, are dfs
function effectively does not execute.
LC 695. Max Area of Island (✓)
Given a non-empty 2D array grid
of 0
's and 1
's, an island is a group of 1
's (representing land) connected 4-directionally (horizontal or vertical.) You may assume all four edges of the grid are surrounded by water.
Find the maximum area of an island in the given 2D array. (If there is no island, the maximum area is 0
.)
class Solution:
def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
def valid(row, col):
return 0 <= row < m and 0 <= col < n and grid[row][col] == 1
def dfs(row, col):
connected_area = 0
for dr, dc in dirs:
next_row, next_col = row + dr, col + dc
next_node = (next_row, next_col)
if valid(*next_node) and next_node not in seen:
seen.add(next_node)
connected_area += 1 + dfs(*next_node)
return connected_area
m = len(grid)
n = len(grid[0])
dirs = [(-1,0),(1,0),(0,1),(0,-1)]
seen = set()
max_area = 0
for i in range(m):
for j in range(n):
if (i, j) not in seen and grid[i][j] == 1:
seen.add((i, j))
max_area = max(max_area, 1 + dfs(i, j))
return max_area
The idea here is to basically find the largest connected component.
LC 2368. Reachable Nodes With Restrictions (✓)
There is an undirected tree with n
nodes labeled from 0
to n - 1
and n - 1
edges.
You are given a 2D integer array edges
of length n - 1
where edges[i] = [ai, bi]
indicates that there is an edge between nodes ai
and bi
in the tree. You are also given an integer array restricted
which represents restricted nodes.
Return the maximum number of nodes you can reach from node 0
without visiting a restricted node.
Note that node 0
will not be a restricted node.
class Solution:
def reachableNodes(self, n: int, edges: List[List[int]], restricted: List[int]) -> int:
restricted = set(restricted)
def build_adj_list(edge_arr):
graph = defaultdict(list)
for node, neighbor in edge_arr:
if node not in restricted and neighbor not in restricted:
graph[node].append(neighbor)
graph[neighbor].append(node)
return graph
def dfs(node):
for neighbor in graph[node]:
if neighbor not in seen and neighbor not in restricted:
seen.add(neighbor)
dfs(neighbor)
graph = build_adj_list(edges)
seen = {0}
dfs(0)
return len(seen)
The idea behind the solution above is to start by ensuring our graph only has valid nodes. This means getting rid of all edges that contain one (or both) nodes from the restricted
list, which we start by "setifying" in order to make it possible to have lookups.
It's worth reflecting on why it behooves us to get rid of an edge when one of its nodes is from the restricted
set. If the node in the restricted is the source, then there's no way to get to its destination. If the restricted node is the destination, then we will not go there from the source. Whatever the case, it is a waste of time and space to consider edges that have one (or both) nodes from the restricted
set.
At the end, the number of nodes reached from 0
is the length of the set seen
, which is why we return len(seen)
. We could just as well kept track of the number of visited nodes by just using the dfs
function itself:
class Solution:
def reachableNodes(self, n: int, edges: List[List[int]], restricted: List[int]) -> int:
restricted = set(restricted)
def build_adj_list(edge_arr):
graph = defaultdict(list)
for node, neighbor in edge_arr:
if node not in restricted and neighbor not in restricted:
graph[node].append(neighbor)
graph[neighbor].append(node)
return graph
def dfs(node):
ans = 0
for neighbor in graph[node]:
if neighbor not in seen and neighbor not in restricted:
seen.add(neighbor)
ans += 1 + dfs(neighbor)
return ans
graph = build_adj_list(edges)
seen = {0}
return dfs(0) + 1
DFS (iterative)
Remarks
Assume the nodes are numbered from 0
to n - 1
and the graph is given as an adjacency list. Depending on the problem, you may need to convert the input into an equivalent adjacency list before using the templates.
def fn(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
Examples
TBD
BFS
Remarks
Assume the nodes are numbered from 0
to n - 1
and the graph is given as an adjacency list. Depending on the problem, you may need to convert the input into an equivalent adjacency list before using the templates.
from collections import deque
def fn(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
Examples
LC 1091. Shortest Path in Binary Matrix (✓)
Given an n x n
binary matrix grid
, return the length of the shortest clear path in the matrix. If there is no clear path, return -1
.
A clear path in a binary matrix is a path from the top-left cell (i.e., (0, 0)
) to the bottom-right cell (i.e., (n - 1, n - 1)
) such that:
- All the visited cells of the path are
0
. - All the adjacent cells of the path are 8-directionally connected (i.e., they are different and they share an edge or a corner).
The length of a clear path is the number of visited cells of this path.
class Solution:
def shortestPathBinaryMatrix(self, grid: List[List[int]]) -> int:
def valid(row, col):
return 0 <= row < n and 0 <= col < n and grid[row][col] == 0
dirs = [(1,0),(1,1),(1,-1),(-1,0),(-1,1),(-1,-1),(0,-1),(0,1)]
n = len(grid)
seen = {(0,0)}
if grid[0][0] != 0 or grid[n-1][n-1] != 0:
return -1
queue = deque([(0,0,1)])
while queue:
row, col, path_length = queue.popleft()
if row == n - 1 and col == n - 1:
return path_length
for dr, dc in dirs:
next_row, next_col = row + dr, col + dc
next_node = (next_row, next_col)
if valid(*next_node) and next_node not in seen:
queue.append((*next_node, path_length + 1))
seen.add(next_node)
return -1
It's fairly conventional in BFS solutions for graphs to encode with each node additional information like the current level for that node or some other kind of stateful data. We do not need to encode anything other than each node's position in the seen
set because whenever we encounter a node it will be in the fewest steps possible (i.e., the trademark of BFS solutions ... finding shortest paths).
LC 863. All Nodes Distance K in Binary Tree (✓)
We are given a binary tree (with root node root
), a target
node, and an integer value K
.
Return a list of the values of all nodes that have a distance K
from the target
node. The answer can be returned in any order.
class Solution:
def distanceK(self, root: TreeNode, target: TreeNode, k: int) -> List[int]:
def build_parent_lookup(node, parent = None):
if not node:
return
parent_lookup[node] = parent
build_parent_lookup(node.left, node)
build_parent_lookup(node.right, node)
parent_lookup = dict()
build_parent_lookup(root)
seen = {target}
queue = deque([target])
for _ in range(k):
num_nodes_this_level = len(queue)
for _ in range(num_nodes_this_level):
node = queue.popleft()
for neighbor in [node.left, node.right, parent_lookup[node]]:
if neighbor and neighbor not in seen:
seen.add(neighbor)
queue.append(neighbor)
return [ node.val for node in queue ]
The key in the solution above is to recognize that a BFS traversal will give us exactly what we want if we have some way to reference each node's parent node. The build_parent_lookup
function, which uses DFS to build a hashmap lookup for each node's parent node, gives us this. Much of the rest of the problem then becomes a standard BFS traversal.
LC 542. 01 Matrix (✓)
Given a matrix consists of 0
and 1
, find the distance of the nearest 0
for each cell.
The distance between two adjacent cells is 1
.
class Solution:
def updateMatrix(self, mat: List[List[int]]) -> List[List[int]]:
def valid(row, col):
return 0 <= row < m and 0 <= col < n and mat[row][col] == 1
m = len(mat)
n = len(mat[0])
res = [[0] * n for _ in range(m)]
dirs = [(-1,0),(1,0),(0,1),(0,-1)]
seen = set()
queue = deque()
for i in range(m):
for j in range(n):
if mat[i][j] == 0:
seen.add((i, j))
queue.append((i, j, 0))
while queue:
row, col, dist = queue.popleft()
for dr, dc in dirs:
next_row, next_col = row + dr, col + dc
next_node = (next_row, next_col)
if valid(*next_node) and next_node not in seen:
res[next_row][next_col] = dist + 1
seen.add(next_node)
queue.append((*next_node, dist + 1))
return res
The solution above takes advantage of a so-called multi-source BFS (i.e., a BFS traversal with multiple starting sources). The solution also takes advantage of the fact that cells with a value of 0
do not need to be updated; hence, our BFS can start from all nodes with a value of 0
and explore outwards, updating non-zero nodes (i.e., just nodes with value 1
for this problem) with the distance so far from a node with value 0
. Our intuition tells us to start from the nodes with value 1
, but it's much easier to start from the nodes with value 0
.
Additionally, in the valid
function above, the condition and mat[row][col] == 1
is not necessary since all nodes with value 0
are added to the seen
set before exploring outwards, which means all subsequent nodes we'll explore that are both valid and not in seen
will have a non-zero value (i.e., 1
for this problem). This conditional of the valid
function is only kept for the sake of clarity, but it's worth noting that it's not necessary here.
LC 1293. Shortest Path in a Grid with Obstacles Elimination (✓) ★★
Given a m * n
grid, where each cell is either 0
(empty) or 1
(obstacle). In one step, you can move up, down, left or right from and to an empty cell.
Return the minimum number of steps to walk from the upper left corner (0, 0)
to the lower right corner (m-1, n-1)
given that you can eliminate at most k
obstacles. If it is not possible to find such walk return -1
.
class Solution:
def shortestPath(self, grid: List[List[int]], k: int) -> int:
# ensures the next node to be visited is in bounds
def valid(row, col):
return 0 <= row < m and 0 <= col < n
m = len(grid)
n = len(grid[0])
dirs = [(1,0),(-1,0),(0,1),(0,-1)]
seen = {(0,0,k)}
queue = deque([(0,0,k,0)])
while queue:
row, col, rem, steps = queue.popleft()
# only valid nodes exist in the queue
if row == m - 1 and col == n - 1:
return steps
for dr, dc in dirs:
next_row, next_col = row + dr, col + dc
next_node = (next_row, next_col)
if valid(*next_node):
next_val = grid[next_row][next_col]
# if the next value is not an obstacle, then proceed with visits as normal
if next_val == 0:
if (*next_node, rem) not in seen:
seen.add((*next_node, rem))
queue.append((*next_node, rem, steps + 1))
# the next value is an obstacle: can we still remove obstacles? if so, proceed with visits
else:
if rem > 0 and (*next_node, rem - 1) not in seen:
seen.add((*next_node, rem - 1))
queue.append((*next_node, rem - 1, steps + 1))
return -1
This is an excellent problem for thinking through how a node's state should be recorded in the seen
set; that is, the majority of BFS and DFS traversals on matrix graphs simply record a node's position (i.e., row and column) because the node's position fully describes the state we do not want to visit again. But for some problems, like this one, it's helpful to record more information than just a node's position. Specifically, the state we do not want to visit more than once is a node's position in addition to the number of remaining obstacles we can move.
Thinking about using the seen
set to record states we do not want to visit multiple times is much more accurate and reflective of our actual goal — only perform computation when absolutely necessary.
LC 1129. Shortest Path with Alternating Colors (✓) ★★
Consider a directed graph, with nodes labelled 0, 1, ..., n-1
. In this graph, each edge is either red or blue, and there could be self-edges or parallel edges.
Each [i, j]
in red_edges
denotes a red directed edge from node i
to node j
. Similarly, each [i, j]
in blue_edges
denotes a blue directed edge from node i
to node j
.
Return an array answer
of length n
, where each answer[X]
is the length of the shortest path from node 0
to node X
such that the edge colors alternate along the path (or -1
if such a path doesn't exist).
class Solution:
def shortestAlternatingPaths(self, n: int, redEdges: List[List[int]], blueEdges: List[List[int]]) -> List[int]:
def build_graph(edge_arr):
graph = defaultdict(list)
for node, neighbor in edge_arr:
graph[node].append(neighbor)
return graph
RED_GRAPH = build_graph(redEdges)
BLUE_GRAPH = build_graph(blueEdges)
RED = 1
BLUE = 0
ans = [float('inf')] * n
seen = {(0,RED), (0,BLUE)}
queue = deque([(0,RED,0),(0,BLUE,0)])
while queue:
node, color, steps = queue.popleft()
ans[node] = min(ans[node], steps)
alt_color = 1 - color
graph = RED_GRAPH if alt_color == 1 else BLUE_GRAPH
for neighbor in graph[node]:
if (neighbor, alt_color) not in seen:
seen.add((neighbor, alt_color))
queue.append((neighbor, alt_color, steps + 1))
return [ val if val != float('inf') else -1 for val in ans ]
This is such a great problem in so many ways. The idea is to execute a "semi-multi-source" BFS, where we start with node 0
as if it's red as well as if it's blue. Then we expand outwards.
We also take advantage of a nice numerical trick: 1 - 1 == 0
, and 1 - 0 == 1
. This allows us to effectively (and efficiently) alternate between colors.
LC 1926. Nearest Exit from Entrance in Maze (✓)
You are given an m x n
matrix maze (0-indexed) with empty cells (represented as '.'
) and walls (represented as '+'
). You are also given the entrance
of the maze, where entrance = [entrancerow, entrancecol]
denotes the row and column of the cell you are initially standing at.
In one step, you can move one cell up, down, left, or right. You cannot step into a cell with a wall, and you cannot step outside the maze. Your goal is to find the nearest exit from the entrance
. An exit is defined as an empty cell that is at the border of the maze
. The entrance
does not count as an exit.
Return the number of steps in the shortest path from the entrance
to the nearest exit, or -1
if no such path exists.
class Solution:
def nearestExit(self, maze: List[List[str]], entrance: List[int]) -> int:
def valid(row, col):
return 0 <= row < m and 0 <= col < n and maze[row][col] == '.'
m = len(maze)
n = len(maze[0])
dirs = [(1,0),(-1,0),(0,1),(0,-1)]
start_node = tuple(entrance)
seen = {start_node}
queue = deque([(*start_node, 0)])
exit_rows = {0, m - 1}
exit_cols = {0, n - 1}
while queue:
row, col, moves = queue.popleft()
if row in exit_rows or col in exit_cols:
if row != start_node[0] or col != start_node[1]:
return moves
for dr, dc in dirs:
next_row, next_col = row + dr, col + dc
next_node = (next_row, next_col)
if valid(*next_node) and next_node not in seen:
seen.add(next_node)
queue.append((*next_node, moves + 1))
return -1
There are several variables to initialize before the proper traversal and that is okay.
LC 909. Snakes and Ladders (✓)
On an N x N
board
, the numbers from 1
to N*N
are written boustrophedonically starting from the bottom left of the board, and alternating direction each row. For example, for a 6 x 6 board, the numbers are written as follows:
You start on square 1
of the board (which is always in the last row and first column). Each move, starting from square x
, consists of the following:
- You choose a destination square
S
with numberx+1
,x+2
,x+3
,x+4
,x+5
, orx+6
, provided this number is<= N*N
.
- (This choice simulates the result of a standard 6-sided die roll: ie., there are always at most 6 destinations, regardless of the size of the board.)
- If
S
has a snake or ladder, you move to the destination of that snake or ladder. Otherwise, you move toS
.
A board square on row r
and column c
has a "snake or ladder" if board[r][c] != -1
. The destination of that snake or ladder is board[r][c]
.
Note that you only take a snake or ladder at most once per move: if the destination to a snake or ladder is the start of another snake or ladder, you do not continue moving. (For example, if the board is [[4,-1],[-1,3]]
, and on the first move your destination square is 2
, then you finish your first move at 3
, because you do not continue moving to 4
.)
Return the least number of moves required to reach square N*N
. If it is not possible, return -1
.
class Solution:
def snakesAndLadders(self, board: List[List[int]]) -> int:
def valid(row, col):
return 0 <= row < n and 0 <= col < n
def label_to_cell(num):
row = (num - 1) // n
col = (num - 1) % n
if row % 2 == 1:
col = (n - 1) - col
return [(n - 1) - row, col]
n = len(board)
seen = {1}
queue = deque([(1,0)])
while queue:
label, moves = queue.popleft()
if label == n ** 2:
return moves
for next_label in range(label + 1, min(label + 6, n ** 2) + 1):
row, col = label_to_cell(next_label)
if valid(row, col) and next_label not in seen:
seen.add(next_label)
if board[row][col] != -1:
queue.append((board[row][col], moves + 1))
else:
queue.append((next_label, moves + 1))
return -1
This is a rough one, mostly because of the convoluted problem description. The trickiest part is coming up with a performant way of converting from label to cell:
def label_to_cell(num):
row = (num - 1) // n
col = (num - 1) % n
if row % 2 == 1:
col = (n - 1) - col
return [(n - 1) - row, col]
Coming up with the function above takes some patience and experimentation at first. Another challenge is the mental change from generally recording in the seen
set the position of the node we're processing; in this case, we only care about what node labels we've previously seen. As we explore neighbors, we add each label to the seen
set, but the items we add to the queue will depend on whether or not we've encounter a snake or ladder.
Implicit
Remarks
TBD
TBD
Examples
LC 752. Open the Lock (✓)
You have a lock in front of you with 4
circular wheels. Each wheel has 10
slots: '0'
, '1'
, '2'
, '3'
, '4'
, '5'
, '6'
, '7'
, '8'
, '9'
. The wheels can rotate freely and wrap around: for example we can turn '9'
to be '0'
, or '0'
to be '9'
. Each move consists of turning one wheel one slot.
The lock initially starts at '0000'
, a string representing the state of the 4 wheels.
You are given a list of deadends
dead ends, meaning if the lock displays any of these codes, the wheels of the lock will stop turning and you will be unable to open it.
Given a target
representing the value of the wheels that will unlock the lock, return the minimum total number of turns required to open the lock, or -1
if it is impossible.
class Solution:
def openLock(self, deadends: List[str], target: str) -> int:
def neighbors(node):
ans = []
for i in range(4):
num = int(node[i])
for change in [-1, 1]:
x = (num + change) % 10
ans.append(node[:i] + str(x) + node[i + 1:])
return ans
if "0000" in deadends:
return -1
queue = deque([("0000", 0)])
seen = set(deadends)
seen.add("0000")
while queue:
node, steps = queue.popleft()
if node == target:
return steps
for neighbor in neighbors(node):
if neighbor not in seen:
seen.add(neighbor)
queue.append((neighbor, steps + 1))
return -1
The solution above is quite Pythonic. The key insight in this problem is to view each combination as a node or state we want to visit once. A node's neighbors will be all nodes one letter change away. The trickier part of the problem becomes making the string manipulations effectively and actually generating the neighbors. The neighbors
function above does this effectively even though (num + change) % 10
seems odd at first because of how Python's modulus operator %
operates — its definition is floored, which means, for example, that -1 % 10 == 9
. In general, floored division means the remainder procured by the modulus operator will always have the same sign as the divisor. We could change (num + change) % 10
to (num + change + 10) % 10
to explicitly avoid this confusion if we wanted to.
Another approach is much less clean but also still effective in achieving the desired end result:
class Solution:
def openLock(self, deadends: List[str], target: str) -> int:
def dial_up_down(str_num):
num = int(str_num)
if 1 <= num <= 8:
return str(num - 1), str(num + 1)
else:
if num == 0:
return '9', '1'
else:
return '8', '0'
seen = {'0000'}
deadends = set(deadends)
queue = deque([('0000', 0)])
if target in deadends or '0000' in deadends:
return -1
while queue:
combination, moves = queue.popleft()
if combination == target:
return moves
candidates = []
combination = list(combination)
for i in range(len(combination)):
char = combination[i]
up, down = dial_up_down(char)
new_candidate_up = combination[:]
new_candidate_up[i] = up
new_candidate_down = combination[:]
new_candidate_down[i] = down
candidates.append("".join(new_candidate_up))
candidates.append("".join(new_candidate_down))
for candidate in candidates:
if candidate not in seen and candidate not in deadends:
seen.add(candidate)
queue.append((candidate, moves + 1))
return -1
LC 399. Evaluate Division (✓)
You are given an array of variable pairs equations
and an array of real numbers values
, where equations[i] = [Ai, Bi]
and values[i]
represent the equation Ai / Bi = values[i]
. Each Ai
or Bi
is a string that represents a single variable.
You are also given some queries
, where queries[j] = [Cj, Dj]
represents the jth
query where you must find the answer for Cj / Dj = ?
.
Return the answers to all queries. If a single answer cannot be determined, return -1.0
.
Note: The input is always valid. You may assume that evaluating the queries will not result in division by zero and that there is no contradiction.
class Solution:
def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
def build_graph(edges, weights):
graph = defaultdict(dict)
for i in range(len(edges)):
num, denom = edges[i]
weight = weights[i]
graph[num][denom] = weight
graph[denom][num] = 1 / weight
return graph
def answer_query(query):
num, denom = query
if num not in graph or denom not in graph:
return -1
seen = {num}
queue = deque([(num, 1)])
while queue:
node, result = queue.popleft()
if node == denom:
return result
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
queue.append((neighbor, result * graph[node][neighbor]))
return -1
graph = build_graph(equations, values)
return [ answer_query(query) for query in queries ]
This is kind of a wild problem. It takes a lot of imagination to view it as a graph problem at first. The idea is that each element of a quotient (i.e., numerator and denominator) is a node. An edge is the ratio of numerator to denominator as well as denominator to numerator (we view the equations
provided as an edge list of undirected edges). Each edge is weighted — the quotient value is the weight.
For example, if we're given that and , then we can model the process of trying to solve for as a graph traversal problem:
The idea is to start at node a
, the numerator, and try to eventually reach node c
, the denominator, by traveling along the weighted edges. As the diagram above shows, we should have , which we get by starting from a
with a product of and then multiplying it by the edge weights as we go: .
The BFS solution above is rather clean, but the DFS solution is arguably more intuitive in a sense (even though it may be slightly harder to code) because we're basically trying to determine whether or not there exists a path from the numerator to the denominator:
class Solution:
def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
def build_graph(edges, weights):
graph = defaultdict(dict)
for i in range(len(edges)):
num, denom = edges[i]
weight = weights[i]
graph[num][denom] = weight
graph[denom][num] = 1 / weight
return graph
def answer_query(query):
num, denom = query
if num not in graph or denom not in graph:
return -1
seen = {num}
def dfs(node):
if node == denom:
return 1
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
result = dfs(neighbor)
if result != -1:
return result * graph[node][neighbor]
return -1
return dfs(num)
graph = build_graph(equations, values)
return [ answer_query(query) for query in queries ]
LC 433. Minimum Genetic Mutation (✓)
A gene string can be represented by an 8-character long string, with choices from "A"
, "C"
, "G"
, "T"
.
Suppose we need to investigate about a mutation (mutation from "start" to "end"), where ONE mutation is defined as ONE single character changed in the gene string.
For example, "AACCGGTT" -> "AACCGGTA"
is 1 mutation.
Also, there is a given gene "bank", which records all the valid gene mutations. A gene must be in the bank to make it a valid gene string.
Now, given 3 things - start, end, bank, your task is to determine what is the minimum number of mutations needed to mutate from "start" to "end". If there is no such a mutation, return -1.
Note:
- Starting point is assumed to be valid, so it might not be included in the bank.
- If multiple mutations are needed, all mutations during in the sequence must be valid.
- You may assume start and end string is not the same.
class Solution:
def minMutation(self, startGene: str, endGene: str, bank: List[str]) -> int:
seen = {startGene}
bank = set(bank)
queue = deque([(startGene, 0)])
while queue:
node, mutations = queue.popleft()
if node == endGene:
return mutations
for char in 'ACGT':
for i in range(8):
neighbor = node[:i] + char + node[i+1:]
if neighbor not in seen and neighbor in bank:
seen.add(neighbor)
queue.append((neighbor, mutations + 1))
return -1
It's easy to over-complicate this problem. The core idea is that each gene is a node and nodes are connected by single-difference mutations. The solution above uses the if neighbor not in seen
to its advantage to effectively bypass logic for avoiding the consideration of the same gene more than once. That is, for char in 'ACGT'
means char
will obviously take on values 'A'
, 'C'
, 'G'
, and 'T'
even though the character for the current gene in this place is one of these characters. But since the the current node has already been seen, we will not continue a search from it. It's a clever way to simplify the rest of the logic. The 8
in for i in range(8)
is due to the fact that each gene string is 8
characters long.
Here's a longer and more complicated (not recommended) solution:
class Solution:
def minMutation(self, startGene: str, endGene: str, bank: List[str]) -> int:
def get_neighbors(node):
neighbors = []
for i in range(8):
for j in range(3):
char_mutation = code_to_char[(char_to_code[node[i]] + (j + 1)) % 4]
gene_mutation = node[:i] + char_mutation + node[i+1:]
if gene_mutation in bank:
neighbors.append(gene_mutation)
return neighbors
char_to_code = { 'A': 0, 'C': 1, 'G': 2, 'T': 3 }
code_to_char = { 0: 'A', 1: 'C', 2: 'G', 3: 'T' }
bank = set(bank)
seen = {startGene}
queue = deque([(startGene, 0)])
while queue:
node, mutations = queue.popleft()
if node == endGene:
return mutations
for neighbor in get_neighbors(node):
if neighbor not in seen:
seen.add(neighbor)
queue.append((neighbor, mutations + 1))
return -1
LC 1306. Jump Game III (✓)
Given an array of non-negative integers arr, you are initially positioned at start
index of the array. When you are at index i
, you can jump to i + arr[i]
or i - arr[i]
, check if you can reach to any index with value 0
.
Notice that you can not jump outside of the array at any time.
class Solution:
def canReach(self, arr: List[int], start: int) -> bool:
def valid(idx):
return 0 <= idx < n
n = len(arr)
seen = {start}
queue = deque([start])
while queue:
idx = queue.popleft()
if arr[idx] == 0:
return True
for neighbor in [ idx - arr[idx], idx + arr[idx] ]:
if valid(neighbor) and neighbor not in seen:
seen.add(neighbor)
queue.append(neighbor)
return False
The BFS solution above is a natural solution for this disguised graph problem.
LC 2101. Detonate the Maximum Bombs (✓)
You are given a list of bombs. The range of a bomb is defined as the area where its effect can be felt. This area is in the shape of a circle with the center as the location of the bomb.
The bombs are represented by a 0-indexed 2D integer array bombs
where bombs[i] = [xi, yi, ri]
. xi
and yi
denote the X-coordinate and Y-coordinate of the location of the i
th bomb, whereas ri
denotes the radius of its range.
You may choose to detonate a single bomb. When a bomb is detonated, it will detonate all bombs that lie in its range. These bombs will further detonate the bombs that lie in their ranges.
Given the list of bombs
, return the maximum number of bombs that can be detonated if you are allowed to detonate only one bomb.
class Solution:
def maximumDetonation(self, bombs: List[List[int]]) -> int:
def build_adj_list(edges):
graph = defaultdict(list)
n = len(edges)
for i in range(n):
x1, y1, r1 = bombs[i]
for j in range(i + 1, n):
x2, y2, r2 = bombs[j]
dist = ((x2-x1) ** 2 + (y2-y1) ** 2) ** (1/2)
if r1 >= dist:
graph[i].append(j)
if r2 >= dist:
graph[j].append(i)
return graph
def bombs_detonated(bomb):
seen = {bomb}
queue = deque([(bomb)])
count = 0
while queue:
node = queue.popleft()
count += 1
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
queue.append(neighbor)
return count
graph = build_adj_list(bombs)
return max(bombs_detonated(bomb) for bomb in range(len(bombs)))
Time to remember what the distance between two points is! But for real. The idea here is that each bomb is a node and bomb A
is connected to bomb B
if bomb B
lies within bomb A
's blast radius (and vice-verse, indicating we should use a directed graph to model this problem). We then explore what happens when any single bomb is detonated — how many bombs in total will be detonated after any one bomb is detonated? We want the maximum.
We can use a BFS or DFS to answer this question here. The approach above uses a BFS. One small edge case to be aware of is that nothing prevents two bombs from being placed at the exact same location and with the same radius; that is, the entries in bombs
will be identical, but they will need to be treated separately. Hence, when we build our graph, we should use each bomb's index as its node label as opposed to a tuple for the bomb. Just because a tuple is immutable/hashable does not mean we should use it in such a way; additionally, if we use tuples for each bomb, then we fail to take into account when two bombs can be in the same location and with the same radius.
General
Remarks
TBD
Examples
LC 1557. Minimum Number of Vertices to Reach All Nodes (✓)
Given a directed acyclic graph, with n
vertices numbered from 0
to n-1
, and an array edges
where edges[i] = [fromi, toi]
represents a directed edge from node fromi
to node toi
.
Find the smallest set of vertices from which all nodes in the graph are reachable. It's guaranteed that a unique solution exists.
Notice that you can return the vertices in any order.
class Solution:
def findSmallestSetOfVertices(self, n: int, edges: List[List[int]]) -> List[int]:
indegrees = [0] * n
for _, destination in edges:
indegrees[destination] += 1
return [ node for node in range(n) if indegrees[node] == 0 ]
Arguably the hardest part of this problem is actually understanding what it's really asking for:
Find the smallest set of vertices from which all nodes in the graph are reachable. It's guaranteed that a unique solution exists. Notice that you can return the vertices in any order.
What does this really mean? It essentially means that all nodes that have no inbound neighboring nodes will comprise the smallest set of vertices we seek. How? Because these nodes only have outbound connections to all other nodes (they are not reachable from any other nodes). Hence, the problem boils down to finding all nodes that have an indegree of 0
, as demonstrated above.
Heaps
Initialize heap in O(n) time
Remarks
-
Min heap: Sometimes it is helpful to initialize our own min heap in such a way that we simply append the smallest elements to a list, one at a time. Time and space: .
-
Max heap: Python uses a min heap. Hence, we need to add the biggest elements to the heap, in order, but negate them as we do so. Time and space .
-
Heapify (to min heap): Both approaches above assume we have the luxury of being able to add the minimum or maximum elements to a list and then use that list as a min or max heap, respectively. This is often not the case. We will often want to take an existing list of elements,
arr
, and modify it in-place to be a heap in time. This is not a trivial task, as Python's source code for theheapify
method shows. This is not something we want to have to manually implement ourselves. Fortunately, we don't have to!Simply use Python's
heapify
method. Time: ; space: . -
Heapify (to max heap): The
heapify
approach above only applies for a min heap. As noted in this question, Python's source code actually supports a_heapify_max
method! But there's not similar support for operations like pushing to the max heap. We effectively have two options to utilize the fulle suite of methods available in Python'sheapq
module:- Negate the elements of
arr
in-place. Then use theheapify
method to simulate a max heap even though Python is technically maintaining a min heap. Time: ; space: . - Loop through all elements in
arr
, negating each along the way, and simultaneously use theheappush
method to push the element to the max heap we are building. Time: ; space: .
The time cost of the first approach is since the initial loop through to negate all numbers is and the
heapify
method is also . But practically speaking the second method is also fairly effective and more intuitive. But the first option is surely better for coding interviews! - Negate the elements of
- Min heap
- Max heap
- Heapify (to min heap)
- Heapify (to max heap)
min_heap = []
for i in range(n):
min_heap.append(i)
max_heap = []
for i in range(n - 1, -1, -1):
max_heap.append(-1 * i)
import heapq
arr = [ ... ] # n elements
heapq.heapify(arr)
import heapq
# Approach 1 (negate, heapify in-place); T: O(n); S: O(1)
arr = [ ... ] # n elements
arr = [ -1 * arr[i] for i in range(len(arr)) ] # negate elements in-place
heapq.heapify(arr)
# Approach 2 (negate, build heap); T: O(n lg n); S: O(n)
arr = [ ... ] # n elements
the_heap = []
for num in arr:
heapq.heappush(the_heap, -1 * num)
Examples
LC 1845. Seat Reservation Manager
Design a system that manages the reservation state of n
seats that are numbered from 1
to n
.
Implement the SeatManager
class:
SeatManager(int n)
Initializes aSeatManager
object that will managen
seats numbered from1
ton
. All seats are initially available.int reserve()
Fetches the smallest-numbered unreserved seat, reserves it, and returns its number.void unreserve(int seatNumber)
Unreserves the seat with the givenseatNumber
.
import heapq
class SeatManager:
def __init__(self, n: int):
self.reserved = [ i for i in range(1, n + 1) ]
def reserve(self) -> int:
return heapq.heappop(self.reserved)
def unreserve(self, seatNumber: int) -> None:
heapq.heappush(self.reserved, seatNumber)
# Your SeatManager object will be instantiated and called as such:
# obj = SeatManager(n)
# param_1 = obj.reserve()
# obj.unreserve(seatNumber)
Linked lists
What does it mean for two linked list nodes to be "equal"?
TLDR: The default comparison is made by determining whether or not the two nodes point to the same object in memory; that is, node1 == node2
effectively equates to id(node1) == id(node2)
by default in Python.
If node1
and node2
are both nodes from a linked list, then what does node1 == node2
actually test? How is the returned boolean computed? The following snippet is illustrative:
nodeA = ListNode(1)
nodeB = nodeA
nodeC = ListNode(1)
print(nodeA == nodeB) # True
print(nodeA == nodeC) # False
As noted on Stack Overflow, for an arbitrary object, the ==
operator will only return true if the two objects are the same object (i.e., if they refer to the same address in memory). This is often what we actually want when it comes to linked list nodes.
The Python docs about value comparisons back up the note above:
The operators
<
,>
,==
,>=
,<=
, and!=
compare the values of two objects. The objects do not need to have the same type.Chapter Objects, values and types states that objects have a value (in addition to type and identity). The value of an object is a rather abstract notion in Python: For example, there is no canonical access method for an object's value. Also, there is no requirement that the value of an object should be constructed in a particular way, e.g. comprised of all its data attributes. Comparison operators implement a particular notion of what the value of an object is. One can think of them as defining the value of an object indirectly, by means of their comparison implementation.
Because all types are (direct or indirect) subtypes of object, they inherit the default comparison behavior from object. Types can customize their comparison behavior by implementing rich comparison methods like
__lt__()
, described in Basic customization.The default behavior for equality comparison (
==
and!=
) is based on the identity of the objects. Hence, equality comparison of instances with the same identity results in equality, and equality comparison of instances with different identities results in inequality. A motivation for this default behavior is the desire that all objects should be reflexive (i.e.x is y
impliesx == y
).
One can override the default __eq__
, if desired, but this will likely lead to undesired behavior, particularly in this context of dealing with linked lists. As the Python docs note:
By default,
object
implements__eq__()
by usingis
, returningNotImplemented
in the case of a false comparison:True if x is y else NotImplemented
. For__ne__()
, by default it delegates to__eq__()
and inverts the result unless it isNotImplemented
. There are no other implied relationships among the comparison operators or default implementations; for example, the truth of (x<y
orx==y
) does not implyx<=y
. To automatically generate ordering operations from a single root operation, seefunctools.total_ordering()
.
What are sentinel nodes and how can they be useful for solving linked list problems?
TLDR: Sentinel or "dummy" nodes can simplify linked list operations, especially those involving the head
for singly linked lists (one sentinel node) or the head
and tail
for doubly linked lists (two sentinel nodes).
Sentinel nodes, also known as "dummy" nodes, often simplify linked list operations considerably, especially when dealing with addition or removal operations involving the head
of a linked list. The following brief explanation/definition from ChatGPT is arguably more readable than the linked Wiki article above:
A sentinel node is a dummy or placeholder node used in data structures to simplify boundary conditions and operations. In the context of linked lists, a sentinel node acts as a non-data-bearing head or tail node, eliminating the need for handling special cases for operations at the beginning or end of the list, such as insertions or deletions. It streamlines code by providing a consistent starting or ending point, regardless of whether the list is empty or contains elements.
In the context of solving LeetCode problems (or algorithmic problems in general), you will often see a sentinel node used for singly linked lists in the following manner:
class Solution:
def fn(self, head: Optional[ListNode]) -> Optional[ListNode]:
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
curr = head
# do something with prev, curr, and execute other logic
return sentinel.next # return the new/original head of the modified linked list
A very simple example that highlights the utility of sentinel nodes is the following: Given an integer array nums
, how can you convert the array into a linked list? The immediate problem that confronts is what to do about the head
for the new linked list. It will obviously be the first element in nums
, but is there a nice way of building the list? A simple sentinel node allows us to do just that:
def linked_list_from_arr(nums):
sentinel = ListNode(-1)
curr = sentinel
for num in nums:
curr.next = ListNode(num)
curr = curr.next
return sentinel.next
Pointer manipulation and memory indexes
TLDR: Linked list problems are all about pointer manipulation. Rarely do we change val
attributes for nodes in a linked list, but we regularly shift nodes around by artfully manipulating next
attributes for various nodes (i.e., pointer manipulation).
- Note 1: The assignment
my_var = some_node
meansmy_var
will always point to the originalsome_node
object in memory unless modified directly (e.g.,my_var = something_else
). Caveat to this is the note below. - Note 2: The attribute values of
some_node
, namelyval
andnext
, can be modified indirectly by various means. Hence, even thoughmy_var
may not point to a different object in memory, if the attribute values of the underlying object in memory are changed, then it will likely appear as thoughmy_var
no longer refers to its originally referenced object even though it technically does.
Note 1 (variables remain at nodes unless modifed directly):
When you assign a pointer to an existing linked list node, the pointer refers to the object in memory. Suppose you have a node head
:
ptr = head
head = head.next
head = None
After these lines of code, ptr
still refers to the original head
node, even though the head
variable changed. This underscores an important concept concerning linked lists and pointer manipulation: variables remain at nodes unless they are modified directly (i.e., ptr = something
is the only way to modify ptr
).
We can see this more easily and explicitly in Python by using the id()
function, which returns the address of the object in memory (for the CPython implementation of Python):
class ListNode:
def __init__(self, val):
self.val = val
self.next = None
one = ListNode(1)
two = ListNode(2)
one.next = two
head = one
print(id(head)) # 4423470576
ptr = head
print(id(ptr)) # 4423470576
head = head.next
print(id(head)) # 4423470480
head = None
print(id(head)) # 4420398192
print(id(ptr)) # 4423470576
Objects are mutable in Python. When we make the assignment ptr = head
, we are effectively making ptr
point to the same memory address as head
. The assignment more or less looks like the following:
Subsequently, when we assign head
to head.next
, we are making head
point to the same memory address as head.next
:
Note that ptr
is not still pointing at head
(it's pointing to the object in memory that head
originally pointed to). The takeaway: a variable that serves as a specific node assignment will remain as such a specific node assignment unless modified directly to point to another node. This is what allows us above to point ptr
to head
and then do whatever we want to with head
all while maintaining our reference to the original head
with ptr
.
Note 2 (node attributes such as val
and next
may be modified indirectly):
As detailed in the note above, the code block
ptr = head
head = head.next
head = None
means that ptr
, unless modified directly, will always point to the object in memory originally referred to by head
.
Importantly, however, the object in memory originally referred to by head
can have its attributes modified indirectly (i.e., the val
and next
attributes of the head
object to which ptr
points can be modified without altering ptr
directly). The following example can help illustrate this important observation:
head = ListNode(100)
node1 = ListNode(1)
node2 = ListNode(2)
node3 = ListNode(3)
head.next = node1
node1.next = node2
node2.next = node3
sentinel = ListNode(-1)
sentinel.next = head
ptr = head
head = head.next
head = None
print(id(ptr)) # 0123456789
print(ptr) # 100 -> 1 -> 2 -> 3 -> None
print(ptr.val) # 100
print(ptr.next) # 1 -> 2 -> 3 -> None
sentinel.next.val = 7 # original "head" attribute "val" changes from 100 to 7
sentinel.next.next = node2 # original "head" attribute "next" changes from node1 to node2
print(id(ptr)) # 0123456789
print(ptr) # 7 -> 2 -> 3 -> None
print(ptr.val) # 7
print(ptr.next) # 2 -> 3 -> None
Note that ptr
was never modified directly and that it still points to the same object in memory before and after the sentinel.next.[val|next]
changes (i.e., printing id(ptr)
before and after the changes confirms this since the printed values are the same).
But it certainly seems like ptr
has changed somehow. This is because the object ptr
points to has changed in terms of its val
and next
attribute values. We changed those values indirectly by altering sentinel.next
, which pointed to the same mutable object in memory as ptr
. Altering the attribute values of the original head
object in memory directly with ptr.[val|next]
or indirectly with sentinel.next.[val|next]
makes no difference. The effect is the same: ptr
looks different even though it still points to the same object in memory.
Example: A solution to problem LC 2095. Delete the Middle Node of a Linked List can illustrate the concepts in the second note in a concrete, practical manner:
class Solution:
def deleteMiddle(self, head: Optional[ListNode]) -> Optional[ListNode]:
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
slow = head
fast = head
# slow points to middle node upon termination
while fast and fast.next:
prev = slow
slow = slow.next
fast = fast.next.next
# skip middle node, effectively removing it
prev.next = prev.next.next
return sentinel.next
How does this illustrate the utility of the second note? Consider the case where the linked list is just a single node: [1]
. Removing the middle node means removing the only node, meaning we should return []
or None
. How is the solution above correct for this edge case since the while
loop does not fire, sentinel.next
originally points to head
, and we ultimately return sentinel.next
without ever making a direct reassignment? How does sentinel.next
end up pointing to a null value even though it originally points to head
?
The reason is due to how prev
is being manipulated. Since prev
points to sentinel
and the while
loop doesn't fire, the assignment prev.next = prev.next.next
effectively changes what sentinel.next
points to; that is, we're not changing sentinel
directly, but we are changing the next
attribute value of the underlying object in memory being pointed to by sentinel
. Hence, prev.next = prev.next.next
is effectively the assignment sentinel.next = sentinel.next.next
; since sentinel.next
points to head
and head.next
is None
, we get our desired result by returning the null value (which indicates the single object has been removed).
Visualizing singly linked lists and debugging your code locally
TLDR: Add a simple __repr__
method to the base ListNode
class. This lets you view the linked list extending from any given my_node
by simply running print(my_node)
. To effectively use sample LeetCode inputs in your local testing, you'll need a function arr_to_ll
to convert integer arrays into linked lists.
Linked list problems are all about pointer manipulation and it can help a ton to see things, especially if you need to debug issues with your code. LeetCode almost always provides a linked list by simply providing the head
node, where all nodes in the list have been created with the following ListNode
class (or some minor variation):
class ListNode:
def __init__(self, x):
self.val = x
self.next = None
Given any node, probably the easiest way to visualize the singly linked list that extends from this node is to implement a basic __repr__
method as part of the ListNode
class:
class ListNode:
def __init__(self, x):
self.val = x
self.next = None
def __repr__(self):
node = self
nodes = []
while node:
nodes.append(str(node.val))
node = node.next
nodes.append('None')
return ' -> '.join(nodes)
Of course, LeetCode generally shows a linked list as input by providing an integer array. To effectively test your local code on LeetCode inputs, it's necessary to first convert the integer array to a linked list:
def arr_to_ll(arr):
sentinel = ListNode(-1)
curr = sentinel
for val in arr:
new_node = ListNode(val)
curr.next = new_node
curr = curr.next
return sentinel.next
We can now effectively use LeetCode inputs, visualize them, and also subsequently visualize whatever changes we make to the input:
example_input = [5,2,6,3,9,1,7,3,8,4]
head = arr_to_ll(example_input)
print(head) # 5 -> 2 -> 6 -> 3 -> 9 -> 1 -> 7 -> 3 -> 8 -> 4 -> None
Fast and slow pointers
Remarks
The "fast and slow" pointer technique is a two pointer technique with its own special use cases when it comes to linked lists. Specifically, the idea is that the pointers do not move side by side — they could move at different "speeds" during iteration, begin iteration from different locations, etc. Whatever the abstract difference is, the important point is that they do not move side by side in unison.
There are many two pointer variations when it comes to arrays and strings, but the fast and slow pointer technique for linked lists usually presents itself as, "move the slow pointer one node per iteration, and move the fast node two nodes per iteration."
def fn(head):
slow = head
fast = head
ans = 0
while fast and fast.next:
# some logic
slow = slow.next
fast = fast.next.next
return ans
Examples
Return the middle node value of a linked list with an odd number of nodes (✓)
def get_middle(head):
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow.val
The main problem here is not knowing the length of the linked list ahead of time. We could push the node values into an array and then find the middle node value that way, but that would be cheating and would never pass in an interview. We could also iterate through the entire linked list, note the full length, iterate through half the full length, and then report the value of the middle node. But that also seems potentially inefficient. The solution above takes advantage of the fact that the slow node will have traveled half the distance of the fast node once the while
loop terminates (meaning the slow node should be in the middle of the list, as desired).
It's easier to see with a visualization like the following (x
is the desired node while s
and f
denote the slow and fast pointers, respectively):
x
12345
s
f
x
12345
s
f
x
12345
s
f
Note that now fast
is not null, but fast.next
is null, meaning the while
loop will not execute, and s
points to the middle node, x
, as desired.
Return the kth node from the end provided that it exists (✓)
def find_node(head, k):
slow = head
fast = head
for _ in range(k):
fast = fast.next
while fast:
slow = slow.next
fast = fast.next
return slow
The idea here is to first advance the fast
pointer k
units ahead of the slow
pointer and then to move them at the same speed for each iteration, meaning they will always be k
units apart. When the fast
pointer reaches the end (i.e., it points to null), the slow
pointer will still be k
nodes behind the fast
pointer, which is the desired result.
The following illustration, where k = 3
, may help:
k
1234567
s
f
k
1234567
s
f
k
1234567
s
f
k
1234567
s
f
k
1234567
s
f
k
1234567
s
f
LC 141. Linked List Cycle (✓) ★
Given head
, the head of a linked list, determine if the linked list has a cycle in it.
There is a cycle in a linked list if there is some node in the list that can be reached again by continuously following the next
pointer. Internally, pos
is used to denote the index of the node that tail's next
pointer is connected to. Note that pos
is not passed as a parameter.
Return true
if there is a cycle in the linked list. Otherwise, return false
.
class Solution:
def hasCycle(self, head: Optional[ListNode]) -> bool:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
The solution above takes advantage of Floyd's cycle-detection algorithm, which is quite overcomplicated on the Wikipedia link. The main outcome of the algorithm is that the slow
and fast
pointers above must be equal at some point if there is a cycle. But how do we know this to be true? Would it be possible for the fast
pointer to "jump" the slow
pointer in some way? How do we know they'll actually meet if there's a cycle?
The easiest way to answer this question is to break down all of the possibilities once slow
and fast
are both within the cycle; that is, fast
will obviously be well ahead of slow
until slow
actually enters the cycle, at which point slow
and fast
will either be at the same node or fast
will be behind slow
. We have the following possibilities:
- Case 1:
fast
andslow
meet exactly whenslow
enters the cycle (i.e., at the beginning of the cycle) - Case 2:
fast
is exactly one node behindslow
, and the two nodes will meet on the very next iteration sinceslow
will move forward one node andfast
will move forward two nodes - Case 3:
fast
is exactly two nodes behindslow
, and the nodes will meet after two more iterations sinceslow
will have moved two more nodes andfast
will have moved four more nodes - Case 4:
fast
is more than two nodes behindslow
, which meansfast
will eventually catch up toslow
in such a way that this case resolves into either case 2 or case 3, which means the nodes will still meet
The important takeaway above is that fast
will never jump slow
. The two nodes must meet if there is a cycle.
LC 142. Linked List Cycle II (✓) ★★
Given a linked list, return the node where the cycle begins. If there is no cycle, return null
.
There is a cycle in a linked list if there is some node in the list that can be reached again by continuously following the next
pointer. Internally, pos
is used to denote the index of the node that tail's next
pointer is connected to. Note that pos
is not passed as a parameter.
Notice that you should not modify the linked list.
class Solution:
def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
return None
Determining whether or not a cycle exists is a pre-requisite for coming up with a solution for this problem. We can effectively tweak our solution to LC 141. Linked List Cycle in order to come up with the solution above:
class Solution:
def hasCycle(self, head: Optional[ListNode]) -> bool:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
That is, as the highlighted lines above suggest, we need to do something once we actually have determined that we have a cycle. What should we do? The following image (from this YouTube video) may help:
This image shows how X
units are traveled before the cycle starts. We'll assume we're moving in a counterclockwise direction once we've entered the cycle. We can't know for sure how many times fast
might travel the full cycle of Y + Z
units before slow
enters the cycle. The important observation is that eventually there will come a revolution where fast
starts at the beginning of the cycle and slow
joins the cycle during this revolution.
How many units does fast
travel before it meets slow
? Where exactly fast
is when slow
enters the cycle does not matter — the important point is that it must travel the full distance of Z + Y + Z
before coming back around to meet slow
. In total, then, fast
travels a distance of X + R(Z + Y) + (Z + Y + Z)
, where R(Z + Y)
denotes that fast
has traveled R
full cycle lengths before it starts its last revolution before slow
enters the cycle. Since slow
travels a total distance of X + Z
, we have the following useful equation (we discard R(Z + Y)
during the second simplifying step due to its cyclical nature):
2(X + Z) = X + R(Z + Y) + (Z + Y + Z)
2X + 2Z = X + 2Z + Y
X = Y
What the above equation means in the context of this problem is that all we really need to do to actually return the node at which the cycle starts is reset one of the nodes to start at the head
and then just move them together in unison until they meet again (at the beginning of the cycle). We can then return the desired node.
LC 876. Middle of the Linked List (✓)
Given a non-empty, singly linked list with head node head
, return a middle node of linked list.
If there are two middle nodes, return the second middle node.
class Solution:
def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
This uses the classic fast-slow technique. For an odd-length list:
x
12345
s
f
x
12345
s
f
x
12345
s
f
Done! And we're fortunate for even-length lists since we're asked to return the second middle node:
x
123456
s
f
x
123456
s
f
x
123456
s
f
x
123456
s
f
Things work out nicely in this case. What if, however, we were asked to return the first middle node? Then we could make use of a prev
pointer to basically lag behind the slow
pointer. Which node we returned at the end would depend on where fast
was (not null for odd-length lists and null for even-length lists):
class Solution:
def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
slow = head
fast = head
while fast and fast.next:
prev = slow
slow = slow.next
fast = fast.next.next
return prev if not fast else slow
LC 83. Remove Duplicates from Sorted List (✓) ★
Given the head
of a sorted linked list, delete all duplicates such that each element appears only once. Return the linked list sorted as well.
class Solution:
def deleteDuplicates(self, head: Optional[ListNode]) -> Optional[ListNode]:
curr = head
while curr and curr.next:
if curr.val == curr.next.val:
curr.next = curr.next.next
else:
curr = curr.next
return head
The solution above is probably less obvious than it should seem at first glance, but it cleverly avoids the need to use two pointers (slow and fast) because of how curr
is manipulated. Three observations worth making:
- It only makes sense to test for duplicates if we have at least two nodes for comparison. Hence, we only look to make modificates while
curr
andcurr.next
both exist. - If we have
node1 -> node2 -> ...
, then how can we effectively "delete"node2
from this list? We do so in the following standard way (i.e., using its previous node,node1
, to skip it):node1.next = node1.next.next
. Such an assignment meansnode2
is "skipped" fromnode1
and effectively removed from the chain. In the context of this problem, ifcurr.val == curr.next.val
, then we want to removecurr.next
from the list, and we do so in the standard way:curr.next = curr.next.next
. - If
curr
and the next node have different values, then we simply advancecurr
in the standard way:curr = curr.next
. Note howcurr
is only ever advanced/reassigned when we encounter different values. Since the list is already sorted, this ensures the resultant list only contains distinct values, as desired.
The solution above is elegant, straightforward, and uncomplicated; however, if we wanted to use two pointers, then how would we do so? We could use lag
and lead
as slow and fast pointers, respectively. The idea is that lag
always points to the first non-duplicate value we find, and it "lags" behind lead
until lead
discovers a value for which lead.val != lag.val
, whereby lag.next
now points to this new non-duplicate node/value, and we update lag
to point to where lead
was when this non-duplicated value was discovered.
The main potential "gotcha" with this approach occurs at the end of the list. If there are duplicates at the end of the list, then lag
points at the first duplicate value and lead
never discovers a non-duplicate value. Thus, if we don't manually make the assignment lag.next = None
after lead
has iterated through the entire list, then we run the risk of accidentally including all duplicated values at the end of the list.
Here's the working solution for this two pointer approach:
class Solution:
def deleteDuplicates(self, head: Optional[ListNode]) -> Optional[ListNode]:
if not head:
return None
lead = head
lag = head
while lead:
lead = lead.next
if lead and lead.val != lag.val:
lag.next = lead
lag = lead
lag.next = None
return head
The primary difference between the solutions is how the duplicate nodes are being deleted. In the first approach, duplicate nodes are being deleted as they are encountered. A duplicate node is deleted as soon as it's encountered: curr.next = curr.next.next
. In the second approach, all duplicate nodes are deleted as soon as a non-duplicate value is found: lag.next = lead
.
LC 2095. Delete the Middle Node of a Linked List (✓)
You are given the head
of a linked list. Delete the middle node, and return the head
of the modified linked list.
The middle node of a linked list of size n is the ⌊n / 2⌋
th node from the start using 0-based indexing, where ⌊x⌋
denotes the largest integer less than or equal to x
.
- For
n = 1, 2, 3, 4
, and5
, the middle nodes are0
,1
,1
,2
, and2
, respectively.
class Solution:
def deleteMiddle(self, head: Optional[ListNode]) -> Optional[ListNode]:
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
slow = fast = head
while fast and fast.next:
prev = slow
slow = slow.next
fast = fast.next.next
prev.next = prev.next.next
return sentinel.next
Since slow
will point to the middle node after the while loop terminates above, what we really need is for the node prior to slow
to skip it, effectively deleting it: prev.next = prev.next.next
.
LC 19. Remove Nth Node From End of List (✓)
Given the head
of a linked list, remove the nth
node from the end of the list and return its head.
Follow up: Could you do this in one pass?
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
slow = fast = head
for _ in range(n):
fast = fast.next
while fast:
prev = slow
slow = slow.next
fast = fast.next
prev.next = prev.next.next
return sentinel.next
The idea: advance fast
nodes past slow
so that there are always nodes between these pointers. When the while loop terminates, slow
will be units behind fast
or nodes from the end of the list, as desired. But we need to delete this node, hence the use of the prev
pointer.
LC 82. Remove Duplicates from Sorted List II (✓) ★
Given the head
of a sorted linked list, delete all nodes that have duplicate numbers, leaving only distinct numbers from the original list. Return the linked list sorted as well.
class Solution:
def deleteDuplicates(self, head: Optional[ListNode]) -> Optional[ListNode]:
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
curr = head
while curr and curr.next:
if curr.val == curr.next.val:
while curr and curr.next and curr.val == curr.next.val:
curr.next = curr.next.next
curr = curr.next
prev.next = curr
else:
prev = curr
curr = curr.next
return sentinel.next
This problem is obviously quite similar to LC 83. Remove Duplicates from Sorted List, but the requirement to remove all numbers that have duplicates basically makes this a completely different problem. As always, it's amazing how helpful a drawing that solves a basic example can be:
The picture above illustrates how prev
and curr
need to be moved together in unison when there are not duplicates; however, if duplicates are encountered, then we can employ a strategy similar to that used in LC 83, where we effectively "delete" duplicates as they are encountered by changing the next
attribute for curr
: curr.next = curr.next.next
. The twist with this problem is that we want to retain none of the duplicate values once we've encountered a non-duplicate value. As the figure suggests, one way of achieving this is to move curr
past the last duplicate, and then change the next
attribute for prev
to point to the updated, non-duplicate curr
node. This effectively cuts out all duplicate values, and prev
itself is only ever updated when non-duplicate values are adjacent.
LC 1721. Swapping Nodes in a Linked List (✓)
You are given the head
of a linked list, and an integer k
.
Return the head of the linked list after swapping the values of the kth
node from the beginning and the kth
node from the end (the list is 1-indexed).
class Solution:
def swapNodes(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
left = right = null_checker = head
for _ in range(k - 1):
left = left.next
null_checker = null_checker.next
while null_checker.next:
right = right.next
null_checker = null_checker.next
left.val, right.val = right.val, left.val
return head
The fast-slow pointer approach above works great for this problem where only the node values need to be swapped. But switching the nodes themselves requires a little bit more work:
class Solution:
def swapNodes(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
def swap_nodes(prev_left, prev_right):
if not prev_left or not prev_right \
or not prev_left.next or not prev_right.next \
or prev_left.next == prev_right.next:
return
left = prev_left.next
right = prev_right.next
prev_left.next, prev_right.next = right, left
right.next, left.next = left.next, right.next
sentinel = ListNode(-1)
sentinel.next = head
prev_left = prev_right = sentinel
null_checker = head
for _ in range(k - 1):
prev_left = prev_left.next
null_checker = null_checker.next
while null_checker.next:
prev_right = prev_right.next
null_checker = null_checker.next
swap_nodes(prev_left, prev_right)
return sentinel.next
See more about this approach in the "swap two nodes" template section.
Reverse a linked list
Remarks
The template below is the simplest way of reversing a portion of a linked list from a given node
(potentially the entire list if given the head
of a linked list). One important observation is that the reversed portion is effectively severed from the rest of the list.
For example:
ex = [1,2,3,4,5,6] # integer array
head = arr_to_ll(ex) # convert integer array to linked list
print(fn(head.next.next)) # start reversal from node 3
# outcome: 6 -> 5 -> 4 -> 3 -> None
Suppose the outcome above is not desirable and instead we wanted the reversal to be incorporated into the original list: 1 -> 2 -> 6 -> 5 -> 4 -> 3 -> None
. We clearly need to preserve the node previous to the node where the reversal starts (i.e., the 2
node is a sort of "connecting" node that needs to be preserved). Solving this problem is outside the scope of this template; however, the subsequent template for reversing k
nodes of a linked list does solve this problem.
def fn(node):
prev = None
curr = node
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
Examples
LC 206. Reverse Linked List (✓)
Given the head
of a singly linked list, reverse the list, and return the reversed list.
class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev = None
curr = head
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
This is a classic linked list "pointer manipulation" problem, and the approach above is the conventionally efficient approach, where next_node
effectively serves as a temporary variable (so we don't lose the linked list connection when we make the assignment curr.next = prev
).
One thing worth noting about this conventional approach is how the reversed segment is effectively broken off from the rest of the list if the reversal is started on a node other than the linked list's head. For example, consider the list 1 -> 2 -> 3 -> 4 -> 5 -> None
. If we start the reversal at node 3
, then reversing 3 -> 4 -> 5
gives us 5 -> 4 -> 3
, but what happens to the rest of the list? We lose the connection. Hence, starting from the head of the original linked list we have 1 -> 2 -> 3 -> None
while starting from the head of the reversed segment gives us 5 -> 4 -> 3 -> None
.
Python's support for tuple packing/unpacking and multiple assignment means we can simplify the reversal in a pretty dramatic fashion:
class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev = None
curr = head
while curr:
curr.next, prev, curr = prev, curr, curr.next
return prev
Here's a quick breakdown:
- Tuple Packing: The expression on the right side of the assignment,
prev, curr, curr.next
, effectively creates a tuple of these three references/values. This happens before any assignments are made, which means the original values are preserved in the tuple. - Multiple Assignment: The left-hand side of the assignment has three variables,
curr.next, prev, curr
, awaiting new values. Python will assign the values from the tuple on the right-hand side to these variables in a left-to-right sequence. - Tuple Unpacking: The assignment unpacks the values stored in the tuple into the variables on the left. This happens simultaneously, so the operations do not interfere with each other. Specifically:
curr.next
is assignedprev
. This reverses the pointer/link of the current node to point to the previous node, effectively starting the reversal of the linked list.prev
is assignedcurr
. This moves theprev
pointer one node forward to the current node, which after the assignment, becomes the new "previous" node.curr
is assignedcurr.next
(the originalcurr.next
before any changes). This moves thecurr
pointer one node forward in the original list, to continue the reversal process on the next iteration.
The one-liner above is slick and may be useful for problems where reversing the linked list (or part of it) is naturally part of the solution (e.g., LC 2130. Maximum Twin Sum of a Linked List).
LC 24. Swap Nodes in Pairs (✓)
Given a linked list, swap every two adjacent nodes and return its head.
class Solution:
def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]:
if not head or not head.next:
return head
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
left = head
while left and left.next:
right = left.next
next_left = left.next.next
right.next = left
left.next = next_left
prev.next = right
prev = left
left = next_left
return sentinel.next
As with most linked list problems, this problem becomes much easier if we take a moment to draw what happens for a general pair swap:
The solution above naturally presents itself from this figure.
LC 2130. Maximum Twin Sum of a Linked List (✓) ★★
In a linked list of size n
, where n
is even, the i
th node (0-indexed) of the linked list is known as the twin of the (n-1-i)
th node, if 0 <= i <= (n / 2) - 1
.
- For example, if
n = 4
, then node0
is the twin of node3
, and node1
is the twin of node2
. These are the only nodes with twins forn = 4
.
The twin sum is defined as the sum of a node and its twin.
Given the head
of a linked list with even length, return the maximum twin sum of the linked list.
class Solution:
def pairSum(self, head: Optional[ListNode]) -> int:
def reverse(node):
prev = None
curr = node
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
left = head
right = reverse(slow)
ans = 0
while right:
ans = max(ans, left.val + right.val)
left = left.next
right = right.next
return ans
This is a fun one: Find the middle of the linked list, reverse the rest of the linked list, and then iterate back towards the middle from the head as well as from the end that was just reversed (i.e., the head of the newly reversed portion of the linked list), maintaining the maximum pairwise sum as you go. Small potential "gotcha": after the reversal, slow
points to the second middle node of the linked list (since the total number of nodes is always even), which means it's a single node past what we need to iterate through on the left hand side. The last while loop condition needs to be while right
; otherwise, if we used while left
, then trying to access right.val
would eventually throw an error because the reversed segment on the right would be exhausted while there's a single node left on the left side.
LC 234. Palindrome Linked List (✓)
Given the head
of a singly linked list, return true
if it is a palindrome.
class Solution:
def isPalindrome(self, head: Optional[ListNode]) -> bool:
def reverse(node):
prev = None
curr = node
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
if not head or not head.next:
return True
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
left = head
right = reverse(slow)
while right:
if left.val != right.val:
return False
left = left.next
right = right.next
return True
The solution above is likely the intended solution for this problem even though destroying the original linked list is not a very desirable side effect. It's also nice that we do not have to consider the length of the list when making the reversal. An odd-length list like 1 -> 2 -> 3 -> 2 -> 1
means that once the reversal occurs we get 1 -> 2 -> 3
starting from the original head and 1 -> 2 -> 3
starting from the head of the reversed segment. An even-length list like 1 -> 2 -> 2 -> 1
means after the reversal we have 1 -> 2 -> 2
starting from the head of the original list and 1 -> 2
starting from the head of the reversed segment. This is why it's important that our last while loop uses the condition while right
as opposed to while left
.
If the side effect mentioned above is not allowed, then we can use the "reverse k
nodes in-place" template to determine whether or not the list's values are palindromic and also restore the list itself (although the logic becomes a bit more complicated):
class Solution:
def isPalindrome(self, head: Optional[ListNode]) -> bool:
def reverse_k_nodes(prev, k):
if not prev.next or k < 2:
return prev.next
rev_start = prev.next
next_node = rev_start.next
rev_end = rev_start
count = 1
while count <= k - 1 and next_node:
rev_start.next = next_node.next
next_node.next = prev.next
prev.next = next_node
next_node = rev_start.next
count += 1
return rev_end
if not head or not head.next:
return True
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
slow = fast = head
char_count = 1
while fast and fast.next:
prev = slow
slow = slow.next
fast = fast.next.next
char_count += 1
middle = slow if fast else prev
reverse_k_nodes(middle, char_count)
right = middle.next
left = head
for _ in range(char_count - 1):
if left.val != right.val:
reverse_k_nodes(middle, char_count)
return False
left = left.next
right = right.next
# this reversal optional since values are palindromic after the in-place reversal
reverse_k_nodes(middle, char_count)
return True
LC 2487. Remove Nodes From Linked List★★
You are given the head
of a linked list.
Remove every node which has a node with a strictly greater value anywhere to the right side of it.
Return the head
of the modified linked list.
class Solution:
def removeNodes(self, head: Optional[ListNode]) -> Optional[ListNode]:
def reverse(node):
prev = None
curr = node
while curr:
curr.next, prev, curr = prev, curr, curr.next
return prev
rev_head = reverse(head)
sentinel = ListNode(float('-inf'))
sentinel.next = rev_head
prev = sentinel
curr = rev_head
while curr:
if prev.val > curr.val:
prev.next = curr.next
else:
prev = curr
curr = curr.next
return reverse(sentinel.next)
The issue at the beginning of the problem lies in how the pointers restrict how we can manage what happens with decreasing values. Reversing the linked list as a pre-processing step is an effective strategy here. Then we can simply adjust pointers as needed to ensure our linked list is weakly increasing. Then as a final step we return the reversal of that linked list.
LC 2816. Double a Number Represented as a Linked List (✓)
You are given the head
of a non-empty linked list representing a non-negative integer without leading zeroes.
Return the head
of the linked list after doubling it.
class Solution:
def doubleIt(self, head: Optional[ListNode]) -> Optional[ListNode]:
def reverse(node):
prev = None
curr = node
while curr:
curr.next, prev, curr = prev, curr, curr.next
return prev
rev_head = reverse(head)
carried = 0
sentinel = ListNode(-1)
sentinel.next = rev_head
curr = sentinel.next
while curr:
doubled = carried + curr.val * 2
new_val = doubled % 10
carried = doubled // 10
curr.val = new_val
curr = curr.next
doubled_head = reverse(sentinel.next)
if carried:
new_head = ListNode(carried)
new_head.next = doubled_head
return new_head
else:
return doubled_head
The basic idea above is to first reverse the list so we can more easily manage the updating of node values — values range from 0
through 9
which means the doubled values range from 0
through 18
, where the original value being 5
or more means having a doubled value in the range 10
-18
, which requires carrying.
We perform the needed value updates and maintain the number being carried along the way. Once we're done, we reverse the new list. If carried
is 0
, then we're done, but if carried
is not 0
, then whatever carried
is should become the first value in our new list.
The editorial for this problem is great. Reversing twice as done above is not necessary even though it is sufficient. There's a really slick one-pointer approach that takes advantage of how "carrying" works in terms of whether or not we ever need to update a node's value.
Reverse k nodes of a linked list in-place
What is the purpose of this template?
The purpose of the template, of course, is to reverse k
nodes of a linked list in-place. But, as this post notes (the post that originally motivated this template), the template below does not break off the section to be reversed (this is what happens with the conventional reversal, as detailed in the previous template).
The template below leaves the start node of the section linked to the rest of the list and moves the remaining nodes one by one to the front. This results in the k
-length section (starting at the start node) being reversed, the original start node being the end node of the reversed section, and the original start node being connected to the rest of the list instead of being severed or pointing to None
. The next remark provides some intuition as to how this actually works.
What is the intuition behind how and why this template works?
The core idea is actually somewhat simple: given a prev
node that precedes the start node for the k
-length section to be reversed, we effectively move the k - 1
nodes that follow the start so that they now come before the start node. One at a time. Suppose we want the section 3 -> 4 -> 5 -> 6
to be reversed in the following list (spaces added to emphasize section being reversed):
1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> None
The desired outcome would then be the following list:
1 -> 2 -> 6 -> 5 -> 4 -> 3 -> 7 -> 8 -> 9 -> None
Above, we see that k = 4
, prev
is node 2
, and node 3
is the start of the section to be reversed. Our template stipulates that nodes 4
, 5
, 6
, which originally follow node 3
, will now be moved to come before node 3
. One at a time:
1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> None # start
1 -> 2 -> 4 -> 3 -> 5 -> 6 -> 7 -> 8 -> 9 -> None # after iteration 1
1 -> 2 -> 5 -> 4 -> 3 -> 6 -> 7 -> 8 -> 9 -> None # after iteration 2
1 -> 2 -> 6 -> 5 -> 4 -> 3 -> 7 -> 8 -> 9 -> None # after iteration 3
Hence, k - 1
iterations are needed to reverse the k
-length section in-place, and we conclude by returning the end node for the reversed section, node 3
in this case. Here's a more colorful illustration of what things look like for each iteration:
The top part of the image shows the initial list and which four nodes need to be reversed, where the red numbering 1
, 2
, and 3
indicates where the nodes end up after that many iterations have taken place. The actual node coloring scheme:
- Red
2
: This fixed node denotes the node preceding where the reversal starts. - Magenta
3
: This fixed coloring denotes the node where the reversal starts (and also the node returned at the end). - Orange nodes: The orange node is always the "next node" to be moved to the front (i.e.,
next_node
in the template). - White nodes: This coloring indicates the nodes that have already been processed. The last iteration shows how the node where the reversal started concludes with its proper positioning at the end of the reversed segment.
The image above, particularly the top part of the image with the initial list and the arrow indicators which show how each node moves for each iteration, provides an intuition for how and why the template works. The following image shows the mechanics in more detail:
def reverse_k_nodes(prev, k):
if not prev.next or k < 2:
return prev.next
rev_start = prev.next
next_node = rev_start.next
rev_end = rev_start
count = 1
while count <= k - 1 and next_node:
rev_start.next = next_node.next
next_node.next = prev.next
prev.next = next_node
next_node = rev_start.next
count += 1
return rev_end
Examples
LC 92. Reverse Linked List II (✓) ★★
Reverse a linked list from position m
to n
. Do it in one-pass.
Note: 1 <= m <= n <= length of list
class Solution:
def reverseBetween(self, head: Optional[ListNode], left: int, right: int) -> Optional[ListNode]:
def reverse_k_nodes(prev, k):
if not prev.next or k < 2:
return prev.next
rev_start = prev.next
next_node = rev_start.next
rev_end = rev_start
count = 1
while count <= k - 1 and next_node:
rev_start.next = next_node.next
next_node.next = prev.next
prev.next = next_node
next_node = rev_start.next
count +=1
return rev_end
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
for _ in range(left - 1):
prev = prev.next
reverse_k_nodes(prev, right - left + 1)
return sentinel.next
The template for reversing k
nodes in-place was born out of the efforts to find a nice solution for this problem. The main potential "gotcha" is when left = 1
, where the prev
we need to feed into reverse_k_nodes
needs to be a sentinel node.
LC 2074. Reverse Nodes in Even Length Groups (✓) ★★★
You are given the head
of a linked list.
The nodes in the linked list are sequentially assigned to non-empty groups whose lengths form the sequence of the natural numbers (1, 2, 3, 4, ...
). The length of a group is the number of nodes assigned to it. In other words,
- The
1st
node is assigned to the first group. - The
2nd
and the 3rd nodes are assigned to the second group. - The
4th
,5th
, and6th
nodes are assigned to the third group, and so on. Note that the length of the last group may be less than or equal to1 + the length of the second to last group
.
Reverse the nodes in each group with an even length, and return the head
of the modified linked list.
class Solution:
def reverseEvenLengthGroups(self, head: Optional[ListNode]) -> Optional[ListNode]:
def reverse_k_nodes(prev, k):
if not prev.next or k < 2:
return prev.next
rev_start = prev.next
next_node = rev_start.next
rev_end = rev_start
count = 1
while count <= k - 1 and next_node:
rev_start.next = next_node.next
next_node.next = prev.next
prev.next = next_node
next_node = rev_start.next
count += 1
return rev_end
if not head or not head.next:
return head
sentinel = ListNode(-1)
sentinel.next = head
connector = sentinel
curr = head
grp_size = count = 1
while curr:
if grp_size == count or not curr.next:
if count % 2 == 0:
curr = reverse_k_nodes(connector, count)
connector = curr
count = 0
grp_size += 1
count += 1
curr = curr.next
return sentinel.next
This is such an excellent problem. The solution above, originally inspired by this one, is brilliant in how it fulfills the problem's requirements and deftly utilizes reversing a specified number of nodes in place.
How does the solution logic work apart from reverse_k_nodes
? First, recall what reverse_k_nodes
does apart from reversing k
nodes in-place: it returns the last node of the reversed segment. With this in mind, there are a few key points to highlight to illustrate how and why the solution above works:
-
We will maintain a
connector
node that allows us to keep all groups of the list connected. This node will always directly precede the beginning of a group. -
It's easy to get lost in keeping track of the odd or even groups, especially since the last group has to be treated differently than all the rest. A clever way of treating everything in a uniform fashion is to keep track of what the current group's full size would be,
grp_size
as well as the current node count,count
, as we move our way through the list. -
How do we move from one group to another effectively? We do so by determining whether or not the current node count equals the full size of the current group (i.e.,
grp_size == count
) or if there aren't any nodes left to process, in which case we are done iterating and need to process the last group (i.e.,not curr.next
).In both cases mentioned above, if the number of nodes in the group is even, then we need to reverse all these nodes in the group, and then continue on. Since
reverse_k_nodes
returns the last node of the newly reversed segment, we can setcurr
equal to this function's return value. What remains is to setconnect = curr
before moving to the next group. We also add1
to the next group's size,grp_size += 1
, and we resetcount = 0
because we add1
tocount
after the if block no matter what happens.
LC 25. Reverse Nodes in k-Group (✓)
k
is a positive integer and is less than or equal to the length of the linked list. If the number of nodes is not a multiple of k
then left-out nodes, in the end, should remain as it is.
Follow up:
- Could you solve the problem in extra memory space?
- You may not alter the values in the list's nodes, only nodes itself may be changed.
class Solution:
def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
def reverse_k_nodes(prev, k):
if not prev.next or k < 2:
return prev.next
rev_start = prev.next
next_node = rev_start.next
rev_end = rev_start
count = 1
while count <= k - 1 and next_node:
rev_start.next = next_node.next
next_node.next = prev.next
prev.next = next_node
next_node = rev_start.next
count += 1
return rev_end
if not head or not head.next:
return head
sentinel = ListNode(-1)
sentinel.next = head
connector = sentinel
curr = head
grp_size = k
count = 1
while curr:
if count == k:
curr = reverse_k_nodes(connector, count)
count = 0
connector = curr
count += 1
curr = curr.next
return sentinel.next
This problem is very similar to LC 2074. Reverse Nodes in Even Length Groups. In fact, this problem is actually easier than that problem!
Swap two nodes
Remarks
TLDR: Don't overthink what seems like the tricky edge case of swapping adjacent nodes. The lengthy initial condition simply ensures we don't try to swap nodes that don't exist or identical nodes in memory.
Let left
and right
be the nodes we want to swap. We will need the nodes prior to left
and right
to facilitate the node swapping. Let these nodes be prev_left
and prev_right
, respectively. Most node swaps will look something like the following:
That is, as the figure suggests, we will first make the assignment prev_left.next = right
and then prev_right.next = left
. Now we need right.next
to point to what left.next
was pointing to, and we need left.next
to point to what right.next
was pointing to. We can do this without a temporary variable in Python: right.next, left.next = left.next, right.next
.
Great! But what about the seemingly tricky case when nodes are adjacent? The really cool thing is that the template handles this case seamlessly. For the example figure above, consider what would happen if we tried swapping nodes 4
and 5
:
The assignment prev_left.next = right
behaves as expected, but the assignment prev_right.next = left
seems like it could cause some issues because we have effectively created a self-cycle. But the beautiful thing is how this is exploited to restore the list in the next set of assignments:
right.next, left.next = left.next, right.next
When nodes left
and right
are adjacent, we want right.next
to point to left
. The assignment right.next = left.next
accomplishes exactly this because the self-cycle means left.next
actually points to itself (i.e., left
). The subsequent assignment left.next = right.next
effectively removes the self-cycle and restores the list, resulting in the adjacent left
and right
nodes being swapped, as desired.
def swap_nodes(prev_left, prev_right):
if not prev_left or not prev_right \
or not prev_left.next or not prev_right.next \
or prev_left.next == prev_right.next:
return
left = prev_left.next
right = prev_right.next
prev_left.next, prev_right.next = right, left
right.next, left.next = left.next, right.next
Examples
LC 24. Swap Nodes in Pairs (✓)
Given a linked list, swap every two adjacent nodes and return its head.
class Solution:
def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]:
def swap_nodes(prev_left, prev_right):
if not prev_left or not prev_right \
or not prev_left.next or not prev_right.next \
or prev_left.next == prev_right.next:
return
left = prev_left.next
right = prev_right.next
prev_left.next, prev_right.next = right, left
right.next, left.next = left.next, right.next
if not head or not head.next:
return head
sentinel = ListNode(-1)
sentinel.next = head
prev_left = sentinel
prev_right = sentinel.next
while prev_left and prev_right:
swap_nodes(prev_left, prev_right)
prev_left = prev_right
prev_right = prev_right.next
return sentinel.next
The while loop condition conveys that we are only ever interested in trying to swap two nodes if both of their predecessors exist. Since this problem involves swapping pairs of nodes (i.e., the nodes are adjacent), the prev_left
condition in the while loop is actually unnecessary (prev_right
can only be true if prev_left
is also true).
The main potential "gotcha" occurs after the swapping of the nodes. Note that prev_left
and prev_right
are never reassigned during the node swapping but their next
attributes are. This means we need to be somewhat careful when making reassignments. As always, a drawing of a simple example can be immensely helpful:
Now the solution above basically suggests itself.
LC 1721. Swapping Nodes in a Linked List (✓)
You are given the head
of a linked list, and an integer k
.
Return the head of the linked list after swapping the values of the kth
node from the beginning and the kth
node from the end (the list is 1-indexed).
class Solution:
def swapNodes(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
def swap_nodes(prev_left, prev_right):
if not prev_left or not prev_right \
or not prev_left.next or not prev_right.next \
or prev_left.next == prev_right.next:
return
left = prev_left.next
right = prev_right.next
prev_left.next, prev_right.next = right, left
right.next, left.next = left.next, right.next
sentinel = ListNode(-1)
sentinel.next = head
prev_left = prev_right = sentinel
null_checker = head
for _ in range(k - 1):
prev_left = prev_left.next
null_checker = null_checker.next
while null_checker.next:
prev_right = prev_right.next
null_checker = null_checker.next
swap_nodes(prev_left, prev_right)
return sentinel.next
The motivation for the "swap two nodes" template really comes from this problem. Coming up with the template is the harder part — now all we have to do is work on identifying which nodes precede the left
and right
nodes.
General
Problems
LC 203. Remove Linked List Elements (✓)
Given the head
of a linked list and an integer val
, remove all the nodes of the linked list that has Node.val == val
, and return the new head.
class Solution:
def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]:
sentinel = ListNode(-1)
sentinel.next = head
prev = sentinel
curr = prev.next
while curr:
if curr.val == val:
prev.next = prev.next.next
else:
prev = curr
curr = curr.next
return sentinel.next
The key idea is that we only ever actually move prev
when a non-val
number is encountered; otherwise, we simply skip or "delete" nodes via prev.next = prev.next.next
while constantly moving forward through the list with curr = curr.next
.
LC 1290. Convert Binary Number in a Linked List to Integer (✓)
Given head
which is a reference node to a singly-linked list. The value of each node in the linked list is either 0
or 1
. The linked list holds the binary representation of a number.
Return the decimal value of the number in the linked list.
class Solution:
def getDecimalValue(self, head: ListNode) -> int:
curr = head
ans = curr.val
while curr.next:
ans = ans * 2 + curr.next.val
curr = curr.next
return ans
The idea behind the clever solution above is easier to understand if we first consider a simple base-10 number by itself: 4836
. What this means in a base-10 system is the following (the powers of 10 indicate positional significance of the different numerals): .
How does this help with this problem? Well, consider how the number 4836
could be obtained if we encountered the digits one at a time, left to right:
ans = 4
= 4 * 10 + 8 -> (48)
= 48 * 10 + 3 -> (483)
= 483 * 10 + 6 -> (4836)
= 4836
The same thing is happening in this problem with respect to another base, namely base 2. For example, consider how the process above would look for the following number (in base 2): 101101
:
ans = 1
= 1 * 2 + 0 -> (2)
= 2 * 2 + 1 -> (5)
= 5 * 2 + 1 -> (11)
= 11 * 2 + 0 -> (22)
= 22 * 2 + 1 -> (45)
= 45
The solution above is elegant in exploiting this instead of doing something else unnecessary like reversing the list and then updating the answer as we go along (which might be the first solution idea):
class Solution:
def getDecimalValue(self, head: ListNode) -> int:
def reverse(node):
prev = None
curr = node
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
curr = reverse(head)
ans = 0
pos = 0
while curr:
if curr.val == 1:
ans += 2 ** pos
curr = curr.next
pos += 1
return ans
LC 328. Odd Even Linked List (✓) ★★
Given the head
of a singly linked list, group all the nodes with odd indices together followed by the nodes with even indices, and return the reordered list.
The first node is considered odd, and the second node is even, and so on.
Note that the relative order inside both the even and odd groups should remain as it was in the input.
class Solution:
def oddEvenList(self, head: Optional[ListNode]) -> Optional[ListNode]:
if not head or not head.next:
return head
odd = head
even = odd.next
even_list_head = even
while even and even.next:
odd.next = odd.next.next
even.next = even.next.next
odd = odd.next
even = even.next
odd.next = even_list_head
return head
This is a great problem, but it is easy to overthink it and get bogged down in how the "node swapping" will work; in fact, that's the first problem! There is no node swapping! It's easy to fall into the trap of thinking we'll need to swap nodes to make the solution here work. A much more straightforward solution exists (i.e., the one above) if we can step outside the normal way of thinking here: what if we basically just created two lists, the odd list of the odd-index nodes and the even list of the even-index nodes? Then we could attach the even list to the odd list and call it a day.
And the idea above is completely possible. It just takes a little bit of imagination to execute it effectively. Specifically, what we have to be okay with (even though it may not seem okay in the moment) is effectively skipping or "deleting" the even-index nodes while dynamically creating the odd list and the same for the odd-index nodes while dynamically creating the even list.
Note that the while loop condition above is critical for the solution approach described above to work. We only care to continue building our lists so long as there are nodes that need to be repositioned; that is, we should only continue building our lists while an odd-indexed node appears after an even-indexed node. So long as this is true, we have work to do.
Matrices
Calculate value in a 2D matrix given its index
Remarks
index // n
: This performs integer division ofindex
byn
, where the result is the number of timesn
fully goes intoindex
. Why does this give us the row number? Because, for every row, there aren
elements; hence, after everyn
elements, we move on to the next row.index % n
: This finds the remainder whenindex
is divided byn
, where this remainder corresponds to the column index because it tells us how many places into the current row we are.
def index_to_value(matrix, index):
n = len(matrix[0])
row_pos = index // n
col_pos = index % n
return matrix[row_pos][col_pos]
Examples
LC 74. Search a 2D Matrix
Write an efficient algorithm that searches for a value in an m x n
matrix. This matrix has the following properties:
- Integers in each row are sorted from left to right.
- The first integer of each row is greater than the last integer of the previous row.
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
def index_to_mat_val(index):
row = index // n
col = index % n
return matrix[row][col]
m = len(matrix)
n = len(matrix[0])
left = 0
right = m * n - 1
while left <= right:
mid = left + (right - left) // 2
val = index_to_mat_val(mid)
if target < val:
right = mid - 1
elif target > val:
left = mid + 1
else:
return True
return False
Sliding window
Variable window size
Remarks
The general algorithm behind the sliding window pattern (variable width) is as follows:
- Define window boundaries: Define pointers
left
andright
that bound the left- and right-hand sides of the current window, respectively, where both pointers usually start at0
. - Add elements to window by moving right pointer: Iterate over the source array with the
right
bound to "add" elements to the window. - Remove elements from window by checking constraint and moving left pointer: Whenever the constraint is broken, "remove" elements from the window by incrementing the
left
bound until the constraint is satisfied again.
Note the usage of the non-strict inequality left <= right
in the while
loop — this makes sense for problems where a single-element window is valid; however, the inequality should be strict (i.e., left < right
) for problems where a single-element window does not make sense.
Template with comments
def fn(arr):
# initialize left boundary, window, and answer variables
left = curr = ans = 0
# initialize right boundary
for right in range(len(arr)):
# logic for adding element arr[right] to window
curr += nums[right]
# resize window if window condition/constraint is invalid
while left <= right and WINDOW_CONDITION_BROKEN # (e.g., curr > k):
# logic to remove element from window
curr -= nums[left]
# shift window
left += 1
# logic to update answer
ans = max(ans, right - left + 1)
return ans
def fn(arr):
left = curr = ans = 0
for right in range(len(arr)):
curr += nums[right]
while left <= right and WINDOW_CONDITION_BROKEN # (e.g., curr > k):
curr -= nums[left]
left += 1
ans = max(ans, right - left + 1)
return ans
Examples
Longest subarray of positive integer array with sum not greater than k
(✓)
def longest_subarray(nums, k):
if k <= 0:
return -1
left = curr = ans = 0
for right in range(len(nums)):
curr += nums[right]
while left <= right and curr > k:
curr -= nums[left]
left += 1
ans = max(ans, right - left + 1)
return ans
Longest substring of 1
's given binary string and one possible 0
flip (✓)
def longest_bin_substring(s):
left = curr = ans = 0
for right in range(len(s)):
if s[right] == '0':
curr += 1
while left <= right and curr > 1:
if s[left] == '0':
curr -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
Above, curr
denotes the current number of zeroes in the window.
LC 713. Subarray Product Less Than K (✓) ★
Your are given an array of positive integers nums
.
Count and print the number of (contiguous) subarrays where the product of all the elements in the subarray is less than k
.
class Solution:
def numSubarrayProductLessThanK(self, nums: List[int], k: int) -> int:
if k == 0:
return 0
left = ans = 0
curr = 1
for right in range(len(nums)):
curr *= nums[right]
while left <= right and curr >= k:
curr /= nums[left]
left += 1
ans += (right - left + 1)
return ans
For a valid sliding window [left, right]
that satisfies the condition and constraints, note that adding right - left + 1
to ans
is effectively adding the total number of subarrays in the range [left, right]
that end at right
, inclusive.
LC 1004. Max Consecutive Ones III (✓) ★
Given a binary array nums
and an integer k
, return the maximum number of consecutive 1
's in the array if you can flip at most k
0
's.
class Solution:
def longestOnes(self, nums: List[int], k: int) -> int:
left = curr = ans = 0
for right in range(len(nums)):
if nums[right] == 0:
curr += 1
while left <= right and curr > k:
if nums[left] == 0:
curr -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
The hardest part about this problem is arguably the clever manipulaton of the left
pointer. Ensuring curr
is only decremented when left
is currently pointing at 0
and then incrementing the left
counter is the way to go. Doing this out of order (e.g., trying to increment left
before decrementing curr
) can lead to some headaches.
LC 209. Minimum Size Subarray Sum (✓)
Given an array of positive integers nums
and a positive integer target
, return the minimal length of a contiguous subarray [numsl, numsl+1, ..., numsr-1, numsr]
of which the sum is greater than or equal to target
. If there is no such subarray, return 0
instead.
class Solution:
def minSubArrayLen(self, target: int, nums: List[int]) -> int:
MAX_NUMS_LENGTH = 10 ** 5 + 1
left = 0
curr = 0
ans = MAX_NUMS_LENGTH
for right in range(len(nums)):
curr += nums[right]
while left <= right and curr >= target:
ans = min(ans, right - left + 1)
curr -= nums[left]
left += 1
return 0 if ans == MAX_NUMS_LENGTH else ans
Sometimes the standard template needs to be modified slightly. This problem is clearly asking to be solved via sliding window, but the most natural way of solving the problem does not conform completely to the template. That's okay. Specifically, we want the condition of the window to be met before we remove any elements and/or update the answer.
LC 1208. Get Equal Substrings Within Budget (✓)
You are given two strings s
and t
of the same length. You want to change s
to t
. Changing the i
th character of s
to i
th character of t
costs |s[i] - t[i]|
that is, the absolute difference between the ASCII values of the characters.
You are also given an integer maxCost
.
Return the maximum length of a substring of s
that can be changed to be the same as the corresponding substring of t
with a cost less than or equal to maxCost
.
If there is no substring from s
that can be changed to its corresponding substring from t
, return 0
.
class Solution:
def equalSubstring(self, s: str, t: str, maxCost: int) -> int:
def char_diff(char1, char2):
return abs(ord(char1) - ord(char2))
left = curr = ans = 0
for right in range(len(t)):
curr += char_diff(t[right], s[right])
while left <= right and curr > maxCost:
curr -= char_diff(t[left], s[left])
left += 1
ans = max(ans, right - left + 1)
return ans
The main idea here is that s
is purely for reference while the sliding window operates by traversing t
.
LC 3090. Maximum Length Substring With Two Occurrences★★
Given a string s
, return the maximum length of a substring such that it contains at most two occurrences of each character.
class Solution:
def maximumLengthSubstring(self, s: str) -> int:
lookup = defaultdict(int)
left = ans = 0
for right in range(len(s)):
curr_char = s[right]
lookup[curr_char] += 1
while left <= right and lookup[curr_char] > 2:
prev_char = s[left]
lookup[prev_char] -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
The idea is effectively to use a hash map to track the frequency of characters as we encounter them — as soon as a character occurs more than 2
times (the right boundary), move the left boundry until a valid window is attained (subtracting out the character frequencies from the hash map along the way).
Note how easy it is to extend this solution to a number k >= 2
:
class Solution:
def maximumLengthSubstring(self, s: str, k: int) -> int:
lookup = defaultdict(int)
left = ans = 0
for right in range(len(s)):
curr_char = s[right]
lookup[curr_char] += 1
while left <= right and lookup[curr_char] > k:
prev_char = s[left]
lookup[prev_char] -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
That's pretty much the only change that's needed. A sliding window in conjunction with a hash map can be quite powerful.
LC 3. Longest Substring Without Repeating Characters (✓) ★★
Given a string s
, find the length of the longest substring without repeating characters.
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
lookup = defaultdict(int)
left = ans = 0
for right in range(len(s)):
char = s[right]
lookup[char] += 1
while left <= right and lookup[char] > 1:
lookup[s[left]] -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
The solution above is a fairly classic example of how to use a hash map with a sliding window. The manner in which the left
pointer is incremented after a duplicate character is encountered is characteristic of many sliding window problems, but the nature of this problem allows us to make a small optimization: as soon as we encounter a character that is a duplicate, instead of incrementing left
one character at a time until that character's previous occurrence is encountered and skipped over, we use our hash map to track character indexes (not frequencies) and simply jump the left
pointer just past the last index of the previously encountered duplicate character:
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
lookup = {}
left = ans = 0
for right in range(len(s)):
char = s[right]
if char in lookup and lookup[char] >= left:
left = lookup[char] + 1
lookup[char] = right
ans = max(ans, right - left + 1)
return ans
The hardest part about the solution above is the change in thinking needed to implement the variable width sliding window effectively. There's no while
loop and the conventional "sliding" isn't so much sliding as it is "jumping". Additionally, the condition and lookup[char] >= left
is critical for the sliding window — it ensures the character is not just in the hash map but that its last occurrence is within the current window. Our solution would fail without this conditional check.
For example, consider s = "abba"
. When we encounter the last "a"
, the first "a"
is in the hash map, but it is not in the current window. Mistakenly treating it like it's part of the current window means moving the left
pointer just past this occurrence and mistakenly getting a right - left + 1
length of 3
, which is not correct.
LC 3105. Longest Strictly Increasing or Strictly Decreasing Subarray (✓) ★
You are given an array of integers nums
. Return the length of the longest subarray of nums
which is either strictly increasing or strictly decreasing.
class Solution:
def longestMonotonicSubarray(self, nums: List[int]) -> int:
if not nums:
return 0
inc_window = 1
dec_window = 1
ans = 1
for i in range(1, len(nums)):
if nums[i] > nums[i - 1]:
inc_window += 1
dec_window = 1
elif nums[i] < nums[i - 1]:
dec_window += 1
inc_window = 1
else:
inc_window = 1
dec_window = 1
ans = max(ans, inc_window, dec_window)
return ans
This is a slightly unconventional variable-width sliding window problem due to how the sizes of the windows are being changed, namely incrementally being grown by 1
or being reset to 1
for each iteration. The solution above is a very nice single-pass solution.
The following solution looks like more like the standard variable-width sliding window approach even though it's not nearly as nice (or efficient since two passes are being made):
class Solution:
def longestMonotonicSubarray(self, nums: List[int]) -> int:
def longest_monotonic_sub(arr, comparison):
left = 0
ans = 1
for right in range(1, len(arr)):
prev = arr[right - 1]
curr = arr[right]
if left < right and (prev >= curr if comparison == 'inc' else prev <= curr):
left = right
ans = max(ans, right - left + 1)
return ans
return max(longest_monotonic_sub(nums, 'inc'), longest_monotonic_sub(nums, 'dec'))
Fixed window size
Method 1 (build window outside main loop)
Remarks
- Window size greater than array size (possibility): There is a chance that the window size
k
is greater than the array sizearr.length
— if not properly accounted for, this could easily lead to index out of range errors (it's not accounted for in the pseudocode below). It is often not a bad idea to have a check for this before proceeding with the window creation operation. - Clarity: Method 1 appears to be somewhat cleaner than Method 2 despite having another
for
loop. The construction of the window and initialization ofans
is unambiguous and easy to understand in Method 1. We start by building the window, we set the initial answer, and then we move the window while iterative updating the answer. It's easier to keep things clear and straight with this approach.
Template with commented code
def fn(arr, k):
# some data to keep track of with the window (e.g., sum of elements)
curr = 0
# build the first window (of size k)
for i in range(k):
# do something with curr or other variables to build first window
curr += arr[i]
# initialize answer variable (might be equal to curr here depending on the problem)
ans = curr
for i in range(k, len(arr)):
# add arr[i] to window
curr += arr[i]
# remove arr[i - k] from window
curr -= arr[i - k]
# update ans
ans = max(ans, curr)
return ans
def fn(arr, k):
curr = 0
for i in range(k):
curr += arr[i]
ans = curr
for i in range(k, len(arr)):
curr += arr[i]
curr -= arr[i - k]
ans = max(ans, curr)
return ans
Examples
Max sum of subarray of size k
(✓)
def largest_sum_length_k(nums, k):
curr = 0
for i in range(k):
curr += nums[i]
ans = curr
for i in range(k, len(nums)):
curr += nums[i]
curr -= nums[i - k]
ans = max(ans, curr)
return ans
The idea here is that we first build the sum of the first window of size k
. Then we continue onward by adding and removing elements from the k
-size window while keeping track of the current window sum and comparing that to the maximum, the final of which we return as the ultimate answer.
LC 643. Maximum Average Subarray I (✓)
You are given an integer array nums
consisting of n
elements, and an integer k
.
Find a contiguous subarray whose length is equal to k
that has the maximum average value and return this value. Any answer with a calculation error less than 10-5
will be accepted.
class Solution:
def findMaxAverage(self, nums: List[int], k: int) -> float:
curr = ans = 0
for i in range(k):
curr += nums[i]
ans = curr / k
for i in range(k, len(nums)):
curr += nums[i]
curr -= nums[i - k]
ans = max(ans, curr / k)
return ans
LC 1456. Maximum Number of Vowels in a Substring of Given Length (✓)
Given a string s
and an integer k
.
Return the maximum number of vowel letters in any substring of s
with length k
.
Vowel letters in English are (a
, e
, i
, o
, u
).
class Solution:
def maxVowels(self, s: str, k: int) -> int:
vowels = {'a', 'e', 'i', 'o', 'u'}
curr = 0
for i in range(k):
if s[i] in vowels:
curr += 1
ans = curr
for i in range(k, len(s)):
new_char = s[i]
old_char = s[i - k]
if old_char in vowels and curr > 0:
curr -= 1
if new_char in vowels and curr < k:
curr += 1
ans = max(ans, curr)
return ans
Method 2 (build window within main loop)
Remarks
- Window size greater than array size (possibility): There is a chance that the window size
k
is greater than the array sizearr.length
— if not properly accounted for, this could easily lead to index out of range errors (it's not accounted for in the pseudocode below). It is often not a bad idea to have a check for this before proceeding with the window creation operation. - Initialization of
ans
: Sometimes it can be a little unclear as to how best to initialize theans
variable. For example, in LC 643 we are looking for a "maximum average subarray" which means initializingans
to, say,0
does not make sense because the maximum average could be negative depending on what elements are present in the array. It makes more sense in this problem to haveans = float('-inf')
as the initialization even though it does not feel all that natural. - Complicated logic: The logic for managing the window is actually a bit more complicated than that used in Method 1. Here, in Method 2, the window is really being constructed by adding
arr[i]
to the window untili == k
. If our array only hask
elements, then our updating ofans
before we return can save us from possible errors, but the whole thing takes a bit more effort to wrap your head around.
Template with code comments
def fn(arr, k):
# some data to keep track of with the window (e.g., sum of elements)
curr = 0
# initialize answer variable (initial value depends on problem)
ans = float('-inf')
for i in range(len(arr)):
if i >= k:
# update ans
ans = max(curr, ans)
# remove arr[i - k] from window
curr -= arr[i - k]
# add arr[i] to window
curr += arr[i]
# update ans
ans = max(ans, curr)
return ans
def fn(arr, k):
curr = 0
ans = float('-inf')
for i in range(len(arr)):
if i >= k:
ans = max(curr, ans)
curr -= arr[i - k]
curr += arr[i]
ans = max(ans, curr)
return ans
Examples
Max sum of subarray of size k
(✓)
def largest_sum_length_k(nums, k):
curr = 0
ans = float('-inf')
for i in range(len(nums)):
if i >= k:
ans = max(curr, ans)
curr -= nums[i - k]
curr += nums[i]
ans = max(ans, curr)
return ans
The flow here is a bit unnatural compared to first building the k
-size window, then initializing the answer variable, and then proceeding to slide the window. The solution above makes it clear it's possible to build out a solution all within the main loop, but sometimes it likely will not be preferable.
LC 643. Maximum Average Subarray I (✓)
You are given an integer array nums
consisting of n
elements, and an integer k
.
Find a contiguous subarray whose length is equal to k
that has the maximum average value and return this value. Any answer with a calculation error less than 10-5
will be accepted.
class Solution:
def findMaxAverage(self, nums: List[int], k: int) -> float:
curr = 0
ans = float('-inf')
for i in range(len(nums)):
if i >= k:
ans = max(ans, curr / k)
curr -= nums[i - k]
curr += nums[i]
ans = max(ans, curr / k)
return ans
The solution above is not the recommended approach. It's clear the flow above is rather unnatural. Oftentimes it is more natural to first build the window outside the main loop and then continue. But it is possible to do it all in one go, as shown above (though not recommended).
Stacks and queues
Stacks
Remarks
TBD
# declaration (Python list by default)
stack = []
# push
stack.append(1)
stack.append(2)
stack.append(3)
# pop
stack.pop() # 3
stack.pop() # 2
# peek
stack[-1] # 1
# empty check
not stack # False
# size check
len(stack) # 1
Examples
LC 20. Valid Parentheses (✓) ★
Given a string s
containing just the characters '('
, ')'
, '{'
, '}'
, '['
and ']'
, determine if the input string is valid.
An input string is valid if:
- Open brackets must be closed by the same type of brackets.
- Open brackets must be closed in the correct order.
class Solution:
def isValid(self, s: str) -> bool:
lookup = {
')': '(',
'}': '{',
']': '['
}
stack = []
for char in s:
if char in lookup:
if not stack or lookup[char] != stack.pop():
return False
else:
stack.append(char)
return not stack
LIFO pattern: The last (most recent) opening delimiter is the first to be deleted.
The "correct" order is determined by whatever the previous opening bracket was. Whenever there is a closing bracket, it should correspond to the most recent opening bracket. We can effectively test for this in an iterative fashion by maintaining a history (stack) of the encountered opening delimiters. As soon as we encounter a closing delimiter, if the element on top of the stack doesn't correspond (or if the stack is empty), then we know we cannot have a list of valid parentheses and we can return False
; otherwise, the current character is an opening delimiter and we add it to the stack.
Once we've completed iterating through all characters, if the stack of opening delimiters is empty, then we know all delimiters have a valid correspondence, and we can return True
.
LC 1047. Remove All Adjacent Duplicates In String (✓)
Given a string S
of lowercase letters, a duplicate removal consists of choosing two adjacent and equal letters, and removing them.
We repeatedly make duplicate removals on S
until we no longer can.
Return the final string after all such duplicate removals have been made. It is guaranteed the answer is unique.
class Solution:
def removeDuplicates(self, s: str) -> str:
stack = []
for char in s:
if stack and stack[-1] == char:
stack.pop()
else:
stack.append(char)
return "".join(stack)
LIFO pattern: The last (most recent) character is the first to be deleted.
The example of s = "azxxzy"
resolving to "ay"
highlights the strategy we should use here, namely determining whether or not the current character ever equals the element on top of the stack. If so, then remove the element from the top of the stack and continue on (this effectively deletes both elements); otherwise, add the current element to the stack.
LC 844. Backspace String Compare (✓)
Given two strings s
and t
, return true
if they are equal when both are typed into empty text editors. '#'
means a backspace character.
Note that after backspacing an empty text, the text will continue empty.
class Solution:
def backspaceCompare(self, s: str, t: str) -> bool:
def build_str(r):
stack = []
for char in r:
if char == '#':
if stack:
stack.pop()
else:
stack.append(char)
return "".join(stack)
s_str = build_str(s)
t_str = build_str(t)
return s_str == t_str
LIFO property: Maintaining a history of characters seen and deleting the most recently seen ones when encounter #
characters.
The stack-based solution is quick and easy since #
characters almost literally allow us to backspace by removing (deleting) characters from the stack that maintains a history of the characters seen so far. A more complicated but elegant solution is a two-pointer approach.
LC 71. Simplify Path (✓)
Given a string path
, which is an absolute path (starting with a slash '/'
) to a file or directory in a Unix-style file system, convert it to the simplified canonical path.
In a Unix-style file system, a period '.'
refers to the current directory, a double period '..'
refers to the directory up a level, and any multiple consecutive slashes (i.e. '//'
) are treated as a single slash '/'
. For this problem, any other format of periods such as '...'
are treated as file/directory names.
The canonical path should have the following format:
- The path starts with a single slash
'/'
. - Any two directories are separated by a single slash
'/'
. - The path does not end with a trailing
'/'
. - The path only contains the directories on the path from the root directory to the target file or directory (i.e., no period
'.'
or double period'..'
)
Return the simplified canonical path.
class Solution:
def simplifyPath(self, path: str) -> str:
stack = []
for portion in path.split('/'):
if portion == '' or portion == '.':
continue
elif portion == '..':
if stack:
stack.pop()
else:
stack.append(portion)
return '/' + '/'.join(stack)
FIFO property: Only ever add valid file/directory names to the stack. If you encounter ..
, then remove the most recently seen file or directory name. If you encounter other characters such as ''
or '.'
, then it's a no-op and you should continue on with your processing. At the end, return a string joined with /
separators (the first /
needs to be manually inserted).
LC 1544. Make The String Great (✓)
Given a string s
of lower and upper case English letters.
A good string is a string which doesn't have two adjacent characters s[i]
and s[i + 1]
where:
0 <= i <= s.length - 2
s[i]
is a lower-case letter ands[i + 1]
is the same letter but in upper-case or vice-versa.
To make the string good, you can choose two adjacent characters that make the string bad and remove them. You can keep doing this until the string becomes good.
Return the string after making it good. The answer is guaranteed to be unique under the given constraints.
Notice that an empty string is also good.
class Solution:
def makeGood(self, s: str) -> str:
stack = []
for char in s:
if stack and abs(ord(stack[-1]) - ord(char)) == 32:
stack.pop()
else:
stack.append(char)
return "".join(stack)
FIFO property: The last character added to the stack is the first one out if its corresponding upper- or lower-case character is the one currently being considered.
LC 2390. Removing Stars From a String (✓)
You are given a string s
, which contains stars *
.
In one operation, you can:
- Choose a star in
s
. - Remove the closest non-star character to its left, as well as remove the star itself.
Return the string after all stars have been removed.
Note:
- The input will be generated such that the operation is always possible.
- It can be shown that the resulting string will always be unique.
class Solution:
def removeStars(self, s: str) -> str:
stack = []
for i in range(len(s)):
if s[i] == '*' and stack:
stack.pop()
else:
stack.append(s[i])
return "".join(stack)
LC 232. Implement Queue using Stacks (✓) ★★
Implement a first in first out (FIFO) queue using only two stacks. The implemented queue should support all the functions of a normal queue (push
, peek
, pop
, and empty
).
Implement the MyQueue
class:
void push(int x)
Pushes element x to the back of the queue.int pop()
Removes the element from the front of the queue and returns it.int peek()
Returns the element at the front of the queue.boolean empty()
Returnstrue
if the queue is empty,false
otherwise.
Notes:
- You must use only standard operations of a stack, which means only
push to top
,peek/pop from top
,size
, andis empty
operations are valid. - Depending on your language, the stack may not be supported natively. You may simulate a stack using a list or deque (double-ended queue) as long as you use only a stack's standard operations.
Follow-up: Can you implement the queue such that each operation is amortized O(1)
time complexity? In other words, performing n
operations will take overall O(n)
time even if one of those operations may take longer.
class MyQueue:
def __init__(self):
self.enqueued = []
self.dequeue = []
self.front = None
def push(self, x: int) -> None:
if not self.enqueued:
self.front = x
self.enqueued.append(x)
def pop(self) -> int:
if not self.dequeue:
while self.enqueued:
self.dequeue.append(self.enqueued.pop())
if self.dequeue:
return self.dequeue.pop()
def peek(self) -> int:
if not self.dequeue:
return self.front
else:
return self.dequeue[-1]
def empty(self) -> bool:
return len(self.enqueued) == 0 and len(self.dequeue) == 0
The main insight for solving this problem performantly is realizing that, since stacks are LIFO (last in first out), this means elements popped from the stack appear in reverse order compared to how they were entered. For example, suppose the values 1
, 2
, and 3
are pushed to a stack. Then popping them off one at a time yields 3
, 2
, and 1
, in that order.
Hence, to start, we just keep pushing elements to a stack, self.enqueued
. These are the elements that have been enqueued so far. As soon as we need to pop an element from the queue, this means we need to access and remove the element at the bottom of the self.enqueued
stack. To do this, we use another stack, self.dequeue
, to collect the values from self.enqueued
in reverse order. The element to be popped from the queue is now at the top of the self.dequeue
stack.
This strategy works beyond just a way "to start". Since all of the elements in self.dequeue
are the elements originally in self.enqueued
but reversed, this means we can pop an element from self.dequeue
whenever we're asked to pop an element from the queue. So long as self.dequeue
isn't empty! If, however, self.dequeue
, is empty, then we'll need to again pop all the elements from self.enqueued
so that self.dequeue
will contain the elements in proper FIFO order.
The process outlined above for maintaining these stacks is the core of this question, but being able to effectively "peek" from the queue is also a problem worth mentioning. If self.dequeue
is not empty, then peeking should simply be the element we would otherwise pop from the queue, which is the element at the top of the self.dequeue
stack (we're using the "stack peek" operation in this case): self.dequeue[-1]
. But what if self.dequeue
is actually empty and we've just been pushing elements to the self.enqueued
stack? The element we want is at self.enqueued[0]
, but accessing the element in this way is not a valid stack operation -- the element is at the bottom of the stack! The idea is to use another class variable, self.front
, to keep track of whatever value is at the bottom of self.enqueued
(i.e., bottom of this stack or front of the queue). We keep track of this by reassigning self.front
whenever we're about to push an element to self.enqueued
but self.enqueued
is empty. This is how we can keep pushing elements to self.enqueued
without losing the reference to the element at the front/bottom. Then, once we're asked to perform a "queue peek" operation, we can either return the element at the top of the self.dequeue
stack if it's not empty or self.front
if self.dequeue
is empty.
LC 2434. Using a Robot to Print the Lexicographically Smallest String (✓) ★
You are given a string s
and a robot that currently holds an empty string t
. Apply one of the following operations until s
and t
are both empty:
- Remove the first character of a string
s
and give it to the robot. The robot will append this character to the stringt
. - Remove the last character of a string
t
and give it to the robot. The robot will write this character on paper.
Return the lexicographically smallest string that can be written on the paper.
Approach 1 (frequency array and smallest remaining character helper function)
class Solution:
def robotWithString(self, s: str) -> str:
def smallest_remaining_char(freqs_arr):
for i in range(len(freqs_arr)):
if freqs_arr[i] != 0:
return chr(a_ORD + i)
return 'z'
a_ORD = 97 # ord('a') = 97, the ordinal value of 'a'
freqs = [0] * 26
for char in s:
freqs[ord(char) - a_ORD] += 1
t = []
p = []
for char in s:
t.append(char)
freqs[ord(t[-1]) - a_ORD] -= 1
while t and t[-1] <= smallest_remaining_char(freqs):
p.append(t.pop())
return "".join(p)
The variable t
should almost certainly be a stack that holds a "history" of the characters in s
as we iterate through them. It doesn't take long to realize that the main challenge here is figuring out when to push a character onto the output string, p
, which is a list of accumulated characters that is strategically assembled to satisfy the "lexicographically smallest" demand. The primary idea in this problem is to only pop characters from the stack t
when we're guaranteed that doing so enables us to proceed in assembling the lexicographically smallest string.
When, then, should we pop a character from t
? Only when there are no remaining characters that are lexicographically smaller than the character on top of the stack t
. This means we need some kind of lookup. A lookup for what? The smallest character remaining from wherever we are in our process of iterating through all characters of s
. To do this effectively, we need to keep a frequency count (the smallest character remaining at one point could get removed and there could subsequently be more of the same characters to take its place), which is most often done using a hash map. We could do that here, but using a frequency array is arguably cleaner since the characters we care about are 'a'
through 'z'
, meaning our frequency array only needs to have 26 slots.
We assemble the frequency array as our first pass in our solution. The smallest_remaining_char
function will always give us the current smallest remaining character from wherever we are when processing the string s
(it's helpful to know that 'a'
's ordinal value is 97
, which we can helpfully use to maintain our frequency array).
Now, each time we process a character from s
, we push it to the stack t
, and decrement its count from the frequency array, freqs
. All that really remains is to proceed as alluded to above, namely pop elements from t
into p
that are smaller or equal to the smallest element remaining in s
.
Approach 2 (minimum remaining character lookup array, no frequency count)
class Solution:
def robotWithString(self, s: str) -> int:
n = len(s)
t = []
p = []
min_char = [s[-1]] * n
for i in range(n - 2, -1, -1):
min_char[i] = min(s[i], min_char[i + 1])
idx = 1
for char in s:
t.append(char)
while idx < n and t and t[-1] <= min_char[idx]:
p.append(t.pop())
idx += 1
while t:
p.append(t.pop())
return "".join(p)
The solution in Approach 1 was whereas the solution above is . The constant factor is much smaller even though both solutions belong to the same efficiency class. Regardless, the solution above still uses the main idea from Approach 1, namely we only ever pop characters from t
when they're (lexicographically) smaller or equal to the characters remaining in s
. The difference in this approach compared to that in Approach 1 is that we do not use a frequency count; instead, we precompute what the remaining minimum character will be from any character index in s
:
min_char = [s[-1]] * n
for i in range(n - 2, -1, -1):
min_char[i] = min(s[i], min_char[i + 1])
This is easiest to understand by means of an example. Suppose we had s = 'laptop'
. Then min_char
, after running the code above, would yield ['a', 'a', 'o', 'o', 'o', 'p']
; that is, at index i = 0
, the lexicographically smallest remaining character is 'a'
. At position i = 1
, the lexicographically smallest remaining character is also 'a'
. At index i = 2
, the lexicographically smallest remaining character is 'o'
. And so forth. This allows us to effectively use the min_char
array to determine when elements should be popped from t
and placed in p
.
LC 946. Validate Stack Sequences (✓) ★
Given two sequences pushed
and popped
with distinct values, return true
if and only if this could have been the result of a sequence of push and pop operations on an initially empty stack.
class Solution(object):
def validateStackSequences(self, pushed, popped):
pop_p = 0
ref = []
for num in pushed:
ref.append(num)
while ref and pop_p < len(popped) and ref[-1] == popped[pop_p]:
ref.pop()
pop_p += 1
return pop_p == len(popped)
The key idea here is somewhat difficult to intuit at first. Basically, we're trying to see if the values in pushed
have been pushed (with unspecified pops along the way) in such a way that the pops specified in popped
is actually possible; that is, it's almost as if we're starting with an empty stack, and then we proceed to push values into the stack in the order in which they appear in pushed
, and along the way, we intermittently pop values from the stack, where the order in which values are popped is preserved in popped
.
Hence, one possible strategy is to keep a reference or history of values pushed so far. The LIFO nature of keeping a history naturally suggests a stack. We'll call it ref
. Then we can basically iterate through all values in pushed
, adding them to the ref
stack until the element at the top of the ref
stack equals the first element in popped
. This means we should pop the value from ref
, and it also means we need to move to the second or next value in popped
. This way of keeping a reference to values in popped
suggests usage of a pointer, pop_p
in the solution code above.
We should keep popping elements from ref
so long as they match the ones in popped
. We only move on to the next value in pushed
once this has been done. Our endpoint will naturally be when ref
is exhausted and pop_p == len(popped)
(this means the sequence specified is possible). If pop_p != len(popped)
, then this means not all values in popped
were accounted for and thus the specification is not possible.
Note: The condition pop_p < len(popped)
in the while loop is not strictly necessary since we're guaranteed that pushed
is a permutation of popped
and hence the same length. The condition pop_p < len(popped)
is only ever violated once ref
is empty and hence redundant; if, however, pushed
were not necessarily a permutation of popped
, then pop_p < len(popped)
would be necessary (e.g., pushed = [1,2,3,4,5,6,7]
, popped = [5,6]
). Nonetheless, it's best for the sake of clarity to leave this condition in (gains are minimal when excluding the condition).
LC 735. Asteroid Collision (✓)
We are given an array asteroids
of integers representing asteroids in a row.
For each asteroid, the absolute value represents its size, and the sign represents its direction (positive meaning right, negative meaning left). Each asteroid moves at the same speed.
Find out the state of the asteroids after all collisions. If two asteroids meet, the smaller one will explode. If both are the same size, both will explode. Two asteroids moving in the same direction will never meet.
class Solution:
def asteroidCollision(self, asteroids: List[int]) -> List[int]:
history = []
for asteroid in asteroids:
history.append(asteroid)
while len(history) > 1 and history[-2] > 0 and history[-1] < 0:
prev = history[-2]
curr = history[-1]
if prev > abs(curr):
history.pop()
elif prev < abs(curr):
curr = history.pop()
history.pop()
history.append(curr)
else:
history.pop()
history.pop()
return history
The main challenge here is to implement the solution in as clean a manner as possible. The actual problem statement is simple enough, but laying out the solution carefully takes a bit of thought:
- We should maintain a history of all asteroids we've seen. It only makes sense to consider removing asteroids from the history if the history has more than a single asteroid, hence the while loop condition
len(history) > 1
. - Since we'll be processing asteroids from left to right, asteroid collisions can only happen when the last asteroid in the history is going right and the current asteroid is going left; hence, we have the following condition after adding the current asteroid to the history:
history[-2] > 0 and history[-1] < 0
. - Finally, we can proceed with handling the core conditions of the problem:
- If the previous asteroid has a greater size than the current one, then the current asteroid gets exploded (i.e., removed from the history).
- If, however, the previous asteroid's size is actually smaller than the current one, then the previous asteroid needs to be removed. But the current one is at the top of the history; hence, we temporarily remove the top of the history in order to remove the previous asteroid that exploded (then we add the current asteroid back).
- The only other possibility would be for the asteroids to have the same size, in which case both asteroids explode, and they should both be removed from the history.
LC 155. Min Stack (✓)
Design a stack that supports push, pop, top, and retrieving the minimum element in constant time.
Implement the MinStack
class:
MinStack()
initializes the stack object.void push(val)
pushes the elementval
onto the stack.void pop()
removes the element on the top of the stack.int top()
gets the top element of the stack.int getMin()
retrieves the minimum element in the stack.
class MinStack:
def __init__(self):
self.stack = []
self.min_stack = []
def push(self, val: int) -> None:
self.stack.append(val)
if not self.min_stack:
self.min_stack.append(val)
else:
curr_min = self.min_stack[-1]
self.min_stack.append(min(val, curr_min))
def pop(self) -> None:
self.stack.pop()
self.min_stack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.min_stack[-1]
The requirement is to implement a solution with time complexity for each function (i.e., push
, pop
, top
, and getMin
), and this is a hint in itself. All stacks would generally be designed to make it possible for us to get the minimum (or maximum) if there were no tradeoffs. Of course there must be a tradeoff here, notably one of space. So how should we use space to make each function , particularly the getMin
function?
The answer is to maintain two stacks, the stack itself, self.stack
, as well as a stack that only keeps track of the minimums so far, self.min_stack
. This allows us to keep the stacks in lockstep and to perform normal stack operations as desired while at the same time making it easy to get minimums in time.
Queues
Remarks
TBD
import collections
# declaration (Python deque from collections module)
queue = collections.deque()
# initialize with values (optional)
queue = collections.deque([1, 2, 3])
# enqueue
queue.append(4)
queue.append(5)
# dequeue
queue.popleft() # 1
queue.popleft() # 2
# peek left (next element to be removed)
queue[0] # 3
# peek right
queue[-1] # 5
# empty check
not queue # False
# size check
len(queue) # 3
Examples
LC 933. Number of Recent Calls (✓)
You have a RecentCounter
class which counts the number of recent requests within a certain time frame.
Implement the RecentCounter
class:
RecentCounter()
Initializes the counter with zero recent requests.int ping(int t)
Adds a new request at timet
, wheret
represents some time in milliseconds, and returns the number of requests that has happened in the past3000
milliseconds (including the new request). Specifically, return the number of requests that have happened in the inclusive range[t - 3000, t]
.
It is guaranteed that every call to ping
uses a strictly larger value of t
than the previous call.
class RecentCounter:
def __init__(self):
self.queue = deque()
def ping(self, t: int) -> int:
self.queue.append(t)
while self.queue[-1] < t - 3000:
self.queue.popleft()
return len(self.queue)
The most recently added element will automatically be part of the number of "recent" calls. To determine all recent calls, we need to remove the previous calls not within the specified range of [t - 3000, t]
. A queue is the right data structure for this.
LC 346. Moving Average from Data Stream (✓)
Given a stream of integers and a window size, calculate the moving average of all integers in the sliding window.
Implement the MovingAverage
class:
MovingAverage(int size)
Initializes the object with the size of the windowsize
.double next(int val)
Returns the moving average of the lastsize
values of the stream.
class MovingAverage:
def __init__(self, size: int):
self.queue = deque()
self.val_total = 0
self.size = size
def next(self, val: int) -> float:
if len(self.queue) < self.size:
self.queue.append(val)
self.val_total += val
return self.val_total / len(self.queue)
else:
self.val_total -= self.queue.popleft()
self.queue.append(val)
self.val_total += val
return self.val_total / self.size
There's nothing to prevent us from keeping track of the total window sum, which we can effectively use the queue to adjust by subtracting elements out of the window (first in first out) and adding new elements to the window.
LC 225. Implement Stack using Queues (✓) ★★
Implement a last in first out (LIFO) stack using only two queues. The implemented stack should support all the functions of a normal queue (push
, top
, pop
, and empty
).
Implement the MyStack
class:
void push(int x)
Pushes element x to the top of the stack.int pop()
Removes the element on the top of the stack and returns it.int top()
Returns the element on the top of the stack.boolean empty()
Returnstrue
if the stack is empty,false
otherwise.
Notes:
- You must use only standard operations of a queue, which means only
push to back
,peek/pop from front
,size
, andis empty
operations are valid. - Depending on your language, the queue may not be supported natively. You may simulate a queue using a list or deque (double-ended queue), as long as you use only a queue's standard operations.
The main idea in each approach below is effectively to "rotate" elements in some way so that the most recent element added is accessible from the front (since we can only pop elements in a queue from the first given its FIFO nature). Approach 1 is the intended solution on LeetCode (and probably what would be acceptable in an interview), but approaches 2 and 3 offer seam neat insights for clever optimizations.
Approach 1 (one queue, O(n) pushes with self-rotations)
class MyStack:
def __init__(self):
self.queue = deque()
def push(self, x: int) -> None:
self.queue.append(x)
size = len(self.queue)
while size > 1:
self.queue.append(self.queue.popleft())
size -= 1
def pop(self) -> int:
return self.queue.popleft()
def top(self) -> int:
return self.queue[0]
def empty(self) -> bool:
return len(self.queue) == 0
The key to solving a similar problem, namely LC 232. Implement Queue using Stacks, was to take advantage of the fact that popping elements from a stack meant obtaining them in reverse order compared to how they are added to the stack. This meant we could use two stacks effectively to simulate a queue, where we kept adding elements to one stack (the "enqueued" stack) and we'd pop elements from the other stack (the "dequeue" stack) once a dequeue operation was requested — the main trick was that we only popped all the elements from the "enqueued" stack to "dequeue" stack when a dequeue operation was requested and the dequeue stack was empty. This meant we could perform the operation in amortized . That's not the case here.
Queues are FIFO so popping elements from the left in one queue and appending them to the right in another queue means elements would be added to the second queue in the same order they were added to the first queue. That is definitely not desirable. The main trick for this problem, which is easy to miss at first because it seems like there must be a more performant way to accomplish this (see Approach 2), is to use a single queue and rotate elements through the queue every time a new element is added so that the new element becomes the left-most element of the queue. Each "push" operation for the stack we're trying to implement requires appending an element to the queue and then rotating through all elements by popping from the left and appending the popped element to the right until the newly added element is the left-most element. For example, consider how the numbers 2, 7, 8, 4
would be added to the queue to simulate a stack:
# first element (2) pushed
[2]
# second element (7) pushed
[2] # start state
[2,7] # 7 gets pushed
[7,2] # 2 gets popped from the left and pushed to the right
# third element (8) pushed
[7,2] # start state
[7,2,8] # 8 gets pushed
[2,8,7] # 7 gets popped from the left and pushed to the right
[8,7,2] # 2 gets popped from the left and pushed to the right
# fourth element (4) pushed
[8,7,2] # start state
[8,7,2,4] # 4 gets pushed
[7,2,4,8] # 8 gets popped from the left and pushed to the right
[2,4,8,7] # 7 gets popped from the left and pushed to the right
[4,8,7,2] # 2 gets popped from the left and pushed to the right
Each push operation for the stack ultimately results in all elements being stored in the deque in reverse order, which is the desired effect. The push operation costs and is really the only complicated operation, but it can be a head scratcher if you haven't seen it before.
Approach 2 (two queues, amortized O(sqrt(n)) pushes with self-rotations and cache)
class MyStack:
def __init__(self):
self.cache = deque()
self.storage = deque()
def push(self, x: int) -> None:
self.cache.append(x)
size = len(self.cache)
while size > 1:
self.cache.append(self.cache.popleft())
size -= 1
if len(self.cache) * len(self.cache) > len(self.storage):
while self.storage:
self.cache.append(self.storage.popleft())
self.cache, self.storage = self.storage, self.cache
def pop(self) -> int:
if self.cache:
return self.cache.popleft()
else:
return self.storage.popleft()
def top(self) -> int:
if self.cache:
return self.cache[0]
else:
return self.storage[0]
def empty(self) -> bool:
return len(self.cache) == 0 and len(self.storage) == 0
The approach above is based on this solution. The core idea of maintaining stack order by rotating through elements is still present from Approach 1. But it seemed like there must be a more performant way to push elements to our stack than requiring an approach every time (i.e., rotating through all elements for each push).
The core of the solution above is the same as that in Approach 1 (i.e., rotating through elements), but now we're effectively trying to reduce the amount of rotating we have to do for each push. The idea is to maintain two queues, one that acts as a cache
and one that acts as main storage
. How does this help? Costly rotations arising from push operations will only be executed on the cache
, and our goal will be to keep the size of cache
small. When the size of cache
exceeds the square root of the size of storage
, the following will happen:
- All of the elements in
storage
will be popped and appended tocache
. - The variable designations will be swapped so now
cache
is empty andstorage
has all elements in the stack.
Important to note is that both cache
and storage
will always be maintained in LIFO order, with cache
holding the newest elements at the top of the stack and storage
holding the oldest. An example that illustrates the mechanics of how this works will be most helpful. Suppose we're trying to push the following elements to our stack: 1
, 2
, 3
, 4
, 5
, 6
, 7
, 8
. This is how the process would look:
##### PUSHING (1)
cache = [] # start state
storage = [] # start state
cache = [1] # after adding 1
storage = []
# len(cache) * len(cache) = 1 * 1 = 1 > 0 = len(storage) [YES, transfer and reassign]
# self.storage is empty so the empty pop doesn't happen: self.cache.append(self.storage.popleft())
# we still end up swapping/reassigning cache and storage
cache = []
storage = [1]
##### PUSHING (2)
cache = [] # start state
storage = [1]
cache = [2] # after pushing 2
storage = [1]
# len(cache) * len(cache) = 1 * 1 = 1 > 1 = len(storage) [NO, terminate push op]
##### PUSHING (3)
cache = [2] # start state
storage = [1]
cache = [2,3] # after pushing 3
cache = [3,2] # after rotating
storage = [1]
# len(cache) * len(cache) = 2 * 2 = 4 > 1 = len(storage) [YES, transfer and reassign]
cache = [3,2,1] # pop left all elements in storage and append to cache
storage = [] # pop ALL elements from storage from the left and append to cache until empty
cache = [] # swap and reassign
storage = [3,2,1]
##### PUSHING (4)
cache = [] # start state
storage = [3,2,1]
cache = [4] # after pushing 4
storage = [3,2,1]
# len(cache) * len(cache) = 1 * 1 = 1 > 3 = len(storage) [NO, terminate push op]
##### PUSHING (5)
cache = [4] # start state
storage = [3,2,1]
cache = [4,5] # after pushing 5
cache = [5,4] # after rotating
storage = [3,2,1]
# len(cache) * len(cache) = 2 * 2 = 4 > 3 = len(storage) [YES, transfer and reassign]
cache = [5,4,3,2,1] # pop left all elements in storage and append to cache
storage = [] # pop ALL elements from storage from the left and append to cache until empty
cache = [] # swap and reassign
storage = [5,4,3,2,1]
##### PUSHING (6)
cache = [] # start state
storage = [5,4,3,2,1]
cache = [6] # after pushing 6
storage = [5,4,3,2,1]
# len(cache) * len(cache) = 1 * 1 = 1 > 5 = len(storage) [NO, terminate push op]
##### PUSHING (7)
cache = [] # start state
storage = [5,4,3,2,1]
cache = [6,7] # after pushing 7
cache = [7,6] # after rotating
storage = [5,4,3,2,1]
# len(cache) * len(cache) = 2 * 2 = 4 > 5 = len(storage) [NO, terminate push op]
##### PUSHING (8)
cache = [7,6] # start state
storage = [5,4,3,2,1]
cache = [7,6,8] # after pushing 8
cache = [8,7,6] # after rotating
storage = [5,4,3,2,1]
# len(cache) * len(cache) = 3 * 3 = 6 > 5 = len(storage) [YES, transfer and reassign]
cache = [8,7,6,5,4,3,2,1] # pop left all elements in storage and append to cache
storage = [] # pop ALL elements from storage from the left and append to cache until empty
cache = [] # swap and reassign
storage = [8,7,6,5,4,3,2,1]
The mechanics of the process illustrated above show how cache
always maintains the top elements of the stack until its size limit (the square root of the size of storage
) has been exceeded. Then all elements from storage
are transfered to cache
so as to maintain the LIFO order of the stack. Then cache
and storage
are swapped/reassigned so that cache
is now empty again. Hence, storage
is the main storage for the stack and keeps growing indefinitely while cache
, on the other hand, is sort of an intermediary device that's used to make sure storage
grows in size as efficient as possible.
As this post notes:
push
works in amortized time. There are two cases: if , thenpush
takes time. If , thenpush
takes time, but after this operationcache
will be empty. It will take time before we get to this case again, so the amortized time is time.
Approach 3 (dynamic number of deques, O(1) operations)
class MyStack:
def __init__(self):
self.queue = deque()
def push(self, x: int) -> None:
new_queue = deque()
new_queue.append(x)
new_queue.append(self.queue)
self.queue = new_queue
def pop(self) -> int:
pop_val = self.queue.popleft()
self.queue = self.queue.popleft()
return pop_val
def top(self) -> int:
return self.queue[0]
def empty(self) -> bool:
return len(self.queue) == 0
The solution above, inspired by Stefan Pochmann's, shows it is possible to implement MyStack
using only operations if we get really creative (even though this may be thought of as "cheating" in some sense because we use an unlimited number of deques). This solution takes advantage of the fact that Python is fundamentally a reference-based language: adding a queue object into another does not copy the entire contents — this is an operation since a linked list is used under the hood (for Python deques).
To illustrate exactly how and why the solution above works, considering pushing the following elements to our stack: 1
, 2
, 3
. Below, we'll let D
represent a deque collection:
# starting state
self.queue = D()
# pushing 1
new_queue = D(1) # after pushing 1
= D(1, D()) # after pushing self.queue
self.queue = D(1, D()) # end state after pushing x
# pushing 2
new_queue = D(2) # after pushing 2
= D(2, D(1, D())) # after pushing self.queue
self.queue = D(2, D(1, D())) # end state after pushing 2
# pushing 3
new_queue = D(3) # after pushing 3
= D(3, D(2, D(1, D()))) # after pushing self.queue
self.queue = D(3, D(2, D(1, D()))) # end state after pushing 3
The process illustrated above shows how we always have access to the most recently added element (desirable for stacks because of the LIFO processing). Popping an element is also easy. Consider the final state of the example above: popping an element from the left (a queue operation) of self.queue
means popping the left-most element of D(3, D(2, D(1, D())))
, which is the integer 3
, as desired. After this pop, we have self.queue = D(D(2, D(1, D())))
, which is not desirable because now if we pop left then we'll get a deque and not an integer, as desired and required. But this is not much of an issue because all we have to do is reassign self.queue
to be the left-most popped element (after popping the 3
, the deque only consists of one element, the deque with everything else in the stack): self.queue = D(2, D(1, D()))
. Now we can pop left to get the 2
and reassign by popping left again to get D(1, D())
. Finally, we can pop left to get the 1
and reassign by popping left again to end up back where we started: D()
.
LC 649. Dota2 Senate (✓)
In the world of Dota2, there are two parties: the Radiant
and the Dire
.
The Dota2 senate consists of senators coming from two parties. Now the senate wants to make a decision about a change in the Dota2 game. The voting for this change is a round-based procedure. In each round, each senator can exercise one
of the two rights:
Ban one senator's right
: A senator can make another senator lose all his rights in this and all the following rounds.Announce the victory
: If this senator found the senators who still have rights to vote are all from the same party, he can announce the victory and make the decision about the change in the game.
Given a string representing each senator's party belonging. The character 'R' and 'D' represent the Radiant
party and the Dire
party respectively. Then if there are n
senators, the size of the given string will be n
.
The round-based procedure starts from the first senator to the last senator in the given order. This procedure will last until the end of voting. All the senators who have lost their rights will be skipped during the procedure.
Suppose every senator is smart enough and will play the best strategy for his own party, you need to predict which party will finally announce the victory and make the change in the Dota2 game. The output should be Radiant
or Dire
.
class Solution:
def predictPartyVictory(self, senate: str) -> str:
n = len(senate)
r_sen = deque()
d_sen = deque()
for i in range(len(senate)):
if senate[i] == 'R':
r_sen.append(i)
else:
d_sen.append(i)
while r_sen and d_sen:
banning, banned = min(r_sen[0], d_sen[0]), max(r_sen[0], d_sen[0])
if r_sen[0] == banning:
r_sen.append(r_sen.popleft() + n)
d_sen.popleft()
else:
d_sen.append(d_sen.popleft() + n)
r_sen.popleft()
return 'Radiant' if len(r_sen) > 0 else 'Dire'
This is quite the difficult problem, one where using queues is not at all obvious at first. The solution above (not due to me) is quite brilliant. The core idea, which will be reinforced/illustrated by means of an example in just a moment, is to create two queues, one for each set of senators. Why?
We can safely assume that senators will always act in a greedy way (i.e., they will always ban the senator of the opposing party if there is one). How do we know how to keep track of the senators and their banning decisions? Without queues, that becomes a more difficult question to answer. With queues, however, this becomes a much more straightforward question: we do a first pass of senate
and assemble queues of indexes for both parties, the Radiant senators, r_sen
, and the Dire senators d_sen
.
While both queues are non-empty, we compare their leftmost entries. Indexes naturally correspond to positions for the senators (i.e., the smaller index comes first); hence, the senator with the smaller index of the two will be the senator who does the banning while the senator with the larger of the indexes gets banned. The banned senator can simply be popped from the queue (popped from the left), but the senator who does the banning will have to wait for the next round (given the problem's circular nature). A clever solution to ensure the senator who does the banning is actually involved in the next round is to pop the banning senator from the queue (pop left), and then to push that same senator to the back of the same queue but this time with a larger index to indicate this senator comes later. The easiest way to adroitly perform this index manipulation is just by adding n
to the index that already exists, where n
is the size of the original senate
string.
Whichever queue ends up being non-empty is the victorious senate party.
Note: As can be seen in the solution above, we don't actually need to declare the banned
variable. We can remove it without issue since it is not used (it was only included to illustrate the explanation above).
Monotonic stacks
Remarks
TBD
TBD
Examples
LC 739. Daily Temperatures (✓)
Given an array of integers temperatures
that represents the daily temperatures, return an array answer
such that answer[i]
is the number of days you have to wait after the i
th day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0
instead.
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
ans = [None] * n
stack = []
for i in range(n):
val_A = temperatures[i]
# try to find the next larger temperature, val_B,
# for the current temperature, val_A
while stack and temperatures[stack[-1]] < val_A:
idx_val_B = stack.pop()
ans[idx_val_B] = i - idx_val_B
stack.append(i)
# remaining temperatures, val_A, have no next larger temperature, val_B
while stack:
idx_val_A = stack.pop()
ans[idx_val_A] = 0
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 239. Sliding Window Maximum (✓)
You are given an array of integers nums
, there is a sliding window of size k
which is moving from the very left of the array to the very right. You can only see the k
numbers in the window. Each time the sliding window moves right by one position.
Return the max sliding window.
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
dec_queue = deque() # monotonic deque (weakly decreasing)
ans = []
for i in range(n):
curr_num = nums[i]
# maintain the weakly decreasing deque
while dec_queue and nums[dec_queue[-1]] < curr_num:
dec_queue.pop()
# check to see if leftmost value of the deque
# is now actually an invalid index
if dec_queue and dec_queue[0] == i - k:
dec_queue.popleft()
dec_queue.append(i)
# only add window maximums to the answer array
# once the required length has been reached
if i >= k - 1:
ans.append(nums[dec_queue[0]])
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 1438. Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit (✓)
Given an array of integers nums
and an integer limit
, return the size of the longest non-empty subarray such that the absolute difference between any two elements of this subarray is less than or equal to limit
.
class Solution:
def longestSubarray(self, nums: List[int], limit: int) -> int:
n = len(nums)
dec_queue = deque() # monotonic deque (weakly decreasing) for the maximums
inc_queue = deque() # monotonic deque (weakly increasing) for the minimums
left = ans = 0
for right in range(n):
curr_num = nums[right]
# maintain the deque invariants
while dec_queue and nums[dec_queue[-1]] < curr_num:
dec_queue.pop()
while inc_queue and nums[inc_queue[-1]] > curr_num:
inc_queue.pop()
dec_queue.append(right)
inc_queue.append(right)
# update sliding window to ensure the window is valid
while left <= right and nums[dec_queue[0]] - nums[inc_queue[0]] > limit:
# remove possibly invalidated indexes from the deques once the window has shifted
if dec_queue[0] == left:
dec_queue.popleft()
if inc_queue[0] == left:
inc_queue.popleft()
left += 1
# update the answer with the length of the current valid window
ans = max(ans, right - left + 1)
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 496. Next Greater Element I (✓)
You are given two integer arrays nums1
and nums2
both of unique elements, where nums1
is a subset of nums2.
Find all the next greater numbers for nums1
's elements in the corresponding places of nums2
.
The Next Greater Number of a number x
in nums1
is the first greater number to its right in nums2
. If it does not exist, return -1
for this number.
class Solution:
def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]:
queries = {}
stack = []
# determine "next greater" values in nums2
for i in range(len(nums2)):
val_B = nums2[i]
while stack and nums2[stack[-1]] < val_B:
idx_val_A = stack.pop()
val_A = nums2[idx_val_A]
queries[val_A] = val_B
stack.append(i)
# remaining values have no next greater value (default to -1)
while stack:
idx_val_A = stack.pop()
val_A = nums2[idx_val_A]
queries[val_A] = -1
# the queries hash map tells us the next greater value
# for each value queried from nums1
ans = [None] * len(nums1)
for i in range(len(nums1)):
ans[i] = queries[nums1[i]]
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 503. Next Greater Element II
Given a circular integer array nums
(i.e., the next element of nums[nums.length - 1]
is nums[0]
), return the next greater number for every element in nums
.
The next greater number of a number x
is the first greater number to its traversing-order next in the array, which means you could search circularly to find its next greater number. If it doesn't exist, return -1
for this number.
class Solution:
def nextGreaterElements(self, nums: List[int]) -> List[int]:
n = len(nums)
ans = [None] * n
stack = []
for i in range(n * 2):
val_B = nums[i % n]
while stack and nums[stack[-1]] < val_B:
idx_val_A = stack.pop()
ans[idx_val_A] = val_B
# only add elements to the stack on the first full pass
if i < n:
stack.append(i)
else:
# otherwise the remaining values (if there are any)
# never had a next greater element; hence, we simply
# make another full pass to see if any element is greater
# than the current element in the stack and then pop the
# element from the stack if the answer is affirmative
if stack and nums[stack[-1]] < nums[i % n]:
idx_val_A = stack.pop()
ans[idx_val_A] = nums[i % n]
# the remaining values in the stack are those that do not have a next
# greater element despite two full passes; we report -1 for these values
while stack:
idx_val_A = stack.pop()
ans[idx_val_A] = -1
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 901. Online Stock Span (✓)
Write a class StockSpanner
which collects daily price quotes for some stock, and returns the span of that stock's price for the current day.
The span of the stock's price today is defined as the maximum number of consecutive days (starting from today and going backwards) for which the price of the stock was less than or equal to today's price.
For example, if the price of a stock over the next 7
days were [100, 80, 60, 70, 60, 75, 85]
, then the stock spans would be [1, 1, 1, 2, 1, 4, 6]
.
class StockSpanner:
def __init__(self):
self.stack = []
self.idx = 0
def next(self, price: int) -> int:
val_A = price
while self.stack and self.stack[-1][0] <= price:
self.stack.pop()
if self.stack:
idx_val_B = self.stack[-1][1]
val_B = self.stack[-1][0]
stock_span = self.idx - idx_val_B
else:
stock_span = self.idx + 1
self.stack.append([val_A, self.idx])
self.idx += 1
return stock_span
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 1475. Final Prices With a Special Discount in a Shop (✓)
Given the array prices
where prices[i]
is the price of the i
th item in a shop. There is a special discount for items in the shop, if you buy the ith item, then you will receive a discount equivalent to prices[j]
where j
is the minimum index such that j > i
and prices[j] <= prices[i]
, otherwise, you will not receive any discount at all.
Return an array where the i
th element is the final price you will pay for the i
th item of the shop considering the special discount.
class Solution:
def finalPrices(self, prices: List[int]) -> List[int]:
n = len(prices)
stack = []
for i in range(n):
val_B = prices[i]
while stack and prices[stack[-1]] >= val_B:
idx_val_A = stack.pop()
prices[idx_val_A] -= val_B # val_B is discount since it is next less or equal value to val_A
stack.append(i)
return prices
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 1063. Number of Valid Subarrays (✓)
Given an array A
of integers, return the number of non-empty continuous subarrays that satisfy the following condition:
The leftmost element of the subarray is not larger than other elements in the subarray.
class Solution:
def validSubarrays(self, nums: List[int]) -> int:
n = len(nums)
queries = [n] * n
stack = []
ans = 0
for i in range(n):
val_B = nums[i]
while stack and nums[stack[-1]] > val_B:
idx_val_A = stack.pop()
queries[idx_val_A] = i
stack.append(i)
# query the next smaller value for each index of nums
# the current index will be the included left endpoint
# and the queried value will be the excluded right endpoint
# total number of subarrays contributed where the left endpoint
# is the minimum: right - left (since right is excluded)
for left in range(n):
right = queries[left]
ans += right - left # NOT "right - left + 1" because right is not included here
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 1673. Find the Most Competitive Subsequence (✓)
Given an integer array nums
and a positive integer k
, return the most competitive subsequence of nums
of size k
.
An array's subsequence is a resulting sequence obtained by erasing some (possibly zero) elements from the array.
We define that a subsequence a
is more competitive than a subsequence b
(of the same length) if in the first position where a
and b
differ, subsequence a
has a number less than the corresponding number in b
. For example, [1,3,4]
is more competitive than [1,3,5]
because the first position they differ is at the final number, and 4
is less than 5
.
class Solution:
def mostCompetitive(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
stack = []
for i in range(n):
curr_num = nums[i]
while stack and stack[-1] > curr_num and (n - i + len(stack) > k):
stack.pop()
if len(stack) < k:
stack.append(nums[i])
return stack
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 1944. Number of Visible People in a Queue (✓)
There are n
people standing in a queue, and they numbered from 0
to n - 1
in left to right order. You are given an array heights
of distinct integers where heights[i]
represents the height of the i
th person.
A person can see another person to their right in the queue if everybody in between is shorter than both of them. More formally, the i
th person can see the j
th person if i < j
and min(heights[i], heights[j]) > max(heights[i+1], heights[i+2], ..., heights[j-1])
.
Return an array answer
of length n
where answer[i]
is the number of people the i
th person can see to their right in the queue.
class Solution:
def canSeePersonsCount(self, heights: List[int]) -> List[int]:
n = len(heights)
ans = [0] * n
stack = [] # monotonic stack (decreasing)
for i in range(n):
curr_height = heights[i]
while stack and heights[stack[-1]] < curr_height:
idx_prev_smaller_height = stack.pop()
ans[idx_prev_smaller_height] += 1
if stack:
ans[stack[-1]] += 1
stack.append(i)
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 2398. Maximum Number of Robots Within Budget (✓)
You have n
robots. You are given two 0-indexed integer arrays, chargeTimes
and runningCosts
, both of length n
. The i
th robot costs chargeTimes[i]
units to charge and costs runningCosts[i] units to run. You are also given an integer budget
.
The total cost of running k
chosen robots is equal to max(chargeTimes) + k * sum(runningCosts)
, where max(chargeTimes)
is the largest charge cost among the k
robots and sum(runningCosts)
is the sum of running costs among the k
robots.
Return the maximum number of consecutive robots you can run such that the total cost does not exceed budget
.
class Solution:
def maximumRobots(self, chargeTimes: List[int], runningCosts: List[int], budget: int) -> int:
dec_queue = deque() # monotonic deque (weakly decreasing) for charge times
left = window_sum = ans = 0
for right in range(len(chargeTimes)):
# maintain monotonic deque to ensure maximum charge time in window is quickly accessible
curr_charge = chargeTimes[right]
while dec_queue and chargeTimes[dec_queue[-1]] < curr_charge:
dec_queue.pop()
dec_queue.append(right)
# maintain total running cost of sliding window
curr_running_cost = runningCosts[right]
window_sum += curr_running_cost
while left <= right and dec_queue and chargeTimes[dec_queue[0]] + (right - left + 1) * window_sum > budget:
# adjust window_sum to reflect new sliding window's total running cost
window_sum -= runningCosts[left]
# remove leftmost queue element if index is no longer valid after shifting window
if dec_queue[0] == left:
dec_queue.popleft()
left += 1
ans = max(ans, right - left + 1)
return ans
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 907. Sum of Subarray Minimums (✓)
Given an array of integers arr, find the sum of min(b)
, where b
ranges over every (contiguous) subarray of arr
. Since the answer may be large, return the answer modulo 109 + 7
.
class Solution:
def sumSubarrayMins(self, arr: List[int]) -> int:
n = len(arr)
stack = []
ans = 0
MOD = 10 ** 9 + 7
for i in range(n + 1):
while stack and (i == n or arr[stack[-1]] >= arr[i]):
curr_min_idx = stack.pop()
curr_min = arr[curr_min_idx]
left_boundary = -1 if not stack else stack[-1]
right_boundary = i
num_subarrays = (curr_min_idx - left_boundary) * (right_boundary - curr_min_idx)
contribution = curr_min * num_subarrays
ans += contribution
stack.append(i)
return ans % MOD
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 2104. Sum of Subarray Ranges (✓)
You are given an integer array nums
. The range of a subarray of nums
is the difference between the largest and smallest element in the subarray.
Return the sum of all subarray ranges of nums
.
A subarray is a contiguous non-empty sequence of elements within an array.
class Solution:
def subArrayRanges(self, nums: List[int]) -> int:
n = len(nums)
stack = []
total_subarray_minimum_sum = 0
total_subarray_maximum_sum = 0
# calculate total contribution of subarray minimums
for i in range(n + 1):
while stack and (i == n or nums[stack[-1]] >= nums[i]): # note: either '>=' or '>' can be used
curr_min_idx = stack.pop()
curr_min = nums[curr_min_idx]
left_boundary = -1 if not stack else stack[-1]
right_boundary = i
num_subarrays = (curr_min_idx - left_boundary) * (right_boundary - curr_min_idx)
contribution = curr_min * num_subarrays
total_subarray_minimum_sum += contribution
stack.append(i)
# reset the stack
stack = []
# calculate total contribution of subarray maximums
for i in range(n + 1):
while stack and (i == n or nums[stack[-1]] <= nums[i]): # note: either '<=' or '<' can be used
curr_max_idx = stack.pop()
curr_max = nums[curr_max_idx]
left_boundary = -1 if not stack else stack[-1]
right_boundary = i
num_subarrays = (curr_max_idx - left_boundary) * (right_boundary - curr_max_idx)
contribution = curr_max * num_subarrays
total_subarray_maximum_sum += contribution
stack.append(i)
return total_subarray_maximum_sum - total_subarray_minimum_sum
See the blog post on monotonic stacks and queues for a more in depth discussion of the solution above, if needed.
LC 2487. Remove Nodes From Linked List★★★
You are given the head
of a linked list.
Remove every node which has a node with a strictly greater value anywhere to the right side of it.
Return the head
of the modified linked list.
class Solution:
def removeNodes(self, head: Optional[ListNode]) -> Optional[ListNode]:
sentinel = ListNode(float('inf'))
sentinel.next = head
curr = head
stack = [sentinel]
while curr:
while stack and stack[-1].val < curr.val:
stack.pop()
stack[-1].next = curr
stack.append(curr)
curr = curr.next
return sentinel.next
For each node, its previous "greater than or equal to" node should be linked to it; that is, maintaining a weakly decreasing monotonic stack gives us what we want by first pushing to it the sentinel node whose value is infinite (positively). This means our stack will never be empty; hence, we do not always need to make the check if stack
as we might normally have to otherwise. The pointer manipulation here is really quite clever.
Trees
__A______ | Pre-order (L -> R): A B X E M S W T P N C H
/ \ | Pre-order (R -> L): A W C H T N P B S X M E
__B __W__ | Post-order (L -> R): E M X S B P N T H C W A
/ \ / \ | Post-order (R -> L): H C N P T W S M E X B A
X S T C | In-order (L -> R): E X M B S A P T N W H C
/ \ / \ / | In-order (R -> L): C H W N T P A S B M X E
E M P N H | Level-order (L -> R): A B W X S T C E M P N H
| Level-order (R -> L): A W B C T S X H N P M E
Manually determine order of nodes visited ("tick trick")
Tick trick overview
One online resource does a good job of detailing the so-called tick trick, a handy trick for figuring out by hand the order in which a binary tree's nodes will be "visited" for the pre-order, in-order, and post-order traversals:
- Draw an arrow as a path around the nodes of the binary tree diagram, closely following its outline. The direction of the arrow depends on whether you are traversing the tree left-to-right or right-to-left.
- Draw a line or tick mark on one of the sides or the bottom of each node in the tree. Where you draw the mark depends on which traversal you are attempting to perform, as shown in the diagram below:
The point at which the path you've drawn around the binary tree intersects the tick mark is the point at which that node will be "visited" during the traversal. Examples for pre-, post-, and in-order traversals are provided below (left-to-right and right-to-left).
Why the tick trick actually works
The "tick trick" is a very nice, effective way to get a quick handle on the order in which nodes of a tree will be traversed. But why does drawing the tick in the manner specified actually work in creating the effective visual?
Perhaps the first key observation to keep in mind is how we actually start drawing any path that is meant to represent a traversal, namely not only from the root but from above whatever edges connect the root to other nodes; that is, we do not trace out a path from the root by drawing from the bottom of the node (no matter what kind of traversal we are doing). We start tracing the path above the edge that connects the root to its left child (or right child if we are doing a reverse traversal). Effectively, we start tracing out the path by starting from the top of the root node and then going the desired direction (conventionally left). These may seem like minor observations, but they are important to specify in order to make our path drawings well-defined (otherwise the "tick trick" could have different meanings for different path drawings).
Drawing a path around the tree, in the manner specified above, and placing tick marks at strategic points on the nodes allows us to create a visual guide that effectively conveys the sequence of node visits, where a "visit" represents the processing of a node and is visually indicated by the path intersecting the tick drawn on the node:
-
Pre-order: The idea is to draw the tick on the node in such a way that we cannot visit any of the node's children (left or right) before visiting the node itself. We can do this by drawing the tick on the left side of the node. The path traced out must intersect the node's tick before proceeding down the tree (i.e., before visiting any children).
Recursive observation: The practical implication of this tracing/traversal is that, starting at
node
(regardless of reference point), we processnode
and its entire left subtree before moving on to process nodes in the right subtree ofnode
. We travel as deeply as we can to the left, processing each node as we encounter it. Only once we've fully exhausted our abilities to go left do we start to go right by means of backtracking (this is indicated in the path tracing by the tracing moving upward and then back down to cover the right subtree). -
In-order: How can we draw the tick in such a way that makes it clear the current node cannot be visited until
- after its left child has been visited and
- before its right child has been visited?
The way in which we are tracing out a path suggests a possibility (think of a tree rooted at
1
with left child2
and right child3
): if we draw the tick straight down from the node, then we can only intersect the tick of the current node after intersecting the tick of its left child; furthermore, as we start to backtrack to visit the right child, we must cross the tick of the current node before reaching its right child.Recursive observation: The practical implication of this tracing/traversal is that, starting at
node
(regardless of reference point), we processnode
only after its entire left subtree has been processed and before its right subtree. This is why in-order traversal is common for binary search trees (BST), where the node values are ordered in a certain way: in-order traversal from left to right ensures nodes are processed in ascending order according to their value; similarly, in-order traversal of a BST from right-to-left ensures nodes are processed in descending order according to their value. -
Post-order: How can we draw the tick in such a way that makes it clear a node is only visited once its children have been visited? Directionally, it seems like a node's tick should be intersected by the path tracing once the path has gone as deep as it can (i.e., visited its children) and it is time to go back up and away. We can accomplish this by drawing the tick on the right side of a node, where the path is leaving the node in an upward direction.
Recursive observation: The practical implication of this tracing/traversal is that, starting at
node
(regardless of reference point), we processnode
only after its entire left subtree and its entire right subtree have been processed.
Correspondence between left-to-right and right-to-left traversals
It may be tempting to think that right-to-left traversals should effectively be "reversals" of their left-to-right counterparts, but this is not the case for pre- and post-order traversals. It is only the case for in-order traversals.
To see why, recall what the various traversals actually mean. A pre-order traversal means we will visit the current node before traversing either of its subtrees whereas a post-order traversal means we will visit the current node after traversing both of its subtrees. In either case, the root node itself serves as a point of clarification:
__A______ | Pre-order (L -> R): A B X E M S W T P N C H
/ \ | Pre-order (R -> L): A W C H T N P B S X M E
__B __W__ | Post-order (L -> R): E M X S B P N T H C W A
/ \ / \ | Post-order (R -> L): H C N P T W S M E X B A
X S T C | In-order (L -> R): E X M B S A P T N W H C
/ \ / \ / | In-order (R -> L): C H W N T P A S B M X E
E M P N H |
How could the left-to-right and right-to-left pre-order traversals be reversals of each other if they both start with the same node? Similarly, the post-order traversals cannot be reversals of each other if they both end with the same node. But what about in-order traversals? As can be seen above, the order in which the nodes are visited is reversed when we change the traversal from left-to-right to right-to-left.
It is worth noting that the left-to-right pre-order traversal is effectively the reverse of the right-to-left post-order traversal. Similarly, the left-to-right post-order traversal is effectively the reverse of the right-to-left pre-order traversal.
Use the binarytree
package in Python to facilitate learning
Learning about trees can become overly cumbersome if you are specifying all of the nodes yourself. For example, the binary tree in the tip above (and the one we will see throughout the subsections below) may be set up in Python without any package support as follows:
See the setup
class TreeNode:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
n1 = TreeNode('A')
n2 = TreeNode('B')
n3 = TreeNode('W')
n4 = TreeNode('X')
n5 = TreeNode('S')
n6 = TreeNode('T')
n7 = TreeNode('C')
n8 = TreeNode('E')
n9 = TreeNode('M')
n10 = TreeNode('P')
n11 = TreeNode('N')
n12 = TreeNode('H')
n1.left = n2
n1.right = n3
n2.left = n4
n2.right = n5
n4.left = n8
n4.right = n9
n3.left = n6
n3.right = n7
n6.left = n10
n6.right = n11
n7.left = n12
That's not fun. The binarytree
package makes things much easier to work with. The same tree can be set up as follows:
from binarytree import build2
bin_tree = build2(['A', 'B', 'W', 'X', 'S', 'T', 'C', 'E', 'M', None, None, 'P', 'N', 'H'])
The code in the sections below will rely on binarytree
for the sake of simplicity.
- Pre-order (L->R)
- Pre-order (R->L)
- Post-order (L->R)
- Post-order (R->L)
- In-order (L->R)
- In-order (R->L)
- Level-order (L->R)
- Level-order (R->L)
Pre-order traversal
Recursive
Remarks
TBD
- Python (L->R)
- Python (R->L)
- Pseudocode
def preorder_recursive_LR(node):
if not node:
return
visit(node)
preorder_recursive_LR(node.left)
preorder_recursive_LR(node.right)
def preorder_recursive_RL(node):
if not node:
return
visit(node)
preorder_recursive_RL(node.right)
preorder_recursive_RL(node.left)
procedure preorder(node)
if node = null
return
visit(node)
preorder(node.left)
preorder(node.right)
Examples
TBD
Iterative
Remarks
TBD
Analogy
procedure iterativePreorder(node)
if node = null
return
stack ← empty stack
stack.push(node)
while not stack.isEmpty()
node ← stack.pop()
visit(node)
if node.right ≠ null
stack.push(node.right)
if node.left ≠ null
stack.push(node.left)
Imagine you're a tourist visiting a town rapidly growing in popularity. This town has several attractions, and you want to start by seeing the the main one (root). In an effort to help tourists plan their sightseeing effectively, town leadership organized the attractions in such a way that subsequent attractions are usually recommended once a tourist has finished visiting the current attraction. Any given attraction will recommend either no subsequent attraction (a leaf), a single subsequent attraction, or two subsequent attractions. If two subsequent attractions are recommended, then one will be a primary attraction (left child) and the other a secondary attraction (right child). You want to see as many primary attractions as you can, starting at the main primary attraction, before moving on to secondary attractions, but you want to see them all.
Here's the process you will follow in order to accomplish this:
- Step 1 (start seeing attractions): Begin your sightseeing journey by visiting the town's main attraction (visit the root).
- Step 2 (note the recommendations): If the attraction you just visited recommends another attraction (not a leaf), then make a note of this (push to the stack).
- Step 3 (visit primary attractions first and as encountered): Before exploring the town any further and visiting other attractions, always immediately visit the recommended primary attraction (left child) if it exists.
- Step 4 (note secondary attractions): If the attraction you just visited recommends a secondary attraction (right child), then note this secondary attraction for visiting later (push to the stack), but continue on your current path.
- Step 5 (use your notes to visit more attractions): Once you have finished seeing as many consecutive primary attractions as you can, consult your notes and follow your most recent note about secondary attractions that you've made.
- Step 6 (finish seeing attractions): Continue the pattern of visiting primary attractions as you encounter them and noting down secondary attractions for future visitations until you have explored all attractions in the town.
We can annotate the previously provided Python code to illustrate the steps above (the highlighted line simply serves to show where the logic would be included to process the current node):
def preorder_iterative_LR(node):
# (in case there is no main attraction)
if not node:
return
stack = []
stack.append(node) # Step 1: Start seeing attractions
while stack:
node = stack.pop() # Step 3 or 5: Visit primary attraction (Step 3) OR
# check most recent note for secondary attraction (Step 5)
visit(node) # Visit the current attraction (process current node)
# Step 4: Note the recommended secondary attraction (if it exists)
if node.right:
stack.append(node.right)
# Step 2: Note the recommended primary attraction (if it exists)
if node.left:
stack.append(node.left)
Note that since stacks are fundamentally LIFO structures (i.e., last in first out) we want to push primary attraction recommendations to the stack after secondary attraction recommendations. This ensures we always get the primary attraction recommendation when we pop from the stack.
This analogy makes it clear the tourist focuses on the primary attractions but neither loses sight of nor forgets the secondary attractions thanks to the notes taken after visiting each attraction (i.e., pushing to the stack).
- Python (L->R)
- Python (R->L)
- Pseudocode
def preorder_iterative_LR(node):
if not node:
return
stack = []
stack.append(node)
while stack:
node = stack.pop()
visit(node)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
def preorder_iterative_RL(node):
if not node:
return
stack = []
stack.append(node)
while stack:
node = stack.pop()
visit(node)
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
procedure iterativePreorder(node)
if node = null
return
stack ← empty stack
stack.push(node)
while not stack.isEmpty()
node ← stack.pop()
visit(node)
// right child is pushed first so that left is processed first
if node.right ≠ null
stack.push(node.right)
if node.left ≠ null
stack.push(node.left)
Examples
TBD
Post-order traversal
Recursive
Remarks
TBD
- Python (L->R)
- Python (R->L)
- Pseudocode
def postorder_recursive_LR(node):
if not node:
return
postorder_recursive_LR(node.left)
postorder_recursive_LR(node.right)
visit(node)
def postorder_recursive_RL(node):
if not node:
return
postorder_recursive_RL(node.right)
postorder_recursive_RL(node.left)
visit(node)
procedure postorder(node)
if node = null
return
postorder(node.left)
postorder(node.right)
visit(node)
Examples
TBD
Iterative
Remarks
TBD
Analogy
procedure iterativePostorder(node)
stack ← empty stack
lastNodeVisited ← null
while not stack.isEmpty() or node ≠ null
if node ≠ null
stack.push(node)
node ← node.left
else
peekNode ← stack.peek()
if peekNode.right ≠ null and lastNodeVisited ≠ peekNode.right
node ← peekNode.right
else
visit(peekNode)
lastNodeVisited ← stack.pop()
def postorder_iterative_LR(node):
stack = []
last_node_visited = None
while stack or node:
if node:
stack.append(node)
node = node.left
else:
peek_node = stack[-1]
if peek_node.right and (last_node_visited is not peek_node.right):
node = peek_node.right
else:
visit(peek_node)
last_node_visited = stack.pop()
Imagine you're exploring a series of underground caves, where the caves have multiple tunnels (paths) and chambers (nodes) connected in a complex network. Your ultimate goal is to mark each chamber as having been "Explored", but you can only mark a chamber as having been "Explored" if you have explored all the deeper chambers (children) accessible from it. To accomplish this task, you have been given a piece of chalk for marking chambers and a map to record where you have been. Every time you enter a new chamber, you mark it on your map (push it to the stack), but you hold off on marking the chamber as "Explored" until you've visited every chamber accessible from it.
Here's the process you will follow in order to accomplish this:
- Step 1 (begin the exploration): Enter the first chamber (root).
- Step 2 (check for a left tunnel): Check for a left tunnel. If there is a left tunnel, then mark this chamber on your map (push to the stack) and venture down the left tunnel.
- Step 3 (exhaust all left tunnels): Continue to the deepest chamber you can reach by always taking left tunnels.
- Step 4 (check for a right tunnel): Check for a right tunnel once you find yourself in a chamber with no left tunnel or where all left chambers have been marked as "Explored".
- Step 5 (venture down a right tunnel): If there's an unexplored right tunnel, mark your current chamber on the map (it's still on the stack) and venture down the right tunnel.
- Step 6 (mark a chamber as "Explored"): If there's no right tunnel or if it's already been explored, then this chamber is now the deepest unexplored one, so you can mark it as "Explored" (visit the node by printing its value). Then cross it off your map (pop it from the stack) and backtrack.
- Step 7 (continue the process): Continue the process described above. Every time you backtrack to a chamber, check its right tunnel. If it's unexplored, then venture in. If it's explored or non-existent, then mark the chamber as "Explored" and backtrack further.
- Step 8 (return to the entrance): Keep doing everything above until you've marked every chamber as "Explored" and have returned to the cave entrance.
Essentially, you will be venturing down as deep as you can, marking chambers as "Explored" on your way out, ensuring the deeper chambers are always marked as "Explored" before the shallower ones from which they are accessible.
We can annotate the previously provided Python code to illustrate the steps above (the highlighted line simply serves to show where the logic would be included to process the current node):
def postorder_iterative_LR(node):
stack = []
last_node_visited = None
# Step 1: Enter the cave system. As long as there's a chamber to explore
# or a path in the stack to backtrack to, continue.
while stack or node:
# Step 2: If you're in a new chamber, then mark the path you took
# to get there (push it onto a stack).
if node:
stack.append(node)
# Step 3: Always check the left tunnel of the current chamber first.
# If there is one, you go down it.
node = node.left
else:
# Step 4: If there's no left tunnel or after coming back
# from a left tunnel, you're ready to check the right tunnel.
peek_node = stack[-1]
# Step 5: Before checking the right tunnel,
# make sure you haven't just explored it.
# If not, you go down the right tunnel.
if peek_node.right and (last_node_visited is not peek_node.right):
node = peek_node.right
else:
# Step 6: If no tunnels remain to explore from current chamber,
# or if you've just explored the right tunnel, then
# it's time to mark the current chamber as "Explored"
visit(peek_node)
# Step 7: After marking the chamber as "Explored", you backtrack.
# The last path you took (from the stack) will help you go back.
last_node_visited = stack.pop()
# Step 8: When you've explored every chamber and every tunnel,
# and there's no path left in your stack,
# you exit the cave system.
It's worth specifically noting what the following if
block accomplishes in the code above:
if peek_node.right and (last_node_visited is not peek_node.right):
node = peek_node.right
peek_node.right
: This checks whether or nor the current chamber (represented bypeek_node
) has a right tunnel and answers the question, "Is there a right tunnel leading out of this chamber?"last_node_visited is not peek_node.right
: This checks if the right tunnel/chamber (peek_node.right
) was the last one you explored. If it was, then you've already visited it and don't need to venture down there again. It answers the question, "Did I just come from that right tunnel, or have I not explored it yet?"node = peek_node.right
: If the current chamber has an unexplored right tunnel, then prepare to venture into it. This assignment is effectively saying, "I haven't explored the right tunnel of this chamber yet. Let's go down there next."
Essentially, the if
block above ensures you explore a chamber's right tunnel if you haven't already — if you've just come back from exploring the right tunnel (i.e., last_node_visited is peek_node.right
is True
), then you know it's time to mark the current chamber as "Explored" and backtrack.
The procedure outlined above is rather sophisticated and complex in its logic — it is probably easiest to understand if we actually work through a concrete example such as the one provided below (writing out the process may seem tedious, and it is, but it's worth following the first time around to provide some sort of intuition for things).
Concrete example using a familiar binary tree
We have used the following binary tree in a number of previous examples:
__A______
/ \
__B __W__
/ \ / \
X S T C
/ \ / \ /
E M P N H
For the sake of our example, suppose each node represents a chamber of a cave. Then the entrance to the cave system is marked by the root node, A
. Let's start exploring the cave and try to mark all chambers as "Explored" by using our previously described process, where the order in which the chambers should be marked as "Explored" should be E M X S B P N T H C W A
in order to hold true to a post-order traversal (each bullet point below represents an iteration of the while
loop where each bullet point ends with the current state of explored chambers):
-
We start by entering the cave system, leading us into chamber
A
. We push this on to the stack:| A |
+---+We attempt to go to chamber
A
's left tunnel if there is one. There is. We update the current node to point to chamberB
.Explored chambers:
[]
-
We push
B
on to the stack:| B |
| A |
+---+We attempt to go to chamber
B
's left tunnel if there is one. There is. We update the current node to point to chamberX
.Explored chambers:
[]
-
We push
X
on to the stack:| X |
| B |
| A |
+---+We attempt to go to chamber
X
's left tunnel if there is one. There is. We update the current node to point to chamberE
.Explored chambers:
[]
-
We push
E
on to the stack:| E |
| X |
| B |
| A |
+---+We attempt to go to chamber
E
's left tunnel if there is one. There is not. We update the current node to point toNone
.Explored chambers:
[]
-
Since
node
currently points toNone
, we do not need to check for a left tunnel. Instead, we need to check for a right tunnel.peek_node = stack[-1]
meanspeek_node
points to nodeE
sinceE
is on top of the stack.peek_node.right
has no meaningful value since chamberE
has no right tunnel; hence, no tunnels remain to explore from our current chamber. We can mark chamberE
as "Explored". To keep track of which chamber we last visited and to update our stack of chambers we still need to explore, we letlast_node_visited = stack.pop()
, meaninglast_node_visited
now points to nodeE
, and our updated stack looks as follows:| X |
| B |
| A |
+---+Explored chambers:
[ E ]
-
Since
node
still points toNone
, we do not need to check for a left tunnel. Instead, we need to check for a right tunnel.Simplify Matters by Understanding the Possible Outcomes for Each IterationIt is easy to get lost in some of the fancy referential footwork used in the iterative post-order traversal. But note the only possible outcomes for each iteration of the
while
loop:-
(left tunnel exists): enter chamber of left tunnel and keep going left until you can go no further
-
(no left tunnel; no right tunnel): mark the chamber as explored (print the node's value), note the chamber as being the last one explored, and remove the chamber from the stack of chambers waiting to be explored
-
(no left tunnel; right tunnel exists, not yet explored): enter chamber of right tunnel and try to explore its left tunnels if it has any
-
(no left tunnel; right tunnel exists, already explored): this is effectively the same as neither having a left tunnel nor a right tunnel — follow the guidelines above concerning that scenario
Essentially, you're going left or right if you can; otherwise, you're marking the chamber as having been explored (printing its value), noting that you just explored it so you don't explore it again and updating the stack of chambers that need exploring (popping from the stack the chamber you just visited and referring to it as
last_node_visited
).Since
X
now sits at the top of the stack,peek_node = stack[-1]
meanspeek_node
points to chamberX
. This timepeek_node.right
does have a meaningful value since there is a right tunnel from chamberX
that leads into chamberM
. Before we visit chamberM
, however, we need to ask ourselves, "Have we visited chamberM
yet?" Sincelast_node_visited
points to chamberE
and not chamberM
, we can safely assume we have not yet visited chamberM
. As such, we should prepared to visit chamberM
. Updatenode
to point to chamberM
.Explored chambers:
[ E ]
-
-
We push
M
on to the stack:| M |
| X |
| B |
| A |
+---+We attempt to go to chamber
M
's left tunnel if there is one. There is not. We update the current node to point toNone
.Explored chambers:
[ E ]
-
peek_node = stack[-1]
now points to chamberM
. There's no right tunnel from chamberM
. Mark chamberM
as having been explored, and pop it from the stack of chambers we still need to visit (make sure to keep a reference to this most recently explored chamber as well):| X |
| B |
| A |
+---+Explored chambers:
[ E M ]
-
Since
node
still points toNone
, we look at chamberpeek_node = stack[-1]
, which points again to chamberX
. Note thatpeek_node.right
gives a meaningful value, namely chamberM
. But we just visited chamberM
and marked it as explored. Visiting chamberM
again would not make any sense. Fortunately, we noted which chamber we last visited withlast_node_visited
. This variable points to chamberM
.Hence, the second part of the
and
portion ofpeek_node.right and (last_node_visited is not peek_node.right)
is false, meaning we do not explore the right tunnel (i.e., chamber
M
). This means we can now safely mark chamberX
as having been explored (since all chambers beneath it on the left and right have now been explored) as well as update our stack and our "most recently visited chamber" reference:| B |
| A |
+---+Explored chambers:
[ E M X ]
-
The pattern may start to emerge more clearly now.
node
still points toNone
.peek_node = stack[-1]
meanspeek_node
now points to chamberB
. We see thatpeek_node.right
has a meaningful value, namely chamberS
. Furthermore,last_node_visited
points to chamberX
, not chamberS
. Hence, we should explore the right tunnel from chamberB
that begins with chamberS
.Explored chambers:
[ E M X ]
-
node
now points to chamberS
. Push it to the stack:| S |
| B |
| A |
+---+We attempt to go to chamber
S
's left tunnel if there is one. There is not. We update the current node to point toNone
.Explored chambers:
[ E M X ]
-
node
now points toNone
. Andpeek_node = stack[-1]
points to chamberS
. Andpeek_node.right
does not give a meaningful value, meaning chamberS
has no right tunnel. Mark chamberS
as explored and pop it from the stack:| B |
| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S ]
-
node
points toNone
.peek_node = stack[-1]
points to chamberB
again.peek_node.right
points to chamberS
, butlast_node_visited
also points to chamberS
. Hence, mark chamberB
as explored and pop it from the stack:| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B ]
-
node
points toNone
.peek_node = stack[-1]
points to chamberA
.peek_node.right
points to chamberW
. Sincelast_node_visited
points to chamberB
and not chamberW
, this means we should prepare to visit the right tunnel from chamberA
that begins with chamberW
. Updatenode
to point to chamberW
.Explored chambers:
[ E M X S B ]
-
node
points to chamberW
. Push it to the stack:| W |
| A |
+---+We attempt to go to chamber
W
's left tunnel if there is one. There is. We update the current node to point to chamberT
.Explored chambers:
[ E M X S B ]
-
node
points to chamberT
. Push it to the stack:| T |
| W |
| A |
+---+We attempt to go to chamber
T
's left tunnel if there is one. There is. We update the current node to point to chamberP
.Explored chambers:
[ E M X S B ]
-
node
points to chamberP
. Push it to the stack:| P |
| T |
| W |
| A |
+---+We attempt to go to chamber
P
's left tunnel if there is one. There is not. We update the current node to point toNone
.Explored chambers:
[ E M X S B ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberP
. Sincepeek_node.right
does not have a meaningful value (i.e., chamberP
has no right tunnel), we may mark chamberP
as explored and pop it from the stack:| T |
| W |
| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B P ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberT
. We look for a right tunnel and see thatpeek_node.right
reveals chamberN
. Sincelast_node_visited
points to chamberP
and not chamberN
, we prepare to explore chamberN
. Updatenode
to point to chamberN
.Explored chambers:
[ E M X S B P ]
-
node
points to chamberN
. Push it to the stack:| N |
| T |
| W |
| A |
+---+We attempt to go to chamber
N
's left tunnel if there is one. There is not. We update the current node to point toNone
.Explored chambers:
[ E M X S B P ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberN
. Sincepeek_node.right
does not provide a meaningful value (i.e., chamberN
has no right tunnel), we may mark chamberN
as explored and pop it from the stack:| T |
| W |
| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B P N ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberT
again. Andpeek_node.right
points to chamberN
. Butlast_node_visited
also points to chamberN
, indicating we should not explore chamberN
. Instead, we should mark chamberT
as explored and pop it from the stack:| W |
| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B P N T ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberW
. Andpeek_node.right
points to chamberC
. Sincelast_node_visited
points to chamberT
and not chamberC
, we should prepare to visit chamberC
. Updatenode
to point to chamberC
.Explored chambers:
[ E M X S B P N T ]
-
node
points to chamberC
. Push it to the stack:| C |
| W |
| A |
+---+We attempt to go to chamber
C
's left tunnel if there is one. There is. We update the current node to point to chamberH
.Explored chambers:
[ E M X S B P N T ]
-
node
points to chamberH
. Push it to the stack:| H |
| C |
| W |
| A |
+---+We attempt to go to chamber
H
's left tunnel if there is one. There is not. We update the current node to point toNone
.Explored chambers:
[ E M X S B P N T ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberH
. Sincepeek_node.right
does not provide a meaningful value, we may mark chamberH
as being explored and pop it from the stack:| C |
| W |
| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B P N T H ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberC
. Sincepeek_node.right
does not provide a meaningful value, we may mark chamberC
as explored and pop it from the stack:| W |
| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B P N T H C ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberW
. Even thoughpeek_node.right
points to chamberC
, we see thatlast_node_visited
also points to chamberC
, meaning we should not visit chamberC
. Mark chamberW
as explored and pop it from the stack:| A |
+---+Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B P N T H C W ]
-
node
points toNone
. Andpeek_node = stack[-1]
points to chamberA
. Even thoughpeek_node.right
points to chamberW
, we see thatlast_node_visited
also points to chamberW
, meaning we should not visit chamberW
. Mark chamberA
as explored and pop it from the stack:[]
Update our reference for the most recently explored chamber.
Explored chambers:
[ E M X S B P N T H C W A ]
The while
loop does not fire now since node
still points to None
and stack
is now empty. The iterative post-order traversal is now complete, and we see we have visited the chambers in the expected order:
E M X S B P N T H C W A
In sum, iterative post-order traversals can be rather complicated, but can also be elegant nonetheless.
- Python (L->R)
- Python (R->L)
- Pseudocode
def postorder_iterative_LR(node):
stack = []
last_node_visited = None
while stack or node:
if node:
stack.append(node)
node = node.left
else:
peek_node = stack[-1]
if peek_node.right and (last_node_visited is not peek_node.right):
node = peek_node.right
else:
visit(peek_node)
last_node_visited = stack.pop()
def postorder_iterative_RL(node):
stack = []
last_node_visited = None
while stack or node:
if node:
stack.append(node)
node = node.right
else:
peek_node = stack[-1]
if peek_node.left and (last_node_visited is not peek_node.left):
node = peek_node.left
else:
visit(peek_node)
last_node_visited = stack.pop()
procedure iterativePostorder(node)
stack ← empty stack
lastNodeVisited ← null
while not stack.isEmpty() or node ≠ null
if node ≠ null
stack.push(node)
node ← node.left
else
peekNode ← stack.peek()
// if right child exists and traversing node
// from left child, then move right
if peekNode.right ≠ null and lastNodeVisited ≠ peekNode.right
node ← peekNode.right
else
visit(peekNode)
lastNodeVisited ← stack.pop()
Examples
TBD
In-order traversal
Recursive
Remarks
TBD
- Python (L->R)
- Python (R->L)
- Pseudocode
def inorder_recursive_LR(node):
if not node:
return
inorder_recursive_LR(node.left)
visit(node)
inorder_recursive_LR(node.right)
def inorder_recursive_RL(node):
if not node:
return
inorder_recursive_RL(node.right)
visit(node)
inorder_recursive_RL(node.left)
procedure inorder(node)
if node = null
return
inorder(node.left)
visit(node)
inorder(node.right)
Examples
TBD
Iterative
Remarks
TBD
Analogy
procedure iterativeInorder(node)
stack ← empty stack
while not stack.isEmpty() or node ≠ null
if node ≠ null
stack.push(node)
node ← node.left
else
node ← stack.pop()
visit(node)
node ← node.right
Imagine you're reading a book series. Specifically, let's suppose you're reading the Jack Ryan series of novels by Tom Clancy. You've heard a lot about this series and know several books have film adaptations. You'd like to check it out. The first book you check out, Debt of Honor, seems to probably end with Jack Ryan becoming the President of the United States. That can't be right. Surely there's a lot more to Jack Ryan's story that led up to that point. You'd really like to read the whole book series in order, specifically by chronological order of events as opposed to publication date.
Jack Ryan novels by chronological order of events
The excerpt below is from ChatGPT (Aug 19, 2023).
Tom Clancy's Jack Ryan series, with its complex web of interconnected characters and plots, can be arranged according to the internal chronological order of events rather than their publication dates. This order provides a coherent understanding of Jack Ryan's life, from a young Marine to President of the United States, as well as the lives of other recurring characters.
Here's the chronological ordering of the Jack Ryan series based on character development and the events in the series:
-
Without Remorse (1993) - This novel serves as a prequel to the series, focusing on the backstory of John Kelly (who later becomes John Clark), a recurring character in many of the Jack Ryan books.
-
Red Rabbit (2002) - Set in the early 1980s, this book details a younger Jack Ryan's time with the CIA and a plot to assassinate Pope John Paul II.
-
The Hunt for Red October (1984) - This is the first novel published in the series, introducing Jack Ryan as an analyst for the CIA as he gets involved in the defection of a Soviet submarine captain.
-
The Cardinal of the Kremlin (1988) - Jack Ryan becomes involved in a high-stakes espionage game as the Americans and the Soviets vie for technological dominance.
-
Clear and Present Danger (1989) - Jack Ryan is a National Security Advisor here, and the story revolves around U.S. efforts against Colombian drug cartels.
-
The Sum of All Fears (1991) - Jack Ryan, now Deputy Director of Intelligence for the CIA, must prevent a nuclear conflict between the U.S. and Russia.
-
Debt of Honor (1994) - The focus shifts to the Pacific, with an escalating conflict between the U.S. and Japan. By the end of the novel, Jack Ryan is thrust into the role of President of the United States.
-
Executive Orders (1996) - Continuing directly from where "Debt of Honor" left off, President Ryan faces both internal and external challenges, including rebuilding the U.S. government and dealing with an Ebola-like epidemic.
-
The Bear and the Dragon (2000) - President Jack Ryan oversees a complex geopolitical situation involving China, Russia, and the prospect of World War III.
-
Red Storm Rising (1986) - While not strictly a Jack Ryan novel (he doesn't appear in it), this book is set in the same universe and involves a war between NATO and Warsaw Pact forces.
-
The Teeth of the Tiger (2003), Dead or Alive (2010), Locked On (2011), Threat Vector (2012), and Command Authority (2013) - These novels follow the next generation, including Jack Ryan Jr., as they face modern threats in a post-9/11 world. Jack Ryan Sr. still plays a role, but the torch is being passed to the younger characters.
It's worth noting that Tom Clancy's name has been attached to books written by other authors after his death in 2013. If you're interested in the books in this universe that continue the story or explore other side characters, there's an extended series to dive into, but the above list covers the main Jack Ryan saga as written by Clancy himself.
As fate would have it, the book you just started with (the root) has both prequel and sequel recommendations. Some books you encounter may have no recommendations (leaf nodes), but you want to prioritize tracing back through each preqel recommendation (left child) so you can start at the beginning of the series, but you also need to try to read the sequel (right child) for each book, as recommended.
Here's the process you will follow in order to accomplish this:
- Step 1 (start with the first book in the series): Follow all prequel recommendations (left children) from your starting point (root) until they have all been exhausted (you hit a leaf node), noting each book along the way that recommends a prequel (push it to the stack).
- Step 2 (follow recommendations): If your current book has a prequel recommendation (left child), then set it aside to be read later (push it to the stack).
- Step 3 (keep following recommendations): If the new book also has a prequel recommendation, then repeat the process: set the new book aside to read later, and pick up the recommended prequel. Continue this process until you reach a book with no prequel recommendation.
- Step 4 (read the book): Once there is no prequel left to read, read the book (visit the node).
- Step 5 (move to sequel recommendation or return to books previously set aside): Always attempt to move on to a sequel recommendation (right child) after having read a book (once the node has been visited). If there is no such sequel recommendation, then move back to the most recent book you've set aside but have not yet read (pop from the stack). Continue.
- Step 6 (repeat until all books are read): Repeat the steps above until you have finished all books in the series.
We can annotate the previously provided Python code to illustrate the steps above (the highlighted line simply serves to show where the logic would be included to process the current node):
def inorder_iterative_LR(node):
stack = []
# there is still a book to be read
while stack or node:
# Steps 1-3: Follow prequel recommendations
if node:
stack.append(node) # Step 2: Set aside the current book
node = node.left
else:
# Step 4: Read the current book
node = stack.pop()
print(node.val)
# Step 5: Move to sequel recommendation
node = node.right
Note that this analogy involves a highly contrived example. If we followed the numbering of the Jack Ryan books in chronological ordering after starting with book 7
as the root, then the most sensible binary tree would look rather ridiculous:
7
/ \
6 8
/ \
5 9
/ \
4 10
/ \
3 11
/
2
/
1
But technically any other ordering would work so long as 7
was the root and the in-order traversal led to books 1
through 11
being listed in sequence. One such example:
__7__
/ \
__5 9
/ \ / \
3 6 8 10
/ \ \
2 4 11
/
1
- Python (L->R)
- Python (R->L)
- Pseudocode
def inorder_iterative_LR(node):
stack = []
while stack or node:
if node:
stack.append(node)
node = node.left
else:
node = stack.pop()
visit(node)
node = node.right
def inorder_iterative_RL(node):
stack = []
while stack or node:
if node:
stack.append(node)
node = node.right
else:
node = stack.pop()
visit(node)
node = node.left
procedure iterativeInorder(node)
stack ← empty stack
while not stack.isEmpty() or node ≠ null
if node ≠ null
stack.push(node)
node ← node.left
else
node ← stack.pop()
visit(node)
node ← node.right
Examples
TBD
Level-order traversal
Remarks
TBD
- Python (L->R)
- Python (R->L)
- Pseudocode
- Recursive
def levelorder_LR(node):
queue = deque()
queue.append(node)
while queue:
num_nodes_this_level = len(queue)
for _ in range(num_nodes_this_level):
node = queue.popleft()
visit(node)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
def levelorder_RL(node):
queue = deque()
queue.append(node)
while queue:
num_nodes_this_level = len(queue)
for _ in range(num_nodes_this_level):
node = queue.popleft()
visit(node)
if node.right:
queue.append(node.right)
if node.left:
queue.append(node.left)
procedure levelorder(node)
queue ← empty queue
queue.enqueue(node)
while not queue.isEmpty()
node ← queue.dequeue()
visit(node)
if node.left ≠ null
queue.enqueue(node.left)
if node.right ≠ null
queue.enqueue(node.right)
The pseudocode above (from Wikipedia) is the standard BFS implementation for a binary tree traversal, where we only care about visiting all nodes, level by level, left to right. But it's fairly common to encounter algorithm problems that demand you do something (i.e., perform some logic) on a level by level basis; that is, you effectively need to isolate the nodes by level. The pseudocode above does not do this, but we can easily fix this ourselves:
procedure levelorder(node)
queue ← empty queue
queue.enqueue(node)
while not queue.isEmpty()
// retrieve number of nodes on current level
numNodesThisLevel ← queue.length
// perform logic for current level
for each node in level do
node ← queue.dequeue()
// perform logic on current node
visit(node)
// enqueue nodes on next level (left to right)
if node.left ≠ null
queue.enqueue(node.left)
if node.right ≠ null
queue.enqueue(node.right)
The Python code snippets in the other tabs reflect this approach since it is the most likely approach needed in the context of solving interview problems.
As this Stack Overflow post explores, breadth-first search can be done recursively, but this does not mean it should be done recursively. It's quite a bit more complex than the iterative solution with basically no added benefit (instead of using a queue to explicitly do things efficiently we would now just be implicitly using the call stack).
That said, here's a possible recursive approach to a level-order traversal for the binary tree we've used for reference:
from binarytree import build2
bin_tree = build2(['A', 'B', 'W', 'X', 'S', 'T', 'C', 'E', 'M', None, None, 'P', 'N', 'H'])
root = bin_tree.levelorder[0]
def level_order(root):
h = height(root)
for i in range(1, h + 1):
print_level(root, i)
def print_level(node, level):
if not node:
return
if level == 1:
print(node.val)
elif level > 1:
print_level(node.left, level - 1)
print_level(node.right, level - 1)
def height(node):
if not node:
return 0
l_height = height(node.left)
r_height = height(node.right)
return max(l_height, r_height) + 1
level_order(root) # A B W X S T C E M P N H
Examples
TBD
Level-order (BFS)
Remarks
tbd
def fn(node):
queue = deque()
queue.append(node)
while queue:
num_nodes_this_level = len(queue)
for _ in range(num_nodes_this_level):
node = queue.popleft()
visit(node)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
Examples
LC 199. Binary Tree Right Side View (✓)
Given the root
of a binary tree, imagine yourself standing on the right side of it, return the values of the nodes you can see ordered from top to bottom.
class Solution:
def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return root
level_node_vals_rightmost = []
queue = deque([root])
while queue:
level_node_vals_rightmost.append(queue[-1].val)
num_nodes_this_level = len(queue)
for _ in range(num_nodes_this_level):
node = queue.popleft()
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return level_node_vals_rightmost
The strategy above is fairly simple — execute a left-to-right BFS traversal and pick off the rightmost node value in the queue before expanding to the next level.
LC 515. Find Largest Value in Each Tree Row (✓)
Given the root
of a binary tree, return an array of the largest value in each row of the tree (0-indexed).
class Solution:
def largestValues(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
max_level_vals = []
queue = deque([root])
while queue:
num_nodes_this_level = len(queue)
level_max = float('-inf')
for _ in range(num_nodes_this_level):
node = queue.popleft()
level_max = max(level_max, node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
max_level_vals.append(level_max)
return max_level_vals
Perform a level-order traversal where we push to the answer array the maximum value of each level.
LC 1302. Deepest Leaves Sum (✓)
Given the root
of a binary tree, return the sum of values of its deepest leaves.
class Solution:
def deepestLeavesSum(self, root: Optional[TreeNode]) -> int:
level_sum = 0
queue = deque([root])
while queue:
num_nodes_this_level = len(queue)
curr_level_sum = 0
for _ in range(num_nodes_this_level):
node = queue.popleft()
curr_level_sum += node.val
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
level_sum = curr_level_sum
return level_sum
Accumulate the sum for every single level — the last sum remaining will be the sum of all the nodes in the last level.
LC 103. Binary Tree Zigzag Level Order Traversal (✓)
Given a binary tree, return the zigzag level order traversal of its nodes' values. (ie, from left to right, then right to left for the next level and alternate between).
For example: Given binary tree [3,9,20,null,null,15,7]
,
3
/ \
9 20
/ \
15 7
return its zigzag level order traversal as:
[
[3],
[20,9],
[15,7]
]
class Solution:
def zigzagLevelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
if not root:
return root
LEFT_RIGHT = True
node_vals = []
queue = deque([root])
while queue:
num_nodes_this_level = len(queue)
level_node_vals = []
for _ in range(num_nodes_this_level):
node = queue.popleft()
level_node_vals.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
if not LEFT_RIGHT:
left = 0
right = len(level_node_vals) - 1
while left < len(level_node_vals) // 2:
level_node_vals[left], level_node_vals[right] = level_node_vals[right], level_node_vals[left]
left += 1
right -= 1
node_vals.append(level_node_vals)
LEFT_RIGHT = not LEFT_RIGHT
return node_vals
There are several ways to try to solve this problem, but each approach seems to involve something somewhat unnatural:
- we could use a deque for each level's values where we append to the right or append to the left depending on the level (but most BFS problems aren't supposed to explicitly rely on using deques)
- we could treat the values accumulated for each level as a stack and pop the values from the stack into another list when a right to left order is desired (but this is expensive for time and space)
- and so on
The solution above explicitly reverses a level's node values in-place, if needed. The additional space requirement is minimal since the reversal is in-place. The additional time required is also somewhat minimal since we only iterate over half the length of a level's values (the multi-deque approach avoids this additional time cost, but the use of a deque for each level seems quite unnatural).
Induction (solve subtrees recursively, aggregate results at root)
Remarks
Core idea: Solve the problem at the subtrees recursively, and then aggregate the results at the root.
This template is for problems where the solutions for the subtrees is enough to find the solution for the entire tree. We call it induction because we treat each node as the root of its own subtree, and each subtree has its own solution (we "forget" that there is more tree above it). However, sometimes the solutions for the subtrees are not enough to find the solution for the entire tree, so this template can be too limiting, in which case we move to the traverse-and-accumulate template.
Note: This template effectively uses a post-order traversal since the root is only being processed in the return value when root.val
is reported.
Consider the problem of finding the maximum difference between any two values in a binary tree. We cannot answer this question using just the induction template. Why? Because the smallest and largest node values could reside in different subtrees (at different levels). Using the induction tempalte, there's not an effective way to manage this information.
We need something more, namely the traverse-and-accumulate method where we can traverse the entire tree, accumulating the maximum and minimum node values along the way. Our final step would be to report the difference between these values.
def solution(root):
if not root:
return ...
res_left = solution(root.left)
res_right = solution(root.right)
# return a value computed via res_left, res_right, and root.val
return ...
Examples
Number of leaves in a binary tree (not on LeetCode)
def num_leaves(root):
if not root:
return 0
if not root.left and not root.right:
return 1
left = num_leaves(root.left)
right = num_leaves(root.right)
return left + right
A non-existent node should not count anything towards the overall number of leaf nodes. Return 0
for non-existent nodes. If we encounter a leaf node, then we will return 1
in order to include that number in the overall aggregated result.
Test for value in a binary tree (not on LeetCode)
def has_value(root, target):
if not root:
return False
if root.val == target:
return True
left = has_value(root.left, target)
right = has_value(root.right, target)
return left or right
Calculate tree size (not on LeetCode)
def tree_size(root):
if not root:
return 0
left = tree_size(root.left)
right = tree_size(root.right)
return 1 + left + right
Count the total number of nodes in the tree by counting the nodes in each subtree and then add 1
for the root (this means we're always adding 1
for each node we encounter since each node encountered is treated as the root of its own subtree).
Find the maximum value in a binary tree (not on LeetCode)
def max_tree_val(root):
if not root:
return float('-inf')
left = max_tree_val(root.left)
right = max_tree_val(root.right)
return max(root.val, left, right)
This is also a situation where it's completely possible and natural, albeit unnecessary, to find the maximum by "accumulating" the result in a max_val
non-local variable which we update whenever we find a new maximum value (i.e., we're basically doing a for loop through all the nodes in the tree):
def max_tree_val(root):
max_val = root.val
def visit(node):
if not node:
return float('-inf')
nonlocal max_val
max_val = max(max_val, node.val)
visit(node.left)
visit(node.right)
visit(root)
return max_val
LC 104. Maximum Depth of Binary Tree (✓)
Given the root
of a binary tree, return its maximum depth.
A binary tree's maximum depth is the number of nodes along the longest path from the root node down to the farthest leaf node.
Approach 1 (no helper function, aggregated total height)
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
left = self.maxDepth(root.left)
right = self.maxDepth(root.right)
return 1 + max(left, right)
Solving the tree problem inductively means looking at the left and right children of root
as roots of their own subtrees. Looking at the leaves will always be a hint as to how you should handle base cases. What should happen as soon as we hit a leaf? What is the height of a leaf node? The height should be 1
since the height of a leaf node really just includes the leaf node itself since it doesn't have any children.
The idea is to always add 1
to the height of a result in order to account for the current node — this includes leaf nodes. A leaf node's left and right non-existent children will both contribute 0
to its height: 1 + max(0, 0)
. That's the idea in this problem.
Approach 2 (visit helper function with accumulated max height)
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
def visit(node, height):
if not node:
return 0
if not node.left and not node.right:
return height
return max(visit(node.left, height + 1), visit(node.right, height + 1))
return visit(root, 1)
The solution above is very similar to how extra information can be encoded when performing DFS or BFS on a graph; that is, especially when dealing with matrices, we're often storing the location of a cell as a 2-tuple on the stack or queue in the form (row, col)
, but sometimes it is quite helpful to store additional information. Maybe it's height, as in this example. Maybe it's the maximum depth reached so far. The 2-tuple could then become a 3-tuple, 4-tuple, etc. Whatever the case, the core idea is that we encode information along with whatever atomic element is being processed (e.g., cell in a matrix, node in a tree, etc.).
How is this relevant here? Well, our visit
function for traversing the tree can accept more than just the node it is going to process — it can also accept a height
. Above, maxHeight
is basically playing the role of the visit
function. The following LeetCode solution works and illustrates this idea:
LC 112. Path Sum (✓)
Given the root
of a binary tree and an integer targetSum
, return true
if the tree has a root-to-leaf path such that adding up all the values along the path equals targetSum
.
A leaf is a node with no children.
class Solution:
def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
def visit(node, sum_so_far):
if not node:
return False
sum_so_far += node.val
if not node.left and not node.right:
return sum_so_far == targetSum
return visit(node.left, sum_so_far) or visit(node.right, sum_so_far)
return visit(root, 0)
The intuition here is that we're basically building a sum from the root down to a leaf; hence, it's helpful to send summation information down the tree from parents to children, which we can accomplish by using function parameters, specifically sum_so_far
in the solution above.
LC 965. Univalued Binary Tree
A binary tree is univalued if every node in the tree has the same value.
Return true
if and only if the given tree is univalued.
class Solution:
def isUnivalTree(self, root: Optional[TreeNode]) -> bool:
unival = root.val
def visit(node):
if not node:
return True
left = visit(node.left)
right = visit(node.right)
return node.val == unival and left and right
return visit(root)
Note that the nonlocal
keyword does not need to be used because we're not updating unival
from within the visit
function.
LC 94. Binary Tree Inorder Traversal
Given the root
of a binary tree, return the inorder traversal of its nodes' values.
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
left = self.inorderTraversal(root.left)
right = self.inorderTraversal(root.right)
return left + [root.val] + right
The approach above is inductive — each node passes to its parent the list of nodes in its subtree. But this means each node creates its own list by copying and concatenating the lists of its children, a questionable use of space for this problem. We can avoid the copying of intermediate results by using a "global list" to accumulate nodes as they are visited in the order in which they are visited (in-order in this case):
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
node_vals = []
def visit(node):
if not node:
return
visit(node.left)
node_vals.append(node.val)
visit(node.right)
visit(root)
return node_vals
LC 144. Binary Tree Preorder Traversal
Given the root
of a binary tree, return the preorder traversal of its nodes' values.
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
left = self.preorderTraversal(root.left)
right = self.preorderTraversal(root.right)
return [root.val] + left + right
The approach above is inductive — each node passes to its parent the list of nodes in its subtree. But this means each node creates its own list by copying and concatenating the lists of its children, a questionable use of space for this problem. We can avoid the copying of intermediate results by using a "global list" to accumulate nodes as they are visited in the order in which they are visited (pre-order in this case):
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
node_vals = []
def visit(node):
if not node:
return
node_vals.append(node.val)
visit(node.left)
visit(node.right)
visit(root)
return node_vals
LC 145. Binary Tree Postorder Traversal
Given the root
of a binary tree, return the postorder traversal of its nodes' values.
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
left = self.postorderTraversal(root.left)
right = self.postorderTraversal(root.right)
return left + right + [root.val]
The approach above is inductive — each node passes to its parent the list of nodes in its subtree. But this means each node creates its own list by copying and concatenating the lists of its children, a questionable use of space for this problem. We can avoid the copying of intermediate results by using a "global list" to accumulate nodes as they are visited in the order in which they are visited (post-order in this case):
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
node_vals = []
def visit(node):
if not node:
return
visit(node.left)
visit(node.right)
node_vals.append(node.val)
visit(root)
return node_vals
LC 226. Invert Binary Tree
Given the root
of a binary tree, invert the tree, and return its root.
class Solution:
def invertTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
if not root:
return
root.left, root.right = root.right, root.left
self.invertTree(root.left)
self.invertTree(root.right)
return root
The idea here is to swap the children of the root (of any subtree we're referencing), and then let recursion take care of the subtrees. The order could be pre-order (as above) or it could be post-order, but in-order would cause issues because we would be processing a node between its children when what we really want to do is process the node before or after its children (so the children can be processed/inverted simultaneously).
We also need to be somewhat mindful of not making the following mistake:
class Solution:
def invertTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
if not root:
return
left = root.left
right = root.right
left, right = right, left
self.invertTree(left)
self.invertTree(right)
return root
The code above does not end up inverting anything at all. Why? The reason is because this is like a linked list problem in terms of reference/pointer manipulation. When we performed the inversion in the first solution, we changed the references for what root.left
and root.right
were actually pointing to; that is, the multi-assignment root.left, root.right = root.right, root.left
means that, for any given root
, the left subtree rooted at root.left
has been reassigned to be root.right
(the right subtree rooted at root.right
); similarly, the right subtree rooted at root.right
has been reassigned to be root.left
(the left subtree root at root.left
).
Hence, the left
and right
subtree references for root
are changed (i.e., inverted) for each recursive call. This is not the case for the "dereferenced" code above, where we're not actually changing the references at all. We swap what left
and right
point to in the highlighted code, but we don't actually change the root
attributes of root.left
and root.right
, which is the desired effect in this problem.
LC 701. Insert into a Binary Search Tree★★
You are given the root
node of a binary search tree (BST) and a value
to insert into the tree. Return the root node of the BST after the insertion. It is guaranteed that the new value does not exist in the original BST.
Notice that there may exist multiple valid ways for the insertion, as long as the tree remains a BST after insertion. You can return any of them.
class Solution:
def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
if not root:
return TreeNode(val)
if val > root.val:
root.right = self.insertIntoBST(root.right, val)
if val < root.val:
root.left = self.insertIntoBST(root.left, val)
return root
The inductive solution for this problem, presented above, requires some creativity. We essentially create a new node with the given value and place it as one of the current missing nodes in the BST — but where are we supposed to put it? We can use a similar strategy as that for searching for a value: if the new node's value is larger than the root node's value, then we need to insert the new node into the right subtree (the same logic applies to smaller values needing to be inserted into the left subtree).
The "traverse and accumulate" alternative is arguably easier to envisage:
class Solution:
def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
if not root:
return TreeNode(val)
ref_node_found = False
def visit(node):
nonlocal ref_node_found
if not node or ref_node_found:
return
if not node.left and val < node.val:
node.left = TreeNode(val)
ref_node_found = True
if not node.right and val > node.val:
node.right = TreeNode(val)
ref_node_found = True
if node.val < val:
visit(node.right)
else:
visit(node.left)
visit(root)
return root
LC 1448. Count Good Nodes in Binary Tree (✓) ★★
Given a binary tree root
, a node X
in the tree is named good if in the path from root to X
there are no nodes with a value greater than X
.
Return the number of good nodes in the binary tree.
class Solution:
def goodNodes(self, root: TreeNode) -> int:
def visit(node, max_so_far):
if not node:
return 0
left = visit(node.left, max(max_so_far, node.val))
right = visit(node.right, max(max_so_far, node.val))
ans = left + right
if node.val >= max_so_far:
ans += 1
return ans
return visit(root, float("-inf"))
The inductive solution for this problem, provided above, is arguably more difficult to come up with than its "traverse and accumulate" alternative (provided below). But both approaches use the same critical idea, namely passing down the maximum value of a node encountered on the path so far to determine whether or not the current node value exceeds or equals that value (in which case the current node is a good node).
This is a good problem for the induction template though because we can definitely solve it by amassing all the good nodes on a subtree by subtree basis, accumulating the final value in the root. The solution above makes use of a post-order traversal to do this, where we start adding values to the overall answer once we hit a leaf node (the overall maximum for that path is recorded in the max_so_far
variable). This is different from how the number of good nodes is accumulated using the traverse and accumulate approach where a pre-order traversal is used:
class Solution:
def goodNodes(self, root: TreeNode) -> int:
good_nodes = 0
def visit(node, max_so_far):
nonlocal good_nodes
if not node:
return
max_so_far = max(max_so_far, node.val)
if node.val >= max_so_far:
good_nodes += 1
visit(node.left, max_so_far)
visit(node.right, max_so_far)
visit(root, root.val)
return good_nodes
LC 100. Same Tree (✓)
Given the roots of two binary trees p
and q
, write a function to check if they are the same or not.
Two binary trees are considered the same if they are structurally identical, and the nodes have the same value.
class Solution:
def isSameTree(self, p: Optional[TreeNode], q: Optional[TreeNode]) -> bool:
if not p and not q:
return True
if not p or not q or p.val != q.val:
return False
left = self.isSameTree(p.left, q.left)
right = self.isSameTree(p.right, q.right)
return left and right
This is a great problem to solve with the induction template — the core idea is that if two trees are the same then their subtrees must also be the same. The recursive solution provides a natural way of solving this problem — return false if we ever encounter a condition that indicates (sub)trees are not the same (i.e., dissimilar missing nodes or unequal values). We return true if neither node exists, and this will be the terminating condition as we keep drilling down into the tree.
LC 236. Lowest Common Ancestor of a Binary Tree (✓) ★★★
Given a binary tree, find the lowest common ancestor (LCA) of two given nodes in the tree.
According to the definition of LCA on Wikipedia: "The lowest common ancestor is defined between two nodes p
and q
as the lowest node in T
that has both p
and q
as descendants (where we allow a node to be a descendant of itself)."
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
if not root:
return None
if root == p or root == q:
return root
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
if left and right:
return root
return left if left else right
This problem is a bit of a doozy if you have not yet seen it. Adding some code comments can help a great deal:
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
# non-existent node cannot be LCA
if not root:
return None
# current node is p or q -- return it because it could be the LCA (pre-order at this point)
# stop searching further down this branch because the LCA cannot be deeper
# any lower node along this branch would not be an ancestor to both p and q
if root == p or root == q:
return root
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
# all child nodes of current node have been visited (post-order at this point)
# left/right will only hold non-null values if p or q (or both) were encountered
# (as children of the current node)
# if left AND right are non-null, then LCA must be current node
# (it serves as the connector between the two subtrees containing p and q)
if left and right:
return root
# both target nodes were not found in the current node's subtrees
# either one target was found (return that node) or no target was found
# (arbitrarily return right which will equal None)
return left if left else right
Intuition for this problem can be gained by considering what properties the LCA must satisfy and how this fits in with our traversal strategy. Specifically, as we progress down a branch (i.e., consider pre-order logic), if we encounter a target node, then we should return it immediately because further exploring that branch serves no purpose — the LCA cannot possibly be at a greater depth than the current node (if it were, then it would exclude the current node which is a target node).
If the current node is not a target node, then we should keep exploring (i.e., we should explore the left and right subtrees of the current node). Specifically, it would help to have information about the child nodes of the current node being processed — we consider some post-order logic. If we explored the left and right branches of the current node and we found one target node in the left branch and another target node in the right branch, then this means the current node is the LCA and we should return it (it serves as the connecting node between the branches containing the targets).
What are the options at this point for the current node?
- If we made it to the point of exploring the current node's left and right subtrees, then we know the current node is not one of the target nodes. (lines
10
-11
) - If our visits to the left and right subtrees of the current node did not both turn up successful searches, then the current node is not the LCA. (lines
21
-22
) - The only possibilities remaining are that a target node was found in one of the current node's subtrees or no target node was found at all. We return the target node if it was found; otherwise, we just arbitrarily return
right
, which will equalNone
. The last line of the solution above could just as well bereturn right if right else left
to capture this logic.
It can be kind of difficult to imagine all of the logic above and how it actually looks when executed. To clarify things a bit, consider the following tree where we want to find the LCA of the nodes with values 6
and 4
:
______3__
/ \
5__ 1
/ \ / \
6 2 0 8
/ \
7 4
We can see from the tree display above that the answer will be 5
. But how does the logic in our solution actually unfold? The following image may help (nodes are in green; left
and right
value resolutions, L
and R
, respectively, are in blue, where N
represents None
; red numbers above each value resolution indicate the order in which that value resolution was made):
The image makes it clear how we do not continue processing a branch once a target node is found along that branch. Unfortunately, there's a clear inefficiency highlighted in the process pictured above — we still process the overall root's entire right subtree even though it's clearly not possible for the LCA to exist within it since both target nodes have already been found. Fixing this inefficiency would require a good bit more effort, but is worth considering at some point. The main goal of the picture is to illustrate how searches are executed and terminated and how values found are propagated back up the tree.
LC 111. Minimum Depth of Binary Tree (✓) ★★
Given a binary tree, find its minimum depth.
The minimum depth is the number of nodes along the shortest path from the root node down to the nearest leaf node.
Note: A leaf is a node with no children.
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
if not root.left:
return 1 + self.minDepth(root.right)
elif not root.right:
return 1 + self.minDepth(root.left)
return 1 + min(self.minDepth(root.left), self.minDepth(root.right))
The inductive approach for this problem is not the easiest to come up with at first. The idea is that if the current node is missing its left child, then we should explore the right branch. If the current node is missing its right child, then we should explore the left branch. If neither child nodes are missing, then we should explore both branches. Whatever the case, the branch(es) we go down should have 1
added to it to account for the current node's depth.
The "traverse and accumulate" approach is easier to come up with but a bit of a cheat:
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
min_leaf_depth = float('inf')
def visit(node, curr_depth):
if not node and curr_depth > min_leaf_depth:
return
curr_depth += 1
nonlocal min_leaf_depth
if not node.left and not node.right:
min_leaf_depth = min(min_leaf_depth, curr_depth)
visit(node.left, curr_depth)
visit(node.right, curr_depth)
visit(root, 0)
return min_leaf_depth
LC 1026. Maximum Difference Between Node and Ancestor (✓)
Given the root
of a binary tree, find the maximum value V
for which there exist different nodes A
and B
where V = |A.val - B.val|
and A
is an ancestor of B
.
A node A
is an ancestor of B
if either: any child of A
is equal to B
, or any child of A
is an ancestor of B
.
class Solution:
def maxAncestorDiff(self, root: Optional[TreeNode]) -> int:
def visit(node, max_path_val, min_path_val, max_diff_so_far):
if not node:
return max_path_val - min_path_val
max_path_val = max(max_path_val, node.val)
min_path_val = min(min_path_val, node.val)
left = visit(node.left, max_path_val, min_path_val, max_diff_so_far)
right = visit(node.right, max_path_val, min_path_val, max_diff_so_far)
return max(left, right)
return visit(root, float('-inf'), float('inf'), float('-inf'))
We're guaranteed at least two nodes which means we don't have to worry about edge cases as much. Our goal is basically to keep track of each path's maximum value as well as its minimum value because the maximum difference will be obtained by subtracting the minimum node value from the maximum node value.
LC 938. Range Sum of BST (✓)
Given the root
node of a binary search tree, return the sum of values of all nodes with a value in the range [low, high]
.
class Solution:
def rangeSumBST(self, root: Optional[TreeNode], low: int, high: int) -> int:
if not root:
return 0
ans = 0
if low <= root.val <= high:
ans += root.val
if root.val > low:
ans += self.rangeSumBST(root.left, low, high)
if root.val < high:
ans += self.rangeSumBST(root.right, low, high)
return ans
We add the current node's value to the final answer in the pre-order stage of the traversal if the value falls in the [low, high]
interval. How should we strategically visit the other subtrees though? We should make use of the fact that the tree is a BST. If the current node's value is less than low
, then looking at the left subtree would be pointless because all of its values are less than the current node (because the tree is a BST); similarly, if the current node's value is greater than high
, then looking at the right subtree would be pointless becall all of its values are greater than the current node (because the tree is a BST).
Great, so we know what not to do, but what should we do? If the current value is greater than low
, then smaller values than the current value might be able to contribute to the range sum as well; hence, we should explore the left subtree. Similarly, if the current value is less than high
, then larger values than the current value might be able to contribute to the range sum as well; hence, we should explore the right subtree. This lets us take advantage of the BST properties of the tree while efficiently performing a DFS traversal.
The solution above works in terms of how the answer is accumulate because we're only ever adding non-zero values if the value is in the range [low, high]
or 0
otherwise. Thus, the final answer returned will be the desired range sum.
Traverse-and-accumulate (visit nodes and accumulate information in nonlocal variables)
Remarks
Core idea: Visit all the nodes with a traversal and accumulate the wanted information in a nonlocal variable.
If we need to accumulate some global information about the entire tree, then we can use the this template to facilitate that process. This template is like doing a for loop through all the nodes, which is often very convenient. It would be great to be able to simply do
initialize some data
for node in tree:
do_something(node)
The traverse-and-accumulate template is basically a way to achieve this. It doesn't look like a for loop, because it uses recursion, but it can be used like a for loop. Furthermore, we can essentially do a for loop through the nodes in different orders depending on the traversal.
The parameter of the recursive function in the induction template is called root
. This makes sense because
each node, when visited, is treated as the root of its own subtree. However, when using the
traverse-and-accumulate template, the parameter is called node
instead of root
. This is
because we think of it as just a for loop through the nodes, and we wouldn't do for root in tree: ...
.
The induction and traverse-and-accumulate templates can be mixed together with pre/in/postorder traversals on occasion, depending on the problem (such as LC 543. Diameter of Binary Tree).
Note (traversal ordering): The traverse-and-accumulate template, as presented below, uses a pre-order traversal since the updating of res
(i.e., the processing of node
by the visit
function) occurs before processing the left or right subtrees with visit(node.left)
and visit(node.right)
, respectively. We can modify the order of the traversal based on when/where we update res
.
Note (traversal function name): Below, we use the function name visit
to indicate how each node
will be visited, starting from the root
. A DFS traversal is implied, meaning we are going to use a pre-order, post-order, or in-order traversal of the tree to obtain our desired result. The contents of visit
will make clear which traversal is being used. While visit
is the function name used below, it's somewhat common for people to use dfs
, process
, or some other function name instead.
def solution(root):
res = ... # initial value
def visit(node):
if not node:
return
nonlocal res
res = ... # update res here
visit(node.left)
visit(node.right)
visit(root)
return res
Examples
Maximum difference between two nodes in a binary tree (not on LeetCode)
def max_diff(root):
if not root:
return 0
min_val = float('inf')
max_val = float('-inf')
def visit(node):
if not node:
return
nonlocal min_val, max_val
min_val = min(min_val, node.val)
max_val = max(max_val, node.val)
visit(node.left)
visit(node.right)
visit(root)
return max_val - min_val
The largest difference between nodes could be between nodes deeper down in the left and right subtrees of the tree's overall root; that is, we cannot effectively determine the answer for the whole tree from the answers for the subtrees. The induction template is thus not applicable. We can traverse and accumulate the overall maximum and minimum node values in non-local variables min_val
and max_val
. We report the answer after a full traversal by returning the difference between these values.
Longest unival vertical path (not on LeetCode)
def longest_vertical_path(root):
longest_path = 0
def visit(node, curr_path_val, curr_path_length):
if not node:
return
nonlocal longest_path
if node.val == curr_path_val:
curr_path_length += 1
longest_path = max(longest_path, curr_path_length)
else:
curr_path_length = 0
visit(node.left, node.val, curr_path_length)
visit(node.right, node.val, curr_path_length)
visit(root, root.val, 0)
return longest_path
The idea is to keep track of the longest univalue vertical path found so far by storing it in a nonlocal variable, longest_path
. To calculate the longest vertical path for any given node, we pass that node's value down the tree as well as the current path length where that is the only value encountered thus far.
Find mode of a binary tree (not on LeetCode)
def tree_mode(root):
freqs = defaultdict(int)
max_freq = 0
mode = -1
def visit(node):
if not node:
return
nonlocal mode, max_freq
freqs[node.val] += 1
curr_freq = freqs[node.val]
max_freq = max(max_freq, curr_freq)
if curr_freq == max_freq:
mode = node.val
visit(root)
return mode
Since it's possible the mode may be a value that appears once or more in both subtrees, the induction template is not enough here. The approach above makes use of a hash map as a frequency lookup to progressively determine what the mode will be. It's also possible to determine the mode by passing information down the tree (although this approach is arguably less clear than the approach above):
def tree_mode(root):
freqs = defaultdict(int)
def visit(node, mode_so_far):
if not node:
return
freqs[node.val] += 1
mode_so_far = max(freqs[mode_so_far], freqs[node.val])
visit(node.left, mode_so_far)
visit(node.right, mode_so_far)
return mode_so_far
return visit(root, root.val)
LC 94. Binary Tree Inorder Traversal
Given the root
of a binary tree, return the inorder traversal of its nodes' values.
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
node_vals = []
def visit(node):
if not node:
return
visit(node.left)
node_vals.append(node.val)
visit(node.right)
visit(root)
return node_vals
LC 144. Binary Tree Preorder Traversal
Given the root
of a binary tree, return the preorder traversal of its nodes' values.
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
node_vals = []
def visit(node):
if not node:
return
node_vals.append(node.val)
visit(node.left)
visit(node.right)
visit(root)
return node_vals
LC 145. Binary Tree Postorder Traversal
Given the root
of a binary tree, return the postorder traversal of its nodes' values.
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
node_vals = []
def visit(node):
if not node:
return
visit(node.left)
visit(node.right)
node_vals.append(node.val)
visit(root)
return node_vals
LC 98. Validate Binary Search Tree★
Given the root
of a binary tree, determine if it is a valid binary search tree (BST).
A valid BST is defined as follows:
- The left subtree of a node contains only nodes with keys less than the node's key.
- The right subtree of a node contains only nodes with keys greater than the node's key.
- Both the left and right subtrees must also be binary search trees.
class Solution:
def isValidBST(self, root: Optional[TreeNode]) -> bool:
is_bst = True
prev = float('-inf')
def visit(node):
nonlocal is_bst, prev
if not node or not is_bst:
return
visit(node.left)
if prev >= node.val:
is_bst = False
prev = node.val
visit(node.right)
visit(root)
return is_bst
The solution above is slick in terms of how it uses prev
to bypass adding a bunch of space overhead to the solution. It's sort of "linked list-ish" in nature in terms of how the prev
"pointer" is being used and updated. A more obvious solution that results in adding a bunch of space is as follows (this just assembles the array of node values and we check to see if it is sorted or not — both solutions are for time and space, but the solution above is more elegant):
class Solution:
def isValidBST(self, root: Optional[TreeNode]) -> bool:
node_vals = []
def visit(node):
if not node:
return
visit(node.left)
node_vals.append(node.val)
visit(node.right)
visit(root)
for i in range(1, len(node_vals)):
if node_vals[i - 1] >= node_vals[i]:
return False
return True
LC 530. Minimum Absolute Difference in BST
Given a binary search tree with non-negative values, find the minimum absolute difference between values of any two nodes.
class Solution:
def getMinimumDifference(self, root: Optional[TreeNode]) -> int:
min_diff = float('inf')
prev = float('-inf')
def visit(node):
if not node:
return
visit(node.left)
nonlocal min_diff, prev
min_diff = min(min_diff, node.val - prev)
prev = node.val
visit(node.right)
return node.val
visit(root)
return min_diff
The key realization here is that an in-order traversal gives us the node values in sorted ascending order. Hence, we can make use of a prev
variable to store the value of previous nodes and we compare adjacent nodes as we go, keeping track of the minimum difference encountered, min_diff
, along the way. In the code above, note that min_diff
and prev
are initialized in ways that ensure calculations are meaningful for two or more nodes, which we're guaranteed to have in this problem.
LC 1038. Binary Search Tree to Greater Sum Tree
Given the root
of a Binary Search Tree (BST), convert it to a Greater Tree such that every key of the original BST is changed to the original key plus sum of all keys greater than the original key in BST.
As a reminder, a binary search tree is a tree that satisfies these constraints:
- The left subtree of a node contains only nodes with keys less than the node's key.
- The right subtree of a node contains only nodes with keys greater than the node's key.
- Both the left and right subtrees must also be binary search trees.
Note: This question is the same as 538: https://leetcode.com/problems/convert-bst-to-greater-tree/
class Solution:
def bstToGst(self, root: TreeNode) -> TreeNode:
sum_so_far = 0
def visit(node):
if not node:
return
visit(node.right)
nonlocal sum_so_far
sum_so_far += node.val
node.val = sum_so_far
visit(node.left)
visit(root)
return root
First recall that a conventional (i.e., left to right) in-order traversal of a BST results in traversing the nodes in sorted ascending order. Sometimes, like in this problem, it helps to execute an in-order traversal from right to left, which will yield values in sorted descending order. This realization makes this problem much easier to approach. Execute a right to left in-order traversal and update each node's value to sum_so_far
, an accumulated sum of all node values reached thus far.
LC 700. Search in a Binary Search Tree
You are given the root
of a binary search tree (BST) and an integer val
.
Find the node in the BST that the node's value equals val
and return the subtree rooted with that node. If such a node does not exist, return null
.
class Solution:
def searchBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
target_node = None
def visit(node):
nonlocal target_node
if not node or target_node:
return
if node.val > val:
visit(node.left)
elif node.val < val:
visit(node.right)
else:
target_node = node
visit(root)
return target_node
This problem has an easy solution if you don't take advantage of the fact that the tree is a BST:
class Solution:
def searchBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
target_node = None
def visit(node):
nonlocal target_node
if not node or target_node:
return
if node.val == val:
target_node = node
visit(node.left)
visit(node.right)
visit(root)
return target_node
But that's not the point of the problem. Only a small adjustment needs to be made in order to really take advantage of the fact that the tree is a BST. Only visit a node in a subsequent subtree if it's possible for that subtree to have the node.
LC 701. Insert into a Binary Search Tree
You are given the root
node of a binary search tree (BST) and a value
to insert into the tree. Return the root node of the BST after the insertion. It is guaranteed that the new value does not exist in the original BST.
Notice that there may exist multiple valid ways for the insertion, as long as the tree remains a BST after insertion. You can return any of them.
class Solution:
def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
if not root:
return TreeNode(val)
ref_node_found = False
def visit(node):
nonlocal ref_node_found
if not node or ref_node_found:
return
if not node.left and val < node.val:
node.left = TreeNode(val)
ref_node_found = True
if not node.right and val > node.val:
node.right = TreeNode(val)
ref_node_found = True
if node.val < val:
visit(node.right)
else:
visit(node.left)
visit(root)
return root
The idea in the solution above is to traverse the BST by taking advantage of its BST nature — we only consider inserting the node when there's a natural opportunity to do so (i.e., when the current node is missing its left or right child). We use ref_node_found
to optimize for an early return.
The inductive alternative for solving this problem is more arguably more elegant but harder to envision at first:
class Solution:
def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
if not root:
return TreeNode(val)
if val > root.val:
root.right = self.insertIntoBST(root.right, val)
if val < root.val:
root.left = self.insertIntoBST(root.left, val)
return root
LC 270. Closest Binary Search Tree Value★
Given the root
of a binary search tree and a target
value, return the value in the BST that is closest to the target
.
class Solution:
def closestValue(self, root: Optional[TreeNode], target: float) -> int:
closest = root.val
curr_diff = abs(closest - target)
def visit(node):
nonlocal closest, curr_diff
if not node:
return
diff = abs(node.val - target)
if diff <= curr_diff:
if diff != curr_diff:
curr_diff = diff
closest = node.val
else:
closest = min(closest, node.val)
if target > node.val:
visit(node.right)
elif target < node.val:
visit(node.left)
else:
closest = node.val
visit(root)
return closest
The solution above is, oddly enough, probably the easiest of the approaches to come up with where we're both taking advantage of the BST tree structure as well as not allocating additional space beyond the call stack for the recursion.
If, however, we allow ourselves the freedom to perform an in-order traversal where we just assemble a sorted array ( additional space to store all the node values), then we can easily iterate through the sorted array to find the closest value:
class Solution:
def closestValue(self, root: TreeNode, target: float) -> int:
def inorder(node):
return inorder(node.left) + [node.val] + inorder(node.right) if node else []
return min(inorder(root), key = lambda x: abs(target - x))
This is cute and short but not particularly clever since we create an array the full size of the tree just to store the node values when that's really unnecessary.
LC 1448. Count Good Nodes in Binary Tree (✓)
Given a binary tree root
, a node X
in the tree is named good if in the path from root to X
there are no nodes with a value greater than X
.
Return the number of good nodes in the binary tree.
class Solution:
def goodNodes(self, root: TreeNode) -> int:
good_nodes = 0
def visit(node, max_so_far):
nonlocal good_nodes
if not node:
return
max_so_far = max(max_so_far, node.val)
if node.val >= max_so_far:
good_nodes += 1
visit(node.left, max_so_far)
visit(node.right, max_so_far)
visit(root, root.val)
return good_nodes
We traverse the tree and accumulate the number of good nodes in the nonlocal good_nodes
variable — keeping track of the max_so_far
for the maximum node value in a path is how we determine for each node we encounter whether or not that node should be considered good. We use a pre-order traversal to accomplish this.
The induction alternative is also quite possible but arguably more difficult to come up with at first (it uses a post-order traversal and starts adding values to the total number of good nodes once we hit a leaf and then adds values as we backtrack back up the path):
class Solution:
def goodNodes(self, root: TreeNode) -> int:
def visit(node, max_so_far):
if not node:
return 0
left = visit(node.left, max(max_so_far, node.val))
right = visit(node.right, max(max_so_far, node.val))
ans = left + right
if node.val >= max_so_far:
ans += 1
return ans
return visit(root, float("-inf"))
LC 111. Minimum Depth of Binary Tree (✓)
Given a binary tree, find its minimum depth.
The minimum depth is the number of nodes along the shortest path from the root node down to the nearest leaf node.
Note: A leaf is a node with no children.
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
min_leaf_depth = float('inf')
def visit(node, curr_depth):
if not node and curr_depth > min_leaf_depth:
return
curr_depth += 1
nonlocal min_leaf_depth
if not node.left and not node.right:
min_leaf_depth = min(min_leaf_depth, curr_depth)
visit(node.left, curr_depth)
visit(node.right, curr_depth)
visit(root, 0)
return min_leaf_depth
The approach above is really quite simple. The pure inductive approach is a bit harder to come up with but rather elegant:
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
if not root.left:
return 1 + self.minDepth(root.right)
elif not root.right:
return 1 + self.minDepth(root.left)
return 1 + min(self.minDepth(root.left), self.minDepth(root.right))
LC 199. Binary Tree Right Side View (✓)
Given the root
of a binary tree, imagine yourself standing on the right side of it, return the values of the nodes you can see ordered from top to bottom.
class Solution:
def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return root
level_node_vals_rightmost = []
def visit(node, level):
if not node:
return
if level == len(level_node_vals_rightmost):
level_node_vals_rightmost.append(node.val)
visit(node.right, level + 1)
visit(node.left, level + 1)
visit(root, 0)
return level_node_vals_rightmost
The BFS approach to this problem is a bit clearer to envision, but a clever DFS solution is also quite possible, as evidenced above. Specifically, this problem is an excellent illustration of where logic in the pre-order stage of a traversal can be very helpful. The right side view dictates that we always report the value of the rightmost node for each level of the tree. We're not guaranteed that each node will have a right child. For some levels, a node's left child may actually be the level's rightmost node whose value we need to report. The key insight is to use a right-to-left traversal where in the pre-order stage we determine whether or not the current node's value should be added to the list we're ultimately trying to return — we cleverly use the length of the list to determine whether or not a node's value should be added.
Combining templates: induction and traverse-and-accumulate
Remarks
TLDR (use template for reference): The points directly below are elaborated on more extensively under the dividing line. Use the provided template for explicit reference. The general concepts are remarked on first and then their applicability to the template specifically is addressed.
- Parent to child data flow: Use recursive call parameters to pass information down the tree from parent to child. This means defining the
visit
function with more than just thenode
parameter (we're not usually allowed to alter thesolution
function signature). The information passed down can be data passed by value or by reference (if the latter, be cautious of when state mutations should not be shared across different branches of the tree). - Child to parent data flow: Use the induction template. This means whatever function is being called recursively must actually return a value. Usually this means finding solutions for the subtrees is enough to find the solution for the entire tree — we solve the problem at the subtrees recursively and then aggregate the results at the root. For a pure induction template answer, this means
solution
returns a value which builds or aggregates solutions from the leaves to the root. If the template usage needs to be mixed, then defining a helper function,visit
, and returning a value from that function will be necessary. - Global access (non-parent-child data flow): Sometimes it is not enough (or overly cumbersome) to strictly communicate between parent and child, and we need to break out of the normal traversal order. We effectively visit all the nodes with a traversal while accumulating the wanted information in a nonlocal variable. If all we need to do is accumulate data in nonlocal variables, then it is unnecessary to return anything from the
visit
function (i.e., a pure usage of the traverse-and-accumulate template); however, if we also need to pass information back up the tree, then we will need to rely on the induction template as well.
Note how everything above is concerned with how node data can flow as opposed to the order in which node data is encountered. The order in which node data is encountered and processed is determined by the various DFS traversals: pre-order, in-order, and post-order. But, in general, the direction of information flow (e.g., downward from parent to child) and the order of node processing (e.g., post-order) are separate aspects to consider when coming up with a tree traversal strategy. These aspects should be combined as needed or appropriate.
In general, which (DFS) tree traversal template you use to solve a problem largely depends on how you need to manage the information or data flow between nodes. Specifically, information can flow in the following ways:
-
Parent to child (recursive call parameters): We use the parameters of the recursive call when we need to pass information down the tree. This means we can visit or process the current
node
(i.e., the "parent") and pass along data we'd like to have access to (via recursive call parameters) when we process its children (i.e.,node.left
andnode.right
).Template usage: This means defining the
visit
function with more than just thenode
parameter. For example, a very rough start of an implementation might look as follows:def visit(node, data_received_from_parent):
# ...
# update/use data_received_from_parent
visit(node.left, data_from_curr_node_to_its_left_child_node)
visit(node.right, data_from_curr_node_to_its_right_child_node)
# ...The data passed from parent to child does not need to be restricted to a single parameter as in the simple illustration above. Multiple parameters could be used depending on the problem.
Examples of potentially meaningful parameters might include data passed by value like
sum_so_far
,curr_path_length
,path_str
, etc. (i.e., data that is immutable and passed by value such as a string, integer, etc.), where we can freely update these values to pass along in subsequent recursive calls without worrying about mutations (i.e., the state of the variable wouldn't be shared across different branches of the tree).The data could also be passed from parent to child by reference such as a list, dictionary, etc.. In such cases, we need to be aware of and make a decision about how we want the state of the referenced data to be managed. By default, the referenced data will be mutated and its state shared across different branches of the tree. Such mutations and state sharing across branches is often undesirable; hence, we need to effectively undo the state changes/mutations after the recursive calls. This will ensure the state is not shared across different branches of the tree. For example, if the data being passed down by reference is a list, and we're appending to the list before the recursive calls, then we should pop from the list after the recursive calls to return the referenced data to its original state before the recursion.
-
Child to parent (induction): We use the induction template when we need to pass information up the tree. We can use the "pure induction template" (i.e., no changes needed) if it suffices to solve the problem at hand by simply solving the problem for the subtrees (i.e., essentially building up the solution from the leaves to the root):
def solution(root):
if not root:
return ...
res_left = solution(root.left)
res_right = solution(root.right)
# return a value computed via res_left, res_right, and root.val
return ...If, however, the problem involves also needing to accumulate information in a nonlocal variable(s), then we'll need to make a slight change to use the induction template properly, namely by creating a helper function,
visit
, and then returning values from within that function:def solution(root):
# ... (accumulation variables)
def visit(node):
if not node:
return ...
# ... (accumulation happens here)
res_left = visit(node.left)
res_right = visit(node.right)
# return a value computed via res_left, res_right, and node.val
return ...
visit(root)
return resWhatever the case, as can be seen above, when passing information up the tree (i.e., when we use any form of the induction template), we need to be returning values from the function that is being called recursively.
-
Global access (traverse-and-accumulate): Sometimes it is not enough (or overly cumbersome) to strictly communicate between parent and child, and we need to break out of the normal traversal order. We effectively visit all the nodes with a traversal while accumulating the wanted information in a nonlocal variable(s). If all we need to do is accumulate data in nonlocal variables, then it is unnecessary to return anything from the
visit
function (i.e., a pure usage of the traverse-and-accumulate template):def solution(root):
res = ... # initial value for accumulation
def visit(node):
if not node:
return # no value needs to be returned
nonlocal res
res = ... # update accumulated value here
visit(node.left)
visit(node.right)
# no return value from visit function
visit(root)
return res # return the accumulated valueIf, however, we also need to pass information back up the tree, then we will need to rely on the induction template as well (note how we now need to return values from within the
visit
function):def solution(root):
res = ... # initial value for accumulation
def visit(node):
if not node:
return ...
nonlocal res
res = ... # update accumulated value here
res_left = visit(node.left)
res_right = visit(node.right)
# return a value computed via res_left, res_right, and node.val
return ...
visit(root)
return res # return accumulated value
All of the observations above lead us to the combined template provided below.
Short version of template
def solution(root):
res = ... # initial value
def visit(node):
if not node:
return ...
nonlocal res
res = ... # update res here
res_left = visit(node.left)
res_right = visit(node.right)
# return a value computed via res_left, res_right, and node.val
return ...
visit(root)
return res
""" Function signature (something we cannot alter but can mimic altering via the visit function) """
def solution(root):
""" Accumulated values (traverse-and-accumulate)"""
acc_1 = ... # accumulated value 1
acc_2 = ... # accumulated value 2
acc_3 = ... # accumulated value 3
""" Pass data down via recursive call params """
def visit(node, data_pass_down_by_val, data_pass_down_by_ref):
if not node:
return ... # return nothing for early termination (traverse-and-accumulate) OR
# return data for base case or return early for termination (induction)
nonlocal acc_1, acc_2, acc_3 # access nonlocal variables for accumulation
acc_x = ... # update accumulated values
# update/use data_pass_down_by_val
# update/use data_pass_down_by_ref
left_subtree = visit(node.left, data_pass_down_by_val, data_pass_down_by_ref)
right_subtree = visit(node.right, data_pass_down_by_val, data_pass_down_by_ref)
# undo mutation to data_pass_down_by_ref
# (assuming the state should not be shared across different branches of the tree)
""" Pass data up (induction) """
return ... # return a value computed via left_subtree, right_subtree, and node.val
""" Execute tree traversal """
# pass info down and up while accumulating (starting at root)
visit(root, init_data_val, init_data_ref)
# return something based on accumulated values or induction result
return acc_x
Examples
Find height of a tree node (not on LeetCode)
def node_height(root, target):
target_height = -1
def visit(node):
if not node:
return -1
left_height = visit(node.left)
right_height = visit(node.right)
height = 1 + max(left_height, right_height)
if node.val == target:
nonlocal target_height
target_height = height
return height
visit(root)
return target_height
This problem assumes our tree is comprised of nodes with unique values. It helps to do a post-order traversal here, where we build the height up from the leaf nodes, and we only update the nonlocal target_height
once the target value has been encountered for the node whose height we're trying to find.
Remember that a node's height is the longest path from the node to a leaf whereas a node's depth is the length of the path from the root of the tree to that node:
LC 543. Diameter of Binary Tree★★
Given the root
of a binary tree, return the length of the diameter of the tree.
The diameter of a binary tree is the length of the longest path between any two nodes in a tree. This path may or may not pass through the root
.
The length of a path between two nodes is represented by the number of edges between them.
class Solution:
def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
diameter = 0
def visit(node):
if not node:
return -1
left_height = visit(node.left)
right_height = visit(node.right)
nonlocal diameter
diameter = max(diameter, left_height + right_height + 2)
curr_height = 1 + max(left_height, right_height)
return curr_height
visit(root)
return diameter
This problem can be surprisingly difficult depending on how you look at it. Some contemplation results in thinking that the solution has to be related to finding the node whose combination of left and right subtree heights is maximal. Hence, much of the problem really boils down to being able to find the height of a subtree, where recall the height is the distance of the longest path from a node to a leaf. This is different than the depth, which always has the root as a reference point:
- depth of a node: number of edges from the root to that node
- height of a node: number of edges from that node to a leaf
- height of the tree: height of the root
It's informative to first figure out how to calculate the height of a node given that node's value and the root of the tree:
def node_height(root, target):
target_height = -1
def visit(node):
if not node:
return -1
left_height = visit(node.left)
right_height = visit(node.right)
height = 1 + max(left_height, right_height)
if node.val == target:
nonlocal target_height
target_height = height
return height
visit(root)
return target_height
The solution for the diameter problem is then strikingly similar, as the solution at the top shows. The problem description notes that the path determining the diameter may or may not pass through the root
, where by root
they're referring to the overall root of the tree. But of course the path determining the diameter must pass through some subtree's root. And that's the point. We want to find the combination of heights for left and right subtrees for any given node, add 2
to get the path length (we add 2
because the current node serves as the subtree's root, which is the connecting point for the left and right subtrees — we must add 2
to account for the edges that connect the left and right subtrees to their root, the current node), and we want to find the combination such that the overall length or edge count is maximal.
LC 563. Binary Tree Tilt★★
Given the root
of a binary tree, return the sum of every tree node's tilt.
The tilt of a tree node is the absolute difference between the sum of all left subtree node values and all right subtree node values. If a node does not have a left child, then the sum of the left subtree node values is treated as 0
. The rule is similar if there the node does not have a right child.
class Solution:
def findTilt(self, root: Optional[TreeNode]) -> int:
tilt = 0
def visit(node):
if not node:
return 0
left_sum = visit(node.left)
right_sum = visit(node.right)
nonlocal tilt
tilt += abs(left_sum - right_sum)
return left_sum + right_sum + node.val
visit(root)
return tilt
This can be a rather difficult problem at first given its unusual framing. Once it's clear what you're actually trying to accomplish, it becomes clear that a post-order traversal is really what's needed. We find the tilt from the leaves up, where each node serves as its own subtree's root.
The primary difficulty is arguably identifying what we need to pass back up the tree. Just passing node.val
back up the tree will not let us accomplish the desired effect (we need a cumulative sum for each subtree as we move up from the leaves, not just individual node values). It's like we basically need to sum all nodes of a subtree and send that back up while we're going back up the tree.
The leaves can provide useful hints for these kinds of problems — if we're at a node that has two children which are both leaves, then how do we effectively capture both of these nodes' values to send back up the tree? We first need to find the tilt that the current node contributes to the overall tilt, and this is always done for the current node by subtracting its left subtree sum from its right subtree sum and adding the absolute difference to the overall tilt. Hence, we effectively need to keep track of all the left and right subtree sums and keep passing this information up the tree.
LC 110. Balanced Binary Tree★★
Given a binary tree, determine if it is height-balanced.
For this problem, a height-balanced binary tree is defined as:
a binary tree in which the left and right subtrees of every node differ in height by no more than 1.
class Solution:
def isBalanced(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
max_height_diff = float('-inf')
def visit(node):
nonlocal max_height_diff
if not node or max_height_diff > 1:
return -1
left_height = visit(node.left)
right_height = visit(node.right)
max_height_diff = max(max_height_diff, abs(left_height - right_height))
height = 1 + max(left_height, right_height)
return height
visit(root)
return max_height_diff < 2
The idea is to use a post-order traversal where you start comparing left and right subtree heights from the bottom up. The nonlocal max_height_diff
variable lets us keep track of the maximum difference in heights we've encountered from any given node. If max_height_diff
ever exceeds 1
, then we simply repurpose the base case of returning -1
as an early return (this is simply an optimization step — the solution works just fine if we remove or max_height_diff > 1
from the base case conditional).
Two pointers
Opposite ends
Remarks
The idea behind the "opposite ends" two pointer template is to move from the extremes (i.e., beginning and end) toward each other. Binary search is a class example of this template in action.
The template guarantees an run time because only iterations of the while
loop may occur — the left
and right
pointers begin units away from each other and move at least one step closer to each other on every iteration. If the work inside each iteration is kept to , then the result will be an run time.
def fn(arr):
left = 0
right = len(arr) - 1
while left < right:
# choose one of the following depending on the problem:
# left += 1
# right -= 1
# increment left AND decrement right: left += 1 AND right -= 1
Examples
Determine if a string is a palindrome (✓)
def is_palindrome(s):
left = 0
right = len(s) - 1
while left < right:
if s[left] != s[right]:
return False
left += 1
right -= 1
return True
Note that for odd-length s
the middle character is not actually processed and that's okay.
Determine if pair of integers sums to target in sorted array of unique integers (✓)
def arr_has_target_sum(sorted_arr, target):
left = 0
right = len(sorted_arr) - 1
while left < right:
candidate = sorted_arr[left] + sorted_arr[right]
if candidate == target:
return True
if candidate < target:
left += 1
else:
right -= 1
return False
The important observation here is that the pair sum can only increase by incrementing the left
pointer while it can only decrease by decrementing the right
pointer.
LC 344. Reverse String (✓, ✠)
Write a function that reverses a string. The input string is given as an array of characters s
.
class Solution:
def reverseString(self, s: List[str]) -> None:
"""
Do not return anything, modify s in-place instead.
"""
left = 0
right = len(s) - 1
while left < right:
s[left], s[right] = s[right], s[left]
left += 1
right -= 1
Keep swapping characters until left
is equal to or greater than right
. Note that for odd-length s
the middle character is not actually processed and that's okay since all other characters have been swapped.
LC 977. Squares of a Sorted Array (✓)
Given an integer array nums
sorted in non-decreasing order, return an array of the squares of each number sorted in non-decreasing order.
class Solution:
def sortedSquares(self, nums: List[int]) -> List[int]:
n = len(nums)
res = [0] * n
left = 0
right = n - 1
for i in range(n - 1, -1, -1):
if abs(nums[left]) < abs(nums[right]):
square = nums[right]
right -= 1
else:
square = nums[left]
left += 1
res[i] = square * square
return res
This is a clever application of the two pointer approach. nums
being sorted means it has some negative elements with squares in decreasing order and some non-negative elements with squares in increasing order. The numbers with the largest magnitude (and hence largest square value) will be on opposite ends of the input array.
The strategy is to first initialize a results array, res
, that is the same size as nums
and then fill it in with the squares from right to left. This makes it possible for us to use two pointers in such a way that we're always moving towards numbers with smaller magnitudes (and hence smaller squares) while filling in the results array from largest squares to least squares so that res
is also sorted, as required.
LC 125. Valid Palindrome (✠)
Given a string, determine if it is a palindrome, considering only alphanumeric characters and ignoring cases.
Note: For the purpose of this problem, we define empty string as valid palindrome.
class Solution:
def isPalindrome(self, s: str) -> bool:
left = 0
right = len(s) - 1
while left < right:
while left < right and not s[left].isalnum():
left += 1
while left < right and not s[right].isalnum():
right -= 1
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
Using an "opposite ends two pointer approach" is fairly clear here — the main wrinkle comes in handling the non-alphanumeric characters properly. This involves shifting the pointers in such a way that we effectively "skip over" the non-alphanumeric characters. Python's str.isalnum()
function is quite handy here:
Return
True
if all characters in the string are alphanumeric and there is at least one character,False
otherwise. A characterc
is alphanumeric if one of the following returnsTrue
:c.isalpha()
,c.isdecimal()
,c.isdigit()
, orc.isnumeric()
.
Most languages have something similar or equivalent. Using isalnum
effectively here reduces a bunch of otherwise ugly boilerplate code.
LC 905. Sort Array By Parity (✠) ★★
Given an array A
of non-negative integers, return an array consisting of all the even elements of A
, followed by all the odd elements of A
.
You may return any answer array that satisfies this condition.
class Solution:
def sortArrayByParity(self, nums: List[int]) -> List[int]:
left = 0
right = len(nums) - 1
while left < right:
if nums[left] % 2 == 0:
left += 1
elif nums[right] % 2 == 1:
right -= 1
else:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
The idea is to skip left-pointed even numbers (since they're already where they're supposed to be) and skip right-pointed odd numbers (since they're also where they're supposed to be). If we do not perform either skip, then this means both numbers are not where they're supposed to be and we should swap them.
The essence of this problem is the same as that of the Polish National Flag Problem, which is artfully stated in the following manner in [2]:
There is a row of checkers on the table, some of them are red and some are white. (Red and white are the colors of the Polish national flag.) Design an algorithm to rearrange the checkers so that all the red checkers precede all the white ones. The only operations allowed are the examination of a checker's color and the swapping of two checkers. Try to minimize the number of swaps made by your algorithm.
The fundamental idea behind the solution to this problem is a two pointer one in disguise: Find the leftmost white checker and the rightmost red checker — if the leftmost white checker is to the right of the rightmost red checker, then the problem is solved; otherwise, swap the two and repeat the operation.
Here's an illustration of this algorithm in action:
Of course, in the context of implementing the algorithm with code, we need some way of automating the finding of the leftmost white checker and the rightmost red checker — we do that using two pointers. The solution above can be slightly modified to more closely align with the verbiage of the Polish National Flag Problem:
class Solution:
def sortArrayByParity(self, nums: List[int]) -> List[int]:
left = 0
right = len(nums) - 1
# RED is 0 to denote even numbers (zero remainder when divided by 2)
# WHITE is 1 to denote odd numbers (remainder of 1 when divided by 2)
RED = 0
WHITE = 1
while left <= right:
# stop pointing at red checkers until leftmost white checker is encountered
if nums[left] % 2 == RED:
left += 1
# stop pointing at white checkers until rightmost red checker is encountered
elif nums[right] % 2 == WHITE:
right -= 1
# swap the misplaced white (left pointed) and red checkers (right pointed)
else:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
LC 167. Two Sum II - Input Array Is Sorted (✠)
Given an array of integers numbers
that is already sorted in ascending order, find two numbers such that they add up to a specific target
number.
Return the indices of the two numbers (1-indexed) as an integer array answer
of size 2
, where 1 <= answer[0] < answer[1] <= numbers.length
.
You may assume that each input would have exactly one solution and you may not use the same element twice.
class Solution:
def twoSum(self, numbers: List[int], target: int) -> List[int]:
left = 0
right = len(numbers) - 1
while left < right:
curr = numbers[left] + numbers[right]
if curr < target:
left += 1
elif curr > target:
right -= 1
else:
break
return [left + 1, right + 1]
LC 15. 3Sum (✠)
Given an array nums of n
integers, are there elements a
, b
, c
in nums
such that a + b + c = 0
? Find all unique triplets in the array which gives the sum of zero.
Notice that the solution set must not contain duplicate triplets.
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
nums.sort()
res = []
for i in range(n - 2):
curr = nums[i]
if curr > 0:
break
# skip repeated values to avoid duplicate results
if i > 0 and nums[i-1] == curr:
continue
target = -curr
left = i + 1
right = n - 1
while left < right:
curr_pair = nums[left] + nums[right]
if curr_pair < target:
left += 1
elif curr_pair > target:
right -= 1
else:
res.append([curr, nums[left], nums[right]])
left += 1
right -= 1
# skip the values we just added to avoid duplicate results
while left < right and nums[left] == nums[left - 1]:
left += 1
return res
The two pointers approach for this problem is not obvious, largely because the first step in the two pointers solution is a pre-sorting one (i.e., we sort the array first in order to effectively employ two pointers). Further, we use the "opposite ends" two pointers approach for each iteration through the sorted nums
array. Once a two pointer approach is settled on (there are other approaches), the hardest part of the problem is ensuring duplicate results are not included. How this works exactly is best understood by means of an example input (assume the pre-sorting has been done in advance):
nums = [-1, -1, 0, 0, 0, 0, 1, 1, 1]
Before analyzing what happens with the code, let's first identify the desired output (i.e., distinct triples) for this example input, namely the following:
[[-1, 0, 1], [0, 0, 0]]
How does the solution above work to give us this? Let l
and r
denote the left
and right
pointers, respectively. And let A
denote the current i
-value for the iteration and B
the end value (third to last). Then we have the following consequences for the first iteration:
A B
[-1, -1, 0, 0, 0, 0, 1, 1, 1]
l r # -1 + (-1 + 1) < 0; increment l
l r # -1 + (0 + 1) == 0; triple: [-1, 0, 1]
l r # increment l, decrement r
l r # increment l (second skip condition)
l r # increment l (second skip condition)
l r # -1 + (1 + 1) > 0; decrement r
l/r # while loop does not execute
And the second iteration:
A B
[-1, -1, 0, 0, 0, 0, 1, 1, 1] # (first skip condition)
Note how the first skip condition ensures we do not create duplicates. If we had continued with the second iteration as shown above, then we would have the following (resulting in a duplicated triple):
A B
[-1, -1, 0, 0, 0, 0, 1, 1, 1]
l r # -1 + (0 + 1) == 0; triple: [-1, 0 ,1]
Now for the third iteration:
A B
[-1, -1, 0, 0, 0, 0, 1, 1, 1]
l r # 0 + (0 + 1) > 0; decrement r
l r # 0 + (0 + 1) > 0; decrement r
l r # 0 + (0 + 1) > 0; decrement r
l r # 0 + (0 + 0) == 0; triple: [0, 0, 0]
l/r # increment l, decrement r
# while loop does not execute
Fourth iteration:
A B
[-1, -1, 0, 0, 0, 0, 1, 1, 1] # (first skip condition)
Fifth iteration:
A B
[-1, -1, 0, 0, 0, 0, 1, 1, 1] # (first skip condition)
Sixth iteration:
A B
[-1, -1, 0, 0, 0, 0, 1, 1, 1] # (first skip condition)
Seventh iteration:
A/B
[-1, -1, 0, 0, 0, 0, 1, 1, 1] # (curr > 0 skip condition)
LC 557. Reverse Words in a String III (✓)
Given a string s
, reverse the order of characters in each word within a sentence while still preserving whitespace and initial word order.
class Solution:
def reverseWords(self, s: str) -> str:
def reverseWord(start, end):
while start < end:
res[start], res[end] = res[end], res[start]
start += 1
end -= 1
n = len(s)
res = list(s)
word_start = 0
for i in range(n):
if s[i] == ' ':
reverseWord(word_start, i - 1)
word_start = i + 1
reverseWord(word_start, n - 1)
return ''.join(res)
The directive is simple: reverse each space-separated block of characters (i.e., "word") while leaving the spaces in place. But implementing a solution is perhaps a little trickier than it seems at first.
We can use two pointers on opposite ends for each block of characters, but we need some way of determining where the beginning of each word occurs. The first word will naturally start at index 0
and each subsequent word will start at whatever character occurs first after a space. The end of a word will aways be whatever character was most recently encountered before a space.
LC 917. Reverse Only Letters (✓)
Given a string S
, return the "reversed" string where all characters that are not a letter stay in the same place, and all letters reverse their positions.
class Solution:
def reverseOnlyLetters(self, s: str) -> str:
def is_letter(char):
return 65 <= ord(char) <= 90 or 97 <= ord(char) <= 122
left = 0
right = len(s) - 1
res = list(s)
while left < right:
left_is_letter = is_letter(s[left])
right_is_letter = is_letter(s[right])
if not left_is_letter:
left += 1
elif not right_is_letter:
right -= 1
else:
res[left], res[right] = res[right], res[left]
left += 1
right -=1
return ''.join(res)
Note that the use of the is_letter
function is highly optimized for this problem because it only considers the ASCII values of lower and uppercase English letters. We could have used Python's str.isalpha()
instead, but the extra overhead isn't worth it because it considers letters in all kinds of languages.
LC 2000. Reverse Prefix of Word (✓)
Given a 0-indexed string word
and a character ch
, reverse the segment of word
that starts at index 0
and ends at the index of the first occurrence of ch
(inclusive). If the character ch
does not exist in word
, do nothing.
- For example, if
word = "abcdefd"
andch = "d"
, then you should reverse the segment that starts at0
and ends at3
(inclusive). The resulting string will be"dcbaefd"
.
Return the resulting string.
class Solution:
def reversePrefix(self, word: str, ch: str) -> str:
def reverse_chars(end):
start = 0
while start < end:
res[start], res[end] = res[end], res[start]
start += 1
end -= 1
res = list(word)
for i in range(len(res)):
if word[i] == ch:
reverse_chars(i)
return ''.join(res)
return word
LC 75. Sort Colors (✠) ★★
Given an array nums
with n
objects colored red, white, or blue, sort them in-place so that objects of the same color are adjacent, with the colors in the order red, white, and blue.
We will use the integers 0
, 1
, and 2
to represent the color red, white, and blue, respectively.
class Solution:
def sortColors(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
RED = 0
WHITE = 1
BLUE = 2
first_white_pos = 0
left = 0
right = len(nums) - 1
while left <= right:
if nums[left] == RED:
nums[first_white_pos], nums[left] = nums[left], nums[first_white_pos]
left += 1
first_white_pos += 1
elif nums[left] == WHITE:
left += 1
else:
nums[right], nums[left] = nums[left], nums[right]
right -= 1
This problem, which may be considered a more advanced version of LC 905. Sort Array By Parity (i.e., the Polish National Flag Problem due to the Polish flag having colors red and white), is known as the Dutch National Flag Problem (due to the Dutch flag having colors red, white, and blue).
A wonderful framing for this problem appears in [2] (the algorithm's verbal solution also follows from this resource):
There is a row of checkers of three colors: red, white, and blue. Devise an algorithm to rearrange the checkers so that all the red checkers come first, all the white ones come next, and all the blue checkers come last. The only operations allowed are examination of a checker's color and swap of two checkers. Try to minimize the number of swaps made by your algorithm.
The "swap of two checkers" phrase may indicate a two pointers approach could be appropriate. The following algorithm, which can be used with some creativity when implementing the Quicksort sorting algorithm, is based on considering the checker row as made up of four contiguous possibly empty sections: red checkers on the left, then white checkers, then the checkers whose colors are yet to be identified, and finally blue checkers:
Initially, the red, white, and blue sections are empty, with all the checkers being in the unknown section. On each iteration, the algorithm shrinks the size of the unknown section by one element either from the left or from the right: If the first (i.e., leftmost) checker in the unknown section is red, swap it with the first checker after the red section and advance to the next checker; if it is white, advance to the next checker; if it is blue, swap it with the last checker before the blue section. This step is repeated as long as there are checkers in the unknown section.
As with LC 905. Sort Array By Parity, however, to actually implement the algorithm illustrated above with code means we're going to need some pointers to keep track of things. The use of two pointers in LC 905 was somewhat straightforward:
class Solution:
def sortArrayByParity(self, nums: List[int]) -> List[int]:
left = 0
right = len(nums) - 1
# RED is 0 to denote even numbers (zero remainder when divided by 2)
# WHITE is 1 to denote odd numbers (remainder of 1 when divided by 2)
RED = 0
WHITE = 1
while left <= right:
# stop pointing at red checkers until leftmost white checker is encountered
if nums[left] % 2 == RED:
left += 1
# stop pointing at white checkers until rightmost red checker is encountered
elif nums[right] % 2 == WHITE:
right -= 1
# swap the misplaced white (left pointed) and red checkers (right pointed)
else:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
We will still use two pointers, left
and right
, to keep track of the left and right boundaries of the "unknown" zone, pictured previously. But the following part of our algorithm necessitates the addition of another kind of pointer: "If the leftmost checker in the unknown section is red, then swap it with the first checker after the red section (i.e., the first or leftmost white checker) and advance to the next checker." So we basically need three pointers: two for the "unknown" zone (left
and right
), which shrinks inward as we process checkers, and one for where the first white checker is or should be (first_white_pos
).
As long as a white checker is present, the final value of first_white_pos
will always point to where the first white checker is. If we print the final nums
array as well as the final first_white_pos
value, then we'll have the following:
[2,0,2,1,1,0] # initial colors array
[0,0,1,1,2,2] # sorted colors array
2 # first_white_pos final value
If, however, a white checker is not present, then first_white_pos
, will point to where the first white checker should be if one were added:
[2,0,2,0,0,0] # initial colors array
[0,0,0,0,2,2] # sorted colors array
4 # first_white_pos final value
LC 912. Sort an Array (✠) ★★
Given an array of integers nums
, sort the array in ascending order.
class Solution:
def partition(self, arr, l, r, pivot):
first_pivot = l
while l <= r:
if arr[l] < pivot:
arr[first_pivot], arr[l] = arr[l], arr[first_pivot]
l += 1
first_pivot += 1
elif arr[l] == pivot:
l += 1
else:
arr[r], arr[l] = arr[l], arr[r]
r -= 1
return first_pivot, r
def qsort_inplace(self, arr, left, right):
if left >= right:
return
pivot = arr[random.randint(left, right)]
pivot_start, pivot_end = self.partition(arr, left, right, pivot)
self.qsort_inplace(arr, left, pivot_start - 1)
self.qsort_inplace(arr, pivot_end + 1, right)
def sortArray(self, nums: List[int]) -> List[int]:
self.qsort_inplace(nums, 0, len(nums) - 1)
return nums
The official solution provides illustrative implementations of the following sorting algorithms: merge sort, heap sort, counting sort, and radix sort. The sort above, quick sort, is really meant to illustrate how a well-known and often used sorting algorithm (qucksort) fundamentally uses a two pointer strategy behind the scenes for one of its most efficient implementations (i.e., the in-place manipulation of its elements). The partition
method of the code block above serves to highlight where the two pointer strategy is being used.
This strategy is effectively the same as that used in LC 75. Sort Colors. The discussion below details the differences more clearly.
Compare the solutions to LC 75 and LC 912 side by side (the two pointer similarities are highlighted):
class Solution:
def partition(self, arr, l, r, pivot):
first_pivot = l
while l <= r:
if arr[l] < pivot:
arr[first_pivot], arr[l] = arr[l], arr[first_pivot]
l += 1
first_pivot += 1
elif arr[l] == pivot:
l += 1
else:
arr[r], arr[l] = arr[l], arr[r]
r -= 1
return first_pivot, r
def qsort_inplace(self, arr, left, right):
if left >= right:
return
pivot = arr[random.randint(left, right)]
pivot_start, pivot_end = self.partition(arr, left, right, pivot)
self.qsort_inplace(arr, left, pivot_start - 1)
self.qsort_inplace(arr, pivot_end + 1, right)
def sortArray(self, nums: List[int]) -> List[int]:
self.qsort_inplace(nums, 0, len(nums) - 1)
return nums
class Solution:
def sortColors(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
RED = 0
WHITE = 1
BLUE = 2
first_white_pos = 0
left = 0
right = len(nums) - 1
while left <= right:
if nums[left] == RED:
nums[first_white_pos], nums[left] = nums[left], nums[first_white_pos]
left += 1
first_white_pos += 1
elif nums[left] == WHITE:
left += 1
else:
nums[right], nums[left] = nums[left], nums[right]
right -= 1
How is the partition
method of in-place quicksort (left) similar to the Dutch flag problem solution (right)? They're very similar! Almost the same. For the Dutch flag problem, the values 0
, 1
, and 2
represented RED
, WHITE
, and BLUE
, respectively. For in-place quicksort, values smaller than the pivot represent RED
, values equal to the pivot represent WHITE
, and values greater than the pivot represent BLUE
.
We still fundamentally start with the following overall situation:
The difference for this sorting problem that makes it somewhat more complicated than the Dutch flag problem is that we recursively solve smaller and smaller instances of the same subproblem (as opposed to sorting all values in a single pass).
For example, suppose nums = [3,7,4,10,8,1,3,4,3,2]
is our original array and that the random pivot we get is the index value of 6
, meaning nums[6] = 3
serves as our pivot. Then the initial partition
method will result in something similar to the following array of numbers (i.e., where all numbers have been partitioned in such a way that every number less than the pivot lies to the left while every number greater than the pivot lies to the right):
[2,1,3,3,3,8,4,10,4,7]
The genius of quicksort is that each value of 3
, after the first partition, is where it should permanently belong for the final sorted array. Our original problem of sorting nums = [3,7,4,10,8,1,3,4,3,2]
has now been reduced to the following smaller instances of the same problem:
[3,7,4,10,8,1,3,4,3,2] # initial array; pivot -> nums[6] = 3
[2,1,3,3,3,8,4,10,4,7] # result after first partition
[2,1] [8,4,10,4,7] # subproblems to be solved
Exhaust inputs
Remarks
Sometimes a problem provides two or more iterables as input. In such cases, specifically with two iterables (e.g., arrays), we can move pointers along both inputs simultaneously until all elements have been checked or exhausted.
The idea is to have logic that uses both inputs (or more in some cases) in some fashion until one of them has been exhausted. Then logic is passed on so the other input is similarly exhausted.
def fn(arr1, arr2):
i = j = 0
while i < len(arr1) and j < len(arr2):
# choose one of the following depending on the problem:
# i += 1
# j += 1
# increment i AND j: i+= 1 and j += 1
while i < len(arr1):
i += 1
while j < len(arr2):
j += 1
Examples
Merge two sorted arrays into another sorted array (✓)
def merge_sorted_arrs(arr1, arr2):
i = j = 0
res = []
while i < len(arr1) and j < len(arr2):
if arr1[i] < arr2[j]:
res.append(arr1[i])
i += 1
elif arr1[i] > arr2[j]:
res.append(arr2[j])
j += 1
else:
res.append(arr1[i])
res.append(arr2[j])
i += 1
j += 1
while i < len(arr1):
res.append(arr1[i])
i += 1
while j < len(arr2):
res.append(arr2[j])
j += 1
return res
LC 392. Is Subsequence (✓)
Given two strings s
and t
, check if s
is a subsequence of t
.
A subsequence of a string is a new string that is formed from the original string by deleting some (can be none) of the characters without disturbing the relative positions of the remaining characters. (i.e., "ace"
is a subsequence of "abcde"
while "aec"
is not).
class Solution:
def isSubsequence(self, s: str, t: str) -> bool:
i = j = 0
while i < len(s) and j < len(t):
if s[i] == s[j]:
i += 1
j += 1
return i == len(s)
LC 350. Intersection of Two Arrays II (✠)
Given two integer arrays nums1
and nums2
, return an array of their intersection. Each element in the result must appear as many times as it shows in both arrays and you may return the result in any order.
class Solution:
def intersect(self, nums1: List[int], nums2: List[int]) -> List[int]:
nums1.sort()
nums2.sort()
res = []
p1 = p2 = 0
while p1 < len(nums1) and p2 < len(nums2):
if nums1[p1] == nums2[p2]:
res.append(nums1[p1])
p1 += 1
p2 += 1
elif nums1[p1] > nums2[p2]:
p2 += 1
else:
p1 += 1
return res
Pre-sorting is a common theme for employing two pointer solutions.
LC 349. Intersection of Two Arrays (✠)
Given two integer arrays nums1
and nums2
, return an array of their intersection. Each element in the result must be unique and you may return the result in any order.
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
nums1.sort()
nums2.sort()
res = []
p1 = p2 = 0
while p1 < len(nums1) and p2 < len(nums2):
if nums1[p1] == nums2[p2]:
if len(res) == 0 or res[-1] != nums1[p1]:
res.append(nums1[p1])
p1 += 1
p2 += 1
elif nums1[p1] > nums2[p2]:
p2 += 1
else:
p1 += 1
return res
The solution above is equivalent to that for LC 350. Intersection of Two Arrays II, where the only difference is the addition of the following conditional to prevent duplicates:
if len(res) == 0 or res[-1] != nums1[p1]:
res.append(nums1[p1])
A more optimal solution might be to use sets since lookup time is :
def intersection(nums1, nums2):
set1, set2 = set(nums1), set(nums2)
return [x for x in set1 if x in set2]
LC 986. Interval List Intersections (✠)
You are given two lists of closed intervals, firstList
and secondList
, where firstList[i] = [starti, endi]
and secondList[j] = [startj, endj]
. Each list of intervals is pairwise disjoint and in sorted order.
Return the intersection of these two interval lists.
A closed interval [a, b]
(with a < b
) denotes the set of real numbers x
with a <= x <= b
.
The intersection of two closed intervals is a set of real numbers that are either empty or represented as a closed interval. For example, the intersection of [1, 3]
and [2, 4]
is [2, 3]
.
class Solution:
def intervalIntersection(self, firstList: List[List[int]], secondList: List[List[int]]) -> List[List[int]]:
res = []
p1 = p2 = 0
while p1 < len(firstList) and p2 < len(secondList):
f_start = firstList[p1][0]
f_end = firstList[p1][1]
s_start = secondList[p2][0]
s_end = secondList[p2][1]
# determine if firstList[p1] intersects secondList[p2]
lo = max(f_start, s_start)
hi = min(f_end, s_end)
if lo <= hi:
res.append([lo, hi])
# remove interval with smallest endpoint
if f_end < s_end:
p1 += 1
else:
p2 += 1
return res
The two pointer approach above is probably the cleanest implementation for this problem. The strategy is deceptively simple: determine whether or not the linked intervals overlap (if so, add the intersection to the results array) and then increment past whichever interval has the smallest endpoint.
We do not have to worry about missing any intersected intervals in the last conditional of the while
loop (i.e., the removal of the interval with the smallest endpoint) because of the condition that the intervals in each list are pairwise disjoint. That is, for whichever while loop iteration we are currently processing, the interval with the smallest endpoint can only intersect a single interval in the other list (otherwise, the other list would have to have overlapping intervals, which violates the pairwise disjoint condition).
Another less polished two pointer solution might be the following, but it just overcomplicates things:
class Solution:
def intervalIntersection(self, firstList: List[List[int]], secondList: List[List[int]]) -> List[List[int]]:
res = []
p1 = p2 = 0
while p1 < len(firstList) and p2 < len(secondList):
f_start = firstList[p1][0]
f_end = firstList[p1][1]
s_start = secondList[p2][0]
s_end = secondList[p2][1]
if f_end < s_start:
p1 += 1
elif s_end < f_start:
p2 += 1
else:
res.append([max(f_start, s_start), min(f_end, s_end)])
if f_end > s_end:
p2 += 1
else:
p1 += 1
return res
LC 2540. Minimum Common Value (✓)
Given two integer arrays nums1
and nums2
, sorted in non-decreasing order, return the minimum integer common to both arrays. If there is no common integer amongst nums1
and nums2
, return -1
.
Note that an integer is said to be common to nums1
and nums2
if both arrays have at least one occurrence of that integer.
class Solution:
def getCommon(self, nums1: List[int], nums2: List[int]) -> int:
i = j = 0
while i < len(nums1) and j < len(nums2):
if nums1[i] < nums2[j]:
i += 1
elif nums2[j] < nums1[i]:
j += 1
else:
return nums1[i]
return -1
The idea here is that we keep advancing one pointer until it overshoots the value referenced by the other pointer. Then we switch pointers and do the same. This will eventually leads us to the first common value or we'll exhaust both inputs and return -1
, indicating there is no common element, as desired.
LC 844. Backspace String Compare (✓) ★★★
Given two strings s
and t
, return true
if they are equal when both are typed into empty text editors. '#'
means a backspace character.
Note that after backspacing an empty text, the text will continue empty.
class Solution:
def backspaceCompare(self, s: str, t: str) -> bool:
def next_valid_char(r, start_pos):
skip = 0
for i in range(start_pos, -1, -1):
if r[i] == '#':
skip += 1
elif skip > 0:
skip -= 1
else:
return i
return -1
s_p = len(s) - 1
t_p = len(t) - 1
# still characters to process
while s_p >= 0 or t_p >= 0:
s_p = next_valid_char(s, s_p)
t_p = next_valid_char(t, t_p)
# both strings are fully processed
if s_p < 0 and t_p < 0:
return True
# one string is not fully processed but the other one is
# OR the current valid characters don't match
elif (s_p < 0 or t_p < 0) or s[s_p] != t[t_p]:
return False
# a match is made for the valid characters so we continue
else:
s_p -= 1
t_p -= 1
return True
This is not a typical "exhaust both inputs" two pointer problem. The stack-based solution is somewhat clear from the outset:
class Solution:
def backspaceCompare(self, s: str, t: str) -> bool:
def get_str(r):
stack = []
for char in r:
if char == '#':
if stack:
stack.pop()
else:
stack.append(char)
return "".join(stack)
s_str = get_str(s)
t_str = get_str(t)
return s_str == t_str
But this is time and space, but as the follow-up for this problem suggests, we can do better on the space, specifically . And we use two pointers to accomplish that.
The key observation is to consider both strings from their ends. We only ever want to compare characters we know to be valid for the eventual final string. Iterating from the front is a non-starter since we don't know in advance how many backspace characters #
we'll encounter. But if we iterate from the back, then we can treat #
characters as skips and track their count. The next_valid_char
utility function lets us look at each string to determine what their next valid characters at the end would be, returning the valid character's position or -1
if no next valid character is available.
We then take the return values from next_valid_char
and perform some logic to determine whether or not we should keep processing the strings:
- If all we did was skip through the rest of all the characters for both strings, then clearly the strings must be equal.
- If we skipped through everything in one string but still have characters remaining in the other string, then these strings couldn't possibly be equal. Additionally, if we have valid characters from both strings but they're not equal, then again the strings have to be unequal.
- Finally, if both checks above don't flag the processing as completed, then we need to continue on by advancing to the next character.
We can actually generalize the logic in the next_valid_char
function to handle iterables of any kind where skip conditions are made and we want the rightmost valid element:
def next_valid_element(arr_or_str, start_pos):
# declare elements for which skips will be made
one_skip_elements = { '#', '!' }
two_skip_elements = { '@', '%', '*' }
three_skip_elements = { '^' }
# always start with 0 skips
skip = 0
# process entire iterable until valid element is found
for i in range(start_pos, -1, -1):
el = arr_or_str[i]
# one or more skips needed
if el in one_skip_elements:
skip += 1
elif el in two_skip_elements:
skip += 2
elif el in three_skip_elements:
skip += 3
# valid element found but skips exist
elif skip > 0:
skip -= 1
# valid element found and no skips: return position
else:
return i
# no valid element was found
return -1
Fast and slow
Remarks
The "fast and slow" template provided below is not for problems involving linked lists but for other commonly encountered problems where the iterable given is an array, string (array of characters), etc. The idea is that the fast pointer steadily advances while the slow pointer is only advanced in a piecemeal fashion (often after some sort of condition is met).
def fn(arr):
slow = fast = 0
while fast < len(arr):
if CONDITION:
slow += 1
fast += 1
return slow
Examples
LC 26. Remove Duplicates from Sorted Array (✠)
Given an integer array nums
sorted in non-decreasing order, remove the duplicates in-place such that each unique element appears only once. The relative order of the elements should be kept the same.
Since it is impossible to change the length of the array in some languages, you must instead have the result be placed in the first part of the array nums
. More formally, if there are k
elements after removing the duplicates, then the first k
elements of nums
should hold the final result. It does not matter what you leave beyond the first k
elements.
Return k
after placing the final result in the first k
slots of nums
.
Do not allocate extra space for another array. You must do this by modifying the input array in-place with extra memory.
Custom Judge:
The judge will test your solution with the following code:
int[] nums = [...]; // Input array
int[] expectedNums = [...]; // The expected answer with correct length
int k = removeDuplicates(nums); // Calls your implementation
assert k == expectedNums.length;
for (int i = 0; i < k; i++) {
assert nums[i] == expectedNums[i];
}
If all assertions pass, then your solution will be accepted.
class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
slow = fast = 1
while fast < len(nums):
if nums[fast] != nums[fast-1]:
nums[slow] = nums[fast]
slow += 1
fast += 1
return slow
The first number cannot be a duplicate; hence, we start both pointers at index 1
. The idea, then, is that we progressively overwrite the contents of nums
using the slow
pointer once a yet-unencountered number is reached (we're gauranteed to encounter only new numbers since the input array is sorted).
Given how this specific problem is set up, the following solution may be considered slightly cleaner than the one above:
class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
slow = 1
for fast in range(1, len(nums)):
if nums[fast] != nums[fast-1]:
nums[slow] = nums[fast]
slow += 1
return slow
LC 27. Remove Element (✠)
Given an integer array nums
and an integer val
, remove all occurrences of val
in nums
in-place. The relative order of the elements may be changed.
Since it is impossible to change the length of the array in some languages, you must instead have the result be placed in the first part of the array nums
. More formally, if there are k
elements after removing the duplicates, then the first k
elements of nums
should hold the final result. It does not matter what you leave beyond the first k
elements.
Return k
after placing the final result in the first k
slots of nums
.
Do not allocate extra space for another array. You must do this by modifying the input array in-place with extra memory.
Custom Judge:
The judge will test your solution with the following code:
int[] nums = [...]; // Input array
int val = ...; // Value to remove
int[] expectedNums = [...]; // The expected answer with correct length.
// It is sorted with no values equaling val.
int k = removeElement(nums, val); // Calls your implementation
assert k == expectedNums.length;
sort(nums, 0, k); // Sort the first k elements of nums
for (int i = 0; i < actualLength; i++) {
assert nums[i] == expectedNums[i];
}
If all assertions pass, then your solution will be accepted.
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
slow = fast = 0
while fast < len(nums):
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
fast += 1
return slow
The two pointer solution here is arguably a bit of a mind-bender at first, but it becomes quite clear after some reflection. The basic idea: move the slow
pointer continuously until it hits the first val
that needs to be removed. Then, whenever the fast
pointer encounters an element not equal to val
, swap the elements that slow
and fast
point to (val
and non-val
elements, respectively). Wherever slow
ends up pointing (in terms of index value) is the final length of the array whose elements have not been removed.
To see the logic unfold, try moving along the i
and j
pointers below (representing the slow
and fast
pointers, respectively) and swapping values as the solution logic requires:
val = 2
[0,1,2,2,3,0,4,2]
i
j
For example, i
and j
move together until the first 2
is reached:
[0,1,2,2,3,0,4,2]
i
j
And then j
continues forward until the 3
is encountered:
[0,1,2,2,3,0,4,2]
i
j
At this point, we swap the elements that i
and j
point to and then increment i
:
[0,1,3,2,2,0,4,2]
i
j
And we continue along until the input array nums
has been exhausted by j
. Where i
last points in terms of index value will be the length of the array without val
present.
Given the structure of this problem, it's common to see a solution also represented as follows:
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
i = 0
for j in range(len(nums)):
if nums[j] != val:
nums[i] = nums[j]
i += 1
return i
LC 283. Move Zeroes (✓, ✠)
Given an integer array nums
, move all 0
's to the end of it while maintaining the relative order of the non-zero elements.
Note that you must do this in-place without making a copy of the array.
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
slow = fast = 0
while fast < len(nums):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
fast += 1
The idea is for the slow
pointer to always point to a 0
value and to wait until the fast
pointer encounters a non-0
value so that a swap can be made (and then slow
incremented). For example, we could have a start like the following (i
and j
represent the slow
and fast
pointers, respectively):
[1,0,0,3,12]
i
j
The first value is not 0
so a vacuous swap occurs and both pointers are incremented:
[1,0,0,3,12]
i
j
Then j
is incremented again to 0
. Then again to 3
, which is where a swap will need to occur:
[1,0,0,3,12]
i
j
Once the swap occurs and i
is incremented, we have the following:
[1,3,0,0,12]
i
j
It should be clear now how the relative ordering is respected, all while pushing the 0
values to the end of the array.
Miscellaneous
Build a trie
Remarks
TBD
TBD
Examples
TBD
Dijkstra's algorithm
Hashing (and sets)
Checking for existence
Remarks
This is where hash maps (and sets) really shine. Checking whether or not an element exists in a hash table is an operation. Checking for existence in an array, however, is an operation. This means a number of algorithms can often be improved from to by using a hash map instead of an array to check for existence.
lookup = {}
if key in lookup: # existence check is O(1) for hash maps
# ...
seen = set()
if el in seen: # existence check is O(1) for sets
# ...
Examples
LC 1. Two Sum (✓)
Given an array of integers nums
and an integer target
, return indices of the two numbers such that they add up to target
.
You may assume that each input would have exactly one solution, and you may not use the same element twice.
You can return the answer in any order.
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
lookup = {}
for i in range(len(nums)):
complement = target - nums[i]
if complement in lookup:
return [i, lookup[complement]]
lookup[nums[i]] = i
LC 2351. First Letter to Appear Twice (✓)
Given a string s
consisting of lowercase English letters, return the first letter to appear twice.
Note:
- A letter
a
appears twice before another letterb
if the second occurrence ofa
is before the second occurrence ofb
. s
will contain at least one letter that appears twice.
class Solution:
def repeatedCharacter(self, s: str) -> str:
seen = set()
for char in s:
if char in seen:
return char
seen.add(char)
LC 1832. Check if the Sentence Is Pangram (✓)
A pangram is a sentence where every letter of the English alphabet appears at least once.
Given a string sentence
containing only lowercase English letters, return true
if sentence
is a pangram, or false
otherwise.
class Solution:
def checkIfPangram(self, sentence: str) -> bool:
seen = set()
for char in sentence:
seen.add(char)
if len(seen) == 26:
return True
return False
LC 268. Missing Number (✓)
Given an array nums
containing n
distinct numbers in the range [0, n]
, return the only number in the range that is missing from the array.
Follow up: Could you implement a solution using only O(1)
extra space complexity and O(n)
runtime complexity?
class Solution:
def missingNumber(self, nums):
lookup = set(nums)
n = len(nums)
for num in range(n + 1):
if num not in lookup:
return num
Just because you can solve this problem using a hash map does not mean you should. There are two solutions that are notably better, and they both rely on basic mathematical observations.
Using Gauss's formula (sum of first positive integers):
class Solution:
def missingNumber(self, nums: List[int]) -> int:
n = len(nums)
tot_sum = n * (n+1) // 2
return tot_sum - sum(nums)
Another effective mathematical approach involves computing a running sum of the first positive integers — the difference is the missing number:
class Solution:
def missingNumber(self, nums: List[int]) -> int:
running_sum = 0
for i in range(len(nums)):
running_sum += (i + 1) - nums[i]
return running_sum
LC 1426. Counting Elements (✓)
Given an integer array arr
, count how many elements x
there are, such that x + 1
is also in arr
. If there are duplicates in arr
, count them separately.
class Solution:
def countElements(self, arr: List[int]) -> int:
lookup = set(arr)
ans = 0
for num in arr:
if (num + 1) in lookup:
ans += 1
return ans
LC 217. Contains Duplicate (✓)
Given an integer array nums
, return true
if any value appears at least twice in the array, and return false
if every element is distinct.
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
lookup = set()
for num in nums:
if num in lookup:
return True
lookup.add(num)
return False
The solution above is straightforward, but an even more straightforward solution is as follows:
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
return len(nums) != len(set(nums))
LC 1436. Destination City (✓)
You are given the array paths, where paths[i] = [cityAi, cityBi]
means there exists a direct path going from cityAi
to cityBi
. Return the destination city, that is, the city without any path outgoing to another city.
It is guaranteed that the graph of paths forms a line without any loop, therefore, there will be exactly one destination city.
class Solution:
def destCity(self, paths: List[List[str]]) -> str:
departures = set()
destinations = set()
for path in paths:
departures.add(path[0])
destinations.add(path[1])
for destination in destinations:
if destination not in departures:
return destination
The solution above explicitly answers the question we are trying to answer: which destination city is not a departure city? Another more Pythonic solution may be expressed as follows:
class Solution:
def destCity(self, paths: List[List[str]]) -> str:
departures, destinations = zip(*paths)
destination_city = set(destinations) - set(departures)
return destination_city.pop()
LC 1496. Path Crossing (✓)
Given a string path
, where path[i] = 'N'
, 'S'
, 'E'
or 'W'
, each representing moving one unit north, south, east, or west, respectively. You start at the origin (0, 0)
on a 2D plane and walk on the path specified by path
.
Return True
if the path crosses itself at any point, that is, if at any time you are on a location you've previously visited. Return False
otherwise.
class Solution:
def isPathCrossing(self, path: str) -> bool:
dirs = {
"N": (0, 1),
"S": (0, -1),
"W": (-1, 0),
"E": (1, 0)
}
seen = {(0, 0)}
x = 0
y = 0
for move in path:
dx, dy = dirs[move]
x += dx
y += dy
if (x, y) in seen:
return True
seen.add((x, y))
return False
LC 49. Group Anagrams (✓)
Given an array of strings strs
, group the anagrams together. You can return the answer in any order.
An anagram is a word or phrase formed by rearranging the letters of a different word or phrase, typically using all the original letters exactly once.
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
lookup = defaultdict(list)
for s in strs:
key = "".join(sorted(s))
lookup[key].append(s)
return list(lookup.values())
It's easier to check if a string is an anagram of another string by determining whether or not the sorted versions of these strings are equivalent. To return the groups of anagrams, we add the sorted version of a string as the key in a hash map (for efficient lookups) and we add the string itself to the group if its sorted version matches a key.
LC 2352. Equal Row and Column Pairs (✓) ★
Given a 0-indexed n x n
integer matrix grid
, return the number of pairs (Ri, Cj)
such that row Ri
and column Cj
are equal.
A row and column pair is considered equal if they contain the same elements in the same order (i.e. an equal array).
class Solution:
def equalPairs(self, grid: List[List[int]]) -> int:
n = len(grid)
row_sols = defaultdict(int)
ans = 0
for row in grid:
key = tuple(row)
row_sols[key] += 1
for col in range(n):
col_sol = []
for row in range(n):
col_sol.append(grid[row][col])
col_sol_str = tuple(col_sol)
ans += row_sols[col_sol_str]
return ans
Even though we're effectively using the hash map to count the number of equivalent row-column pairs, the crux of the problem is an existence once: the keys for our hash map need to be hashable (i.e., usually "immutable" in most languages). Hence, we can use strings, but this is somewhat messy, especially if you have a nice alternative like the tuple
in Python.
LC 383. Ransom Note (✓)
Given two strings ransomNote
and magazine
, return true
if ransomNote
can be constructed by using the letters from magazine
and false
otherwise.
Each letter in magazine
can only be used once in ransomNote
.
class Solution:
def canConstruct(self, ransomNote: str, magazine: str) -> bool:
if len(magazine) < len(ransomNote):
return False
lookup = defaultdict(int)
for char in magazine:
lookup[char] += 1
for char in ransomNote:
if lookup[char] >= 1:
lookup[char] -= 1
else:
return False
return True
The idea is to chip away at ransomNote
one character at a time. To do this efficiently, we can convert the characters in magazine
into a hash map for efficient frequency lookups. If the letter we are trying to chip away from the ransomNote
does not exist in the magazine lookup, then we know a solution is not possible.
LC 205. Isomorphic Strings (✓) ★★
Given two strings s
and t
, determine if they are isomorphic.
Two strings s
and t
are isomorphic if the characters in s
can be replaced to get t
.
All occurrences of a character must be replaced with another character while preserving the order of characters. No two characters may map to the same character, but a character may map to itself.
class Solution:
def isIsomorphic(self, s: str, t: str) -> bool:
s_lookup = defaultdict(list)
t_lookup = defaultdict(list)
for i in range(len(s)):
char_s = s[i]
char_t = t[i]
s_lookup[char_s].append(i)
t_lookup[char_t].append(i)
return sorted(s_lookup.values()) == sorted(t_lookup.values())
The solution above is probably one of the more natural solutions even though it is not the most efficient. The idea is to keep track of the positional values for the different characters in each string — if the sorted lists are the same, then the strings must be isomorphic.
A more efficient way of crafting a solution is to come up with a nifty way of effectively "encoding" each string:
class Solution:
def isIsomorphic(self, s: str, t: str) -> bool:
def encode(s):
lookup = {}
encoding = []
for char in s:
if char not in lookup:
lookup[char] = len(lookup)
encoding.append(lookup[char])
return str(encoding)
return encode(s) == encode(t)
The encode
function "encodes" a string by mapping each unique character to a unique integer, based on the order in which the character first appears in the string. It effectively allows us to sidestep the need for direct character comparison, instead relying on the position-based pattern of appearances.
For example, here's how the string "hello"
would be encoded:
- For
h
, since it's new,lookup[h] = 0
. The encoding list begins as[0]
. - For
e
, since it's new,lookup[e] = 1
. The encoding list updates to[0, 1]
. - For the first
l
, since it's new,lookup[l] = 2
. The encoding list updates to[0, 1, 2]
. - For the second
l
, it's already inlookup
withlookup[l] = 2
. The encoding list updates to[0, 1, 2, 2]
. - For
o
, since it's new,lookup[o] = 3
. The encoding list updates to[0, 1, 2, 2, 3]
.
The final encoded string representation for "hello"
is thus "0, 1, 2, 2, 3"
. As noted on LeetCode, this solution is more modular and allows us to potentially solve interesting follow-up questions like "grouping isomorphic strings":
def groupIsomorphic(strs):
def encode(s):
lookup = {}
encoding = []
for char in s:
if char not in lookup:
lookup[char] = len(lookup)
encoding.append(lookup[char])
return str(encoding)
groups = defaultdict(list)
for s in strs:
encoding = encode(s)
groups[encoding].append(s)
return list(groups.values())
print(groupIsomorphic(['aab', 'xxy', 'xyz', 'abc', 'def', 'xyx']))
"""
[
['aab', 'xxy'],
['xyz', 'abc', 'def'],
['xyx']
]
"""
LC 290. Word Pattern (✓) ★★
Given a pattern
and a string s
, find if s
follows the same pattern.
Here follow means a full match, such that there is a bijection between a letter in pattern
and a non-empty word in s
.
class Solution:
def wordPattern(self, pattern: str, s: str) -> bool:
def encode(it):
lookup = {}
encoding = []
for el in it:
if el not in lookup:
lookup[el] = len(lookup)
encoding.append(lookup[el])
return str(encoding)
return encode(pattern) == encode(s.split(' '))
This problem is very similar to LC 290. Word Pattern. The efficient solution above exploits the same encoding idea: the items are encoded so as to facilitate positional matching; that is, characters of pattern
contribute to the encoding while words of s
contribute to the encoding.
LC 791. Custom Sort String (✓)
S
and T
are strings composed of lowercase letters. In S
, no letter occurs more than once.
S
was sorted in some custom order previously. We want to permute the characters of T
so that they match the order that S
was sorted. More specifically, if x
occurs before y
in S
, then x
should occur before y
in the returned string.
Return any permutation of T
(as a string) that satisfies this property.
class Solution:
def customSortString(self, order: str, s: str) -> str:
lookup = defaultdict(int)
for char in s:
lookup[char] += 1
ans = []
for char in order:
if char in lookup:
for _ in range(lookup[char]):
ans.append(char)
del lookup[char]
for char in lookup:
for _ in range(lookup[char]):
ans.append(char)
return "".join(ans)
The idea is to first create a hash map of the character frequencies in s
. This will let us recreate a permutation of s
efficiently. Now we simply iterate through order
from left to right, filling in the ans
array with the character counts obtained previously. Lastly, since the order does not matter, we can just fill in the rest of the array with the unused characters and their frequencies.
LC 1657. Determine if Two Strings Are Close (✓) ★
Two strings are considered close if you can attain one from the other using the following operations:
- Operation 1: Swap any two existing characters.
- For example,
abcde -> aecdb
- For example,
- Operation 2: Transform every occurrence of one existing character into another existing character, and do the same with the other character.
- For example,
aacabb -> bbcbaa
(alla
's turn intob
's, and allb
's turn intoa
's)
- For example,
You can use the operations on either string as many times as necessary.
Given two strings, word1
and word2
, return true
if word1
and word2
are close, and false
otherwise.
class Solution:
def closeStrings(self, word1: str, word2: str) -> bool:
if len(word1) != len(word2):
return False
w1_lookup = defaultdict(int)
w2_lookup = defaultdict(int)
for i in range(len(word1)):
w1_char = word1[i]
w2_char = word2[i]
w1_lookup[w1_char] += 1
w2_lookup[w2_char] += 1
char_match = w1_lookup.keys() == w2_lookup.keys()
freqs_match = sorted(w1_lookup.values()) == sorted(w2_lookup.values())
return char_match and freqs_match
If the words do not have the same length, then nothing can be done to make them equivalent. Since we are only allowed to change one letter into another and not create new letters, the set of unique letters in each word must be identical. Additionally, the list of frequencies needs to be the same to account for single-swaps or all-swaps.
Counting
Remarks
Counting is a very common pattern with hash maps, where "counting" generally refers to tracking the frequency of different elements.
In sliding window problems, a frequent constraint is limiting the amount of a certain element in the window. For example, maybe we're trying to find the longest substring with at most k
0
s. In such problems, simply using an integer variable curr
is enough to handle the constraint because we are only focused on a single element, namely 0
. The template for variable width sliding window problems naturally suggests the use of curr
for such situations:
def fn(arr):
left = curr = ans = 0
for right in range(len(arr)):
curr += nums[right]
while left <= right and WINDOW_CONDITION_BROKEN # (e.g., curr > k):
curr -= nums[left]
left += 1
ans = max(ans, right - left + 1)
return ans
Using a hash map allows us to solve problems where the constraint involves multiple elements. For example, we would likely no longer use an integer variable curr
but a hash map variable lookup
, counts
, or something similarly named, where multiple integer variables can be used to track constraints on multiple elements (i.e., the hashable, often required to be immutable, "keys" of the hashmap effectively serve as variables where their integer values convey something about the constraint being monitored).
defaultdict
in Python
The key feature of defaultdict
is that it provides a default value for the key that does not exist. The type of this default value, usually provided in the form of a function like int
(default value 0
) or list
(default value []
) or set
(default value {}
), is specified when the defaultdict
is instantiated.
This means something as simple as tracking the character frequencies in the string "hello world"
is simplified (because we do not have to check for the key's existence first). With defaultdict
:
from collections import defaultdict
s = "hello world"
frequency = defaultdict(int)
for char in s:
frequency[char] += 1
print(frequency)
Without defaultdict
:
s = "hello world"
frequency = {}
for char in s:
if char in frequency:
frequency[char] += 1
else:
frequency[char] = 1
print(frequency)
TBD
Examples
Longest substring of string s
that contains at most k
distinct characters (✓) ★
You are given a string s
and an integer k
. Find the length of the longest substring that contains at most k
distinct characters.
For example, given s = "eceba"
and k = 2
, return 3
. The longest substring with at most 2
distinct characters is "ece"
.
def find_longest_substring(s, k):
lookup = defaultdict(int)
left = ans = 0
for right in range(len(s)):
lookup[s[right]] += 1
while left <= right and len(lookup) > k:
lookup[s[left]] -= 1
if lookup[s[left]] == 0:
del lookup[s[left]]
left += 1
ans = max(ans, right - left + 1)
return ans
LC 2248. Intersection of Multiple Arrays (✓)
Given a 2D integer array nums
where nums[i]
is a non-empty array of distinct positive integers, return the list of integers that are present in each array of nums
sorted in ascending order.
class Solution:
def intersection(self, nums: List[List[int]]) -> List[int]:
lookup = defaultdict(int)
for arr in nums:
for num in arr:
lookup[num] += 1
ans = []
for key in lookup:
if lookup[key] == len(nums):
ans.append(key)
return sorted(ans)
LC 1941. Check if All Characters Have Equal Number of Occurrences (✓)
Given a string s
, return true
if s
is a good string, or false
otherwise.
A string s
is good if all the characters that appear in s
have the same number of occurrences (i.e., the same frequency).
class Solution:
def areOccurrencesEqual(self, s: str) -> bool:
freqs = defaultdict(int)
for char in s:
freqs[char] += 1
return len(set(freqs.values())) == 1
LC 2225. Find Players With Zero or One Losses (✓)
You are given an integer array matches
where matches[i] = [winneri, loseri]
indicates that the player winneri
defeated player loseri
in a match.
Return a list answer
of size 2
where:
answer[0]
is a list of all players that have not lost any matches.answer[1]
is a list of all players that have lost exactly one match.
The values in the two lists should be returned in increasing order.
Note:
- You should only consider the players that have played at least one match.
- The testcases will be generated such that no two matches will have the same outcome.
class Solution:
def findWinners(self, matches: List[List[int]]) -> List[List[int]]:
wins = defaultdict(int)
losses = defaultdict(int)
for winner, loser in matches:
wins[winner] += 1
losses[loser] += 1
ans = [[],[]]
for winner in wins:
if winner not in losses:
ans[0].append(winner)
for loser in losses:
if losses[loser] == 1:
ans[1].append(loser)
return [sorted(ans[0]), sorted(ans[1])]
LC 1189. Maximum Number of Balloons (✓)
Given a string text
, you want to use the characters of text
to form as many instances of the word "balloon" as possible.
You can use each character in text
at most once. Return the maximum number of instances that can be formed.
class Solution:
def maxNumberOfBalloons(self, text: str) -> int:
lookup = {
'b': 0,
'a': 0,
'l': 0,
'o': 0,
'n': 0
}
for char in text:
if char in lookup:
lookup[char] += 1
return min(
lookup['b'],
lookup['a'],
lookup['l'] // 2,
lookup['o'] // 2,
lookup['n'],
)
The approach above explicitly uses a hash map, but using an array for a lookup table work just as well in this case (since we're limited to a total of 26
lowercase letter):
class Solution:
def maxNumberOfBalloons(self, text: str) -> int:
lookup = [0] * 26
for i in range(len(text)):
lookup[ord(text[i]) - 97] += 1
return min (
lookup[ord('b') - 97],
lookup[ord('a') - 97],
lookup[ord('l') - 97] // 2,
lookup[ord('o') - 97] // 2,
lookup[ord('n') - 97],
)
The hash map solution is arguably a bit cleaner than its array-based alternative.
LC 1133. Largest Unique Number (✓)
Given an array of integers A
, return the largest integer that only occurs once.
If no integer occurs once, return -1
.
class Solution:
def largestUniqueNumber(self, nums: List[int]) -> int:
lookup = defaultdict(int)
for num in nums:
lookup[num] += 1
largest = -1
for num in lookup:
if lookup[num] == 1:
largest = max(largest, num)
return largest
LC 2260. Minimum Consecutive Cards to Pick Up (✓)
You are given an integer array cards
where cards[i]
represents the value of the i
th card. A pair of cards are matching if the cards have the same value.
Return the minimum number of consecutive cards you have to pick up to have a pair of matching cards among the picked cards. If it is impossible to have matching cards, return -1
.
class Solution:
def minimumCardPickup(self, cards: List[int]) -> int:
lookup = defaultdict(int)
ans = float('inf')
for i in range(len(cards)):
card = cards[i]
if card in lookup:
ans = min(ans, i - lookup[card] + 1)
lookup[card] = i
return ans if ans != float('inf') else -1
Store the index of each card encountered in the lookup. Once we encounter a match, then we can look at the difference between indices to get the length of the subarray between matches (i.e., the minimum number of consecutive cards we'd need to pick up in order to have a pair of matching cards).
Note that we need to update the index of the card being maintained in the lookup for each iteration because our goal is to find the minimum number of consecutive cards we'd need to pick up.
LC 2342. Max Sum of a Pair With Equal Sum of Digits (✓) ★★
You are given a 0-indexed array nums
consisting of positive integers. You can choose two indices i
and j
, such that i != j
, and the sum of digits of the number nums[i]
is equal to that of nums[j]
.
Return the maximum value of nums[i] + nums[j]
that you can obtain over all possible indices i
and j
that satisfy the conditions.
class Solution:
def maximumSum(self, nums: List[int]) -> int:
def digit_sum(num):
ans = 0
while num > 0:
ans += num % 10
num //= 10
return ans
lookup = defaultdict(int)
ans = -1
for num in nums:
key = digit_sum(num)
if key in lookup:
ans = max(ans, num + lookup[key])
lookup[key] = max(num, lookup[key])
return ans
Arguably the hardest part of this problem is figuring out a way to not have to sort the numbers. Should we start by sorting all numbers as a pre-processing step? Should we sort each list of numbers after we've added them all to the hash map, where keys are digit sums?
Fortunately, we do not actually need to sort the numbers. Since we're looking for the maximum value of nums[i] + nums[j]
, our hash map can simply keep track of the largest number encountered thus far for any given digit sum. Then, once we encounter the digit sum again, we can check whether or not the overall answer needs to be updated, but every iteration we update key value for a digit sum to be the largest positive integer we've seen thus far for that digit sum. This effectively allows us to not have to sort the numbers at all.
LC 771. Jewels and Stones (✓)
You're given strings jewels
representing the types of stones that are jewels, and stones
representing the stones you have. Each character in stones
is a type of stone you have. You want to know how many of the stones you have are also jewels.
Letters are case sensitive, so "a"
is considered a different type of stone from "A"
.
class Solution:
def numJewelsInStones(self, jewels: str, stones: str) -> int:
lookup = defaultdict(int)
for char in stones:
lookup[char] += 1
ans = 0
for char in jewels:
ans += lookup[char]
return ans
Add the frequency of each stone encountered to a lookup hash map. Then use that lookup to iterate through the jewels, adding the frequency to the answer for each iteration (the characters of jewels
are unique).
Rolling prefix and referential prefixes ("exact" number of subarrays)
Remarks
Many problems can be solved using the sliding window technique (fixed or variable size), particularly problems involving subarrays that need to satisfy a given constraint. For such problems, it is frequently the case that all sub-windows of a valid window also satisfy the given constraint; for example, if the input has only positive numbers, and we're trying to find the number of subarrays that have a sum less than k
, then a valid window's sub-windows will all be valid as well.
But sometimes a given constraint can be more restrictive. For example, in some cases, we might be interested in the exact number of subarrays that satisfy some constraint like the number of subarrays whose sum equals k
. Such problems require a nuanced approach because the sliding window technique no longer applies.
The basic idea with these problems (despite their seemingly more complicated nature) is to exploit "computationally advantageous complements" in some way. What does this mean? The very basic core idea behind a solution to LC 1. Two Sum is illustrative: If we're given a target
value and another value num1
, then how can we determine whether or not another number exists, say num2
, such that num1 + num2 == target
? The answer is to look at the complement of num1
, namely target - num1
, because num1 + (target - num1) == target
. This means the num2
value we seek must be equivalent to target - num1
, the complement of num1
.
How does this apply to subarray problems involving an "exactness" constraint? In the template provided below, curr
represents a cumulative prefix for some metric we care about (e.g., subarray sum, number of odd integers, balance of 0
s and 1
s, or any other metric you might be concerned with). The lookup
hash map has "referential prefixes" as its keys with other problem-specific data as its values. Typically, the keys in lookup
will be used in some complementary fashion with curr
; that is, a core part of the problem is figuring out how to complementarily relate the cumulative or rolling prefix, curr
, with the previously encountered referential prefixes lookup
(its keys), thus yielding useful data of some sort (its values).
Here are some quick example problems to highlight appropriate choices of curr
, lookup
, the complementary relationship being exploited, and the appropriate initializion conditions for lookup
:
-
LC 560. Subarray Sum Equals K
-
curr
: Running sum of the input array -
lookup
:- keys: Each subarray sum as it's encountered
- values: The number of times (frequency) the subarray sum has been encountered
-
complementary relationship: If the array sum is
curr
, then all subarrays seen previously that have a sum ofcurr - k
should count towards our final answer sincecurr - (curr - k) == k
: -
initialization: We need to set
lookup[0] = 1
to represent the number of times we have seen a subarray with a sum of0
(i.e., the empty prefix). This ensures we do not overlook cases where the subarray meeting the condition starts at the beginning of the array; for example, ifnums = [4,1,2], k = 4
, then the subarray[4]
meets the condition butlookup[4 - 4]
would not return the correct value of1
unless we explicitly setlookup[0] = 1
at the beginning. -
solution reference:
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
lookup = defaultdict(int)
lookup[0] = 1
curr = ans = 0
for num in nums:
curr += num
ans += lookup[curr - k]
lookup[curr] += 1
return ans
-
-
LC 1248. Count Number of Nice Subarrays
-
curr
: Running total of how many odd numbers have been encountered -
lookup
:- keys: Each subarray total of odd numbers as they're encountered
- values: The number of times (frequency) a subarray has been encountered with the specified number of odd values
-
complementary relationship: If the total number of odd numbers is
curr
, then all subarrays seen previously that have an odd number count ofcurr - k
should count towards our final answer sincecurr - (curr - k) == k
: -
initialization: We need to set
lookup[0] = 1
to represent the number of times we have seen a subarray with an odd integer count of0
(i.e., the empty prefix). This ensures we do not overlook cases where the subarray meeting the condition starts at the beginning of the array; for example, ifnums = [3,1,2], k = 2
, then the subarray[3,1]
meets the condition butlookup[2 - 2]
would not return the correct value of1
unless we explicitly setlookup[0] = 1
at the beginning. -
solution reference:
class Solution:
def numberOfSubarrays(self, nums: List[int], k: int) -> int:
lookup = defaultdict(int)
lookup[0] = 1
curr = ans = 0
for num in nums:
if num % 2 == 1:
curr += 1
ans += lookup[curr - k]
lookup[curr] += 1
return ans
-
-
LC 525. Contiguous Array
-
curr
: Balance of all0
s and1
s seen thus far (0
means balanced, positive means more1
s than0
s, and negative means more0
s than1
s) -
lookup
:- keys: Balance of
0
s and1
s seen in previous subarrays - values: Earliest index of a subarray containing the specified balance of
0
s and1
s (the index recorded is the right endpoint of the subarray, inclusive)
- keys: Balance of
-
complementary relationship: Suppose we encounter a balance of
curr = 3
for a subarray (whose index we note in ourlookup
hash map) and later encounter the same balance ofcurr = 3
. This means the number of0
s and1
s added in the interim must be equivalent since the balance is the same as it was previously:Note how the subtraction for the length computation does not include the left endpoint. This is due to how we are keeping track of the balance.
-
initialization: The initialization of
lookup = {0: -1}
ensures we do not improperly compute the length of a subarray that may begin at the beginning of the input array; for example, ifnums = [0,1]
, thencurr = 0
andi - lookup[curr] = 1 - (-1) = 2
yields a sensible result whereas other definitions would not. -
solution reference:
class Solution:
def findMaxLength(self, nums: List[int]) -> int:
lookup = {0: -1}
curr = ans = 0
for i in range(len(nums)):
num = nums[i]
if num == 1:
curr += 1
else:
curr -= 1
if curr in lookup:
ans = max(ans, i - lookup[curr])
else:
lookup[curr] = i
return ans
-
Note how the first two examples are additive in nature since they are tracking the sum of numbers or the count of odd integers. The last example is more abstract and thus less straightforward. The mental model is the same for all examples though.
def fn(nums, k):
lookup = defaultdict(int) # initialize referential prefix lookup
lookup[0] = 1 # handle "empty prefix" reference
curr = 0 # initialize cumulative or "rolling" prefix
ans = 0
for i in range(len(nums)):
num = nums[i]
if CONDITION:
curr += num # update curr in a problem-specific way
# (updates will usually be conditional)
ans += lookup[curr - k] # update answer based on inputs and lookup
# (updates usually depend on a complementary relationship
# between curr and other inputs or conditions)
lookup[curr] += 1 # update lookup based on curr in a problem-specific way
return ans
Examples
LC 560. Subarray Sum Equals K (✓)
Given an array of integers nums
and an integer k
, return the total number of continuous subarrays whose sum equals to k
.
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
lookup = defaultdict(int)
lookup[0] = 1
curr = ans = 0
for num in nums:
curr += num
ans += lookup[curr - k]
lookup[curr] += 1
return ans
LC 1248. Count Number of Nice Subarrays (✓)
Given an array of integers nums
and an integer k
. A continuous subarray is called nice if there are k
odd numbers on it.
Return the number of nice sub-arrays.
class Solution:
def numberOfSubarrays(self, nums: List[int], k: int) -> int:
lookup = defaultdict(int)
lookup[0] = 1
curr = ans = 0
for num in nums:
if num % 2 == 1:
curr += 1
ans += lookup[curr - k]
lookup[curr] += 1
return ans
LC 525. Contiguous Array (✓) ★★
Given a binary array, find the maximum length of a contiguous subarray with equal number of 0
and 1
.
class Solution:
def findMaxLength(self, nums: List[int]) -> int:
lookup = {0: -1}
curr = ans = 0
for i in range(len(nums)):
num = nums[i]
if num == 1:
curr += 1
else:
curr -= 1
if curr in lookup:
ans = max(ans, i - lookup[curr])
else:
lookup[curr] = i
return ans
Monotonic increasing stack
Remarks
TBD
TBD
Examples
TBD
Prefix sum
Remarks
A prefix sum, in its conventional sense (i.e., a "sum"), is effectively a running total of the input sequence. For example, the input sequence 1, 2, 3, 4, 5, 6, ...
has 1, 3, 6, 10, 15, 21
as its prefix sum. This idea can be very useful when dealing with problems where finding sums of subarrays happens frequently. The idea is to perform an pre-processing operation at the beginning that allows summation queries to be answered in time (i.e., as opposed to each summation query taking time). Building the prefix sum can take or space depending on whether or not the input array itself is transformed or "mutated" into a prefix sum.
The non-mutation approach occurs most frequently:
def prefix_sum(nums):
prefix = [nums[0]]
for i in range(1, len(nums)):
prefix.append(nums[i] + prefix[-1])
return prefix
Its mutation variant is arguably simpler to implement:
def prefix_sum_inplace(nums):
for i in range(1, len(nums)):
nums[i] = nums[i] + nums[i - 1]
In practice, we often need to find the sum of a subarray between indices i
and j
, where i < j
. If prefix
is our prefix sum, and nums
is the input sequence, then such a sum may be found by computing the following:
prefix[j] - prefix[i] + nums[i]
Sometimes people will use prefix[j] - prefix[i - 1]
instead of prefix[j] - prefix[i] + nums[i]
, and that is fine except for the boundary case where i = 0
. It's often safest to explicitly handle the inclusive nature of prefix sums as done above.
Another approach is to initialize the prefix array as [0, nums[0]]
, which means the sum of the subarray between i
and j
is no longer prefix[j] - prefix[i - 1]
but prefix[j + 1] - prefix[i]
, which thus eliminates the left endpoint boundary issue. There's also no right endpoint boundary issue because if j
is the rightmost endpoint, then j + 1
is simply the right endpoint of the prefix sum array (because it's been extended by a single element, the prepended 0
).
Regardless, the approach prefix[j] - prefix[i] + nums[i]
is still probably the clearest.
Prefix sums that are not "sums"
As noted in the wiki article, a prefix sum requires only a binary associative operator. The operator does not have to be +
, the addition operation. The operator could just as well be x
, the multiplication operator.
def prefix_sum(nums):
prefix = [nums[0]]
for i in range(1, len(nums)):
prefix.append(nums[i] + prefix[-1])
return prefix
Examples
Boolean results of queries (see problem statement below) (✓)
Problem: Given an integer array
nums
, an arrayqueries
wherequeries[i] = [x, y]
and an integerlimit
, return a boolean array that represents the answer to each query. A query istrue
if the sum of the subarray fromx
toy
is less thanlimit
, orfalse
otherwise.For example, given
nums = [1, 6, 3, 2, 7, 2]
,queries = [[0, 3], [2, 5], [2, 4]]
, andlimit = 13
, the answer is[true, false, true]
. For each query, the subarray sums are[12, 14, 12]
.
def answer_queries(nums, queries, limit):
def prefix_sum(arr):
prefix = [arr[0]]
for i in range(1, len(arr)):
prefix.append(arr[i] + prefix[-1])
return prefix
prefix = prefix_sum(nums)
res = []
for i, j in queries:
query_result = prefix[j] - prefix[i] + nums[i]
res.append(query_result < limit)
return res
LC 2270. Number of Ways to Split Array (✓)
You are given a 0-indexed integer array nums
of length n
.
nums
contains a valid split at index i
if the following are true:
- The sum of the first
i + 1
elements is greater than or equal to the sum of the lastn - i - 1
elements. - There is at least one element to the right of
i
. That is,0 <= i < n - 1
.
Return the number of valid splits in nums
.
class Solution:
def waysToSplitArray(self, nums: List[int]) -> int:
prefix = [nums[0]]
for i in range(1, len(nums)):
prefix.append(nums[i] + prefix[-1])
valid_splits = 0
for split in range(len(nums) - 1):
left_sum = prefix[split]
right_sum = prefix[-1] - prefix[split]
if left_sum >= right_sum:
valid_splits += 1
return valid_splits
A solution based on the idea of a prefix sum like that above is a natural first start. Note that the right_sum
on each iteration is prefix[-1] - prefix[split]
instead prefix[-1] - prefix[split] + nums[split]
. The reason is because we do not want nums[split]
to be taken into account for the right-hand side (since this element is already included in the left-hand side).
Given the incremental nature of how the prefix sum is used, we can dispense with actually creating the prefix sum by adjusting the left- and right-hand sums accordingly (we get the functionality of a prefix sum without incurring the cost).
class Solution:
def waysToSplitArray(self, nums: List[int]) -> int:
left_sum = 0
right_sum = sum(nums)
valid_splits = 0
for i in range(len(nums) - 1):
left_sum += nums[i]
right_sum -= nums[i]
if left_sum >= right_sum:
valid_splits += 1
return valid_splits
LC 1480. Running Sum of 1d Array (✓)
Given an array nums
. We define a running sum of an array as runningSum[i] = sum(nums[0]…nums[i])
.
Return the running sum of nums
.
class Solution:
def runningSum(self, nums: List[int]) -> List[int]:
for i in range(1, len(nums)):
nums[i] += nums[i - 1]
return nums
This problem effectively is coming up with a prefix sum.
LC 1413. Minimum Value to Get Positive Step by Step Sum (✓) ★
Given an array of integers nums
, you start with an initial positive value startValue
.
In each iteration, you calculate the step by step sum of startValue
plus elements in nums
(from left to right).
Return the minimum positive value of startValue
such that the step by step sum is never less than 1
.
class Solution:
def minStartValue(self, nums: List[int]) -> int:
start_val = nums[0]
for i in range(1, len(nums)):
nums[i] += nums[i - 1]
start_val = min(start_val, nums[i])
return -start_val + 1 if start_val <= 0 else 1
This is a fun one. The result we need to consider is binary in nature: the lowest value encountered when cumulatively summing elements is either non-positive (i.e., negative or zero), whereby we need to reverse the sign of the lowest value and then add 1
(so as to ensure the returned value is positive) or the lowest value is itself positive and we can simply return 1
.
Regardless, we do not need the original nums
array — we can mutate it into a prefix sum that we process as we build the prefix sum. Hence, the code above is time and space.
LC 2090. K Radius Subarray Averages (✓) ★★
You are given a 0-indexed array nums
of n
integers, and an integer k
.
The k-radius average for a subarray of nums
centered at some index i
with the radius k
is the average of all elements in nums
between the indices i - k
and i + k
(inclusive). If there are less than k
elements before or after the index i
, then the k-radius average is -1
.
Build and return an array avgs
of length n
where avgs[i]
is the k-radius average for the subarray centered at index i
.
The average of x
elements is the sum of the x
elements divided by x
, using integer division. The integer division truncates toward zero, which means losing its fractional part.
- For example, the average of four elements
2
,3
,1
, and5
is(2 + 3 + 1 + 5) / 4 = 11 / 4 = 2.75
, which truncates to2
.
class Solution:
def getAverages(self, nums: List[int], k: int) -> List[int]:
prefix = [nums[0]]
for i in range(1, len(nums)):
prefix.append(nums[i] + prefix[-1])
n = len(nums)
subarray_width = 2 * k + 1
avgs = [-1] * n
for i in range(k, len(nums) - k):
subarray_sum = prefix[i + k] - prefix[i - k] + nums[i - k]
avgs[i] = subarray_sum // subarray_width
return avgs
LC 1732. Find the Highest Altitude (✓)
There is a biker going on a road trip. The road trip consists of n + 1
points at different altitudes. The biker starts his trip on point 0
with altitude equal 0
.
You are given an integer array gain
of length n
where gain[i]
is the net gain in altitude between points i
and i + 1
for all (0 <= i < n
). Return the highest altitude of a point.
class Solution:
def largestAltitude(self, gain: List[int]) -> int:
curr = highest = 0
for i in range(len(gain)):
curr += gain[i]
highest = max(highest, curr)
return highest
This is not a problem where a "pure prefix sum" solution makes sense, similar to that seen in LC 2270. Number of Ways to Split Array. Since we can process the net gains in altitudes incrementally, we can just keep track of things along the way without actually building out a prefix sum.
The hardest part of this problem is arguably understanding the very beginning — starting with a net gain in altitude of -5
means going from 0
(the start) to -5
(the first point of travel).
LC 724. Find Pivot Index (✓)
Given an array of integers nums
, calculate the pivot index of this array.
The pivot index is the index where the sum of all the numbers strictly to the left of the index is equal to the sum of all the numbers strictly to the index's right.
If the index is on the left edge of the array, then the left sum is 0
because there are no elements to the left. This also applies to the right edge of the array.
Return the leftmost pivot index. If no such index exists, return -1
.
class Solution:
def pivotIndex(self, nums: List[int]) -> int:
left_sum = 0
right_sum = sum(nums)
for i in range(len(nums)):
curr = nums[i]
right_sum -= curr
if left_sum == right_sum:
return i
left_sum += curr
return -1
This is not a "pure prefix sum" problem because we do not actually create a prefix sum; instead, we make use of the same idea behind why a prefix sum is often used. The main trick here is to not increase left_sum
by curr
until after the left_sum == right_sum
comparison is made.
LC 303. Range Sum Query - Immutable (✓)
Given an integer array nums
, find the sum of the elements between indices left
and right
inclusive, where (left <= right)
.
Implement the NumArray
class:
NumArray(int[] nums)
initializes the object with the integer arraynums
.int sumRange(int left, int right)
returns the sum of the elements of thenums
array in the range[left, right]
inclusive (i.e.,sum(nums[left], nums[left + 1], ... , nums[right])
).
class NumArray:
def __init__(self, nums: List[int]):
self.nums = nums
self.prefix = [self.nums[0]]
for i in range(1, len(self.nums)):
self.prefix.append(self.nums[i] + self.prefix[-1])
def sumRange(self, left: int, right: int) -> int:
return self.prefix[right] - self.prefix[left] + self.nums[left]
The code above is arguably the easiest to read and maintain. Below is an alternative way to bypass the declaration of self.nums
:
class NumArray:
def __init__(self, nums: List[int]):
self.prefix = [0, nums[0]]
for i in range(1, len(nums)):
self.prefix.append(nums[i] + self.prefix[-1])
def sumRange(self, left: int, right: int) -> int:
return self.prefix[right + 1] - self.prefix[left]
This works by effectively shifting the entire prefix sum array by a single unit to the right. Normally, if we wanted to find the sum of the subarray between left
and right
, inclusive, where presumably left <= right
, then we would need to compute prefix[right] - prefix[left - 1]
or prefix[right] - prefix[left] + arr[left]
(this latter option being the preferred method when left
is the left endpoint).
The alternative solution above eliminates the problem caused by the potential boundary issue of left
being the left endpoint: since the prefix array is a prefix sum, then including an extra summand of 0
does not impact things (similarly, if we had a prefix product, then including a value of 1
at the beginning would not change things).
String building
Remarks
TBD
TBD
Examples
TBD
Subarrays (number of) that fit an exact criteria
Remarks
TBD
TBD
Examples
TBD
Top k elements with a heap
Remarks
TBD
TBD
Examples
TBD