Skip to main content

Templates for Data Structures and Algorithms

Contents

Exercise symbol legend
SymbolDesignation
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

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

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.

Computing total number of elements within input array less than target value

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 ith spell and potions[j] represents the strength of the jth 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 ith 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.

Computing total number of elements within input array greater than target value (as well as insertion point)

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 be 4 + 1 = 5. What if the element to be added is not present? If we wanted to add 9, then our template function would return 4. Again, adding 1 to this result gives us our desired insertion point: 4 + 1 = 5. The correct insertion point will always be the returned value plus 1.

  • Number of elements greater than target: If x is the return value of our template function, then computing len(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, namely 10, 12, and 13; hence, the answer we want is 3. 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 simply 27 - 23 + 1 = 5. The same reasoning, albeit slightly nuanced, applies here: If index x 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 of len(arr) - 1 and the element to the right of the returned value has an index of x + 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 O(1)O(1) 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 number x+1, x+2, x+3, x+4, x+5, or x+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 to S.

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 ab=2\frac{a}{b}=2 and bc=3\frac{b}{c}=3, then we can model the process of trying to solve for ac\frac{a}{c} 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 ac=6\frac{a}{c} = 6, which we get by starting from a with a product of 11 and then multiplying it by the edge weights as we go: 1×2×3=61 \times 2\times 3 = 6.

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:

  1. Starting point is assumed to be valid, so it might not be included in the bank.
  2. If multiple mutations are needed, all mutations during in the sequence must be valid.
  3. 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 ith 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: O(n)O(n).

  • 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 O(n)O(n).

  • 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 nn elements, arr, and modify it in-place to be a heap in O(n)O(n) time. This is not a trivial task, as Python's source code for the heapify 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: O(n)O(n); space: O(1)O(1).

  • 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's heapq module:

    1. Negate the elements of arr in-place. Then use the heapify method to simulate a max heap even though Python is technically maintaining a min heap. Time: O(n)O(n); space: O(1)O(1).
    2. Loop through all elements in arr, negating each along the way, and simultaneously use the heappush method to push the element to the max heap we are building. Time: O(nlgn)O(n\lg n); space: O(n)O(n).

    The time cost of the first approach is O(n+n)=O(2n)=O(n)O(n + n) = O(2n) = O(n) since the initial loop through to negate all numbers is O(n)O(n) and the heapify method is also O(n)O(n). But practically speaking the second method is also fairly effective and more intuitive. But the first option is surely better for coding interviews!

min_heap = []
for i in range(n):
min_heap.append(i)
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 a SeatManager object that will manage n seats numbered from 1 to n. 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 given seatNumber.

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 implies x == 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 using is, returning NotImplemented 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 is NotImplemented. There are no other implied relationships among the comparison operators or default implementations; for example, the truth of (x<y or x==y) does not imply x<=y. To automatically generate ordering operations from a single root operation, see functools.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 means my_var will always point to the original some_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, namely val and next, can be modified indirectly by various means. Hence, even though my_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 though my_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):

Start
  x
12345
s
f
After first iteration
  x
12345
s
f
After second iteration
  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:

Start
    k
1234567
s
f
Move fast pointer ahead by k = 3 units
    k
1234567
s
f
After first iteration
    k
1234567
s
f
After second iteration
    k
1234567
s
f
After third iteration
    k
1234567
s
f
After fourth iteration
    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 and slow meet exactly when slow enters the cycle (i.e., at the beginning of the cycle)
  • Case 2: fast is exactly one node behind slow, and the two nodes will meet on the very next iteration since slow will move forward one node and fast will move forward two nodes
  • Case 3: fast is exactly two nodes behind slow, and the nodes will meet after two more iterations since slow will have moved two more nodes and fast will have moved four more nodes
  • Case 4: fast is more than two nodes behind slow, which means fast will eventually catch up to slow 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:

Start
  x
12345
s
f
After first iteration
  x
12345
s
f
After second iteration
  x
12345
s
f

Done! And we're fortunate for even-length lists since we're asked to return the second middle node:

Start
   x
123456
s
f
After first iteration
   x
123456
s
f
After second iteration
   x
123456
s
f
After third iteration
   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 and curr.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 means node2 is "skipped" from node1 and effectively removed from the chain. In the context of this problem, if curr.val == curr.next.val, then we want to remove curr.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 advance curr in the standard way: curr = curr.next. Note how curr 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, and 5, the middle nodes are 0, 1, 1, 2, and 2, 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 nn nodes past slow so that there are always nn nodes between these pointers. When the while loop terminates, slow will be nn units behind fast or nn 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:

  1. 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.
  2. 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.
  3. 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 assigned prev. 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 assigned curr. This moves the prev pointer one node forward to the current node, which after the assignment, becomes the new "previous" node.
    • curr is assigned curr.next (the original curr.next before any changes). This moves the curr 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 ith 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 node 0 is the twin of node 3, and node 1 is the twin of node 2. These are the only nodes with twins for n = 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, and 6th nodes are assigned to the third group, and so on. Note that the length of the last group may be less than or equal to 1 + 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 set curr equal to this function's return value. What remains is to set connect = curr before moving to the next group. We also add 1 to the next group's size, grp_size += 1, and we reset count = 0 because we add 1 to count 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 O(1)O(1) 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): 4103+8102+3101+6100=(4836)104\cdot 10^3 + 8\cdot 10^2 + 3\cdot 10^1 + 6\cdot 10^0 = (4836)_{10}.

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 of index by n, where the result is the number of times n fully goes into index. Why does this give us the row number? Because, for every row, there are n elements; hence, after every n elements, we move on to the next row.
  • index % n: This finds the remainder when index is divided by n, 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:

  1. Define window boundaries: Define pointers left and right that bound the left- and right-hand sides of the current window, respectively, where both pointers usually start at 0.
  2. Add elements to window by moving right pointer: Iterate over the source array with the right bound to "add" elements to the window.
  3. 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 ith character of s to ith 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 size arr.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 of ans 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 size arr.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 the ans variable. For example, in LC 643 we are looking for a "maximum average subarray" which means initializing ans 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 have ans = 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 until i == k. If our array only has k elements, then our updating of ans 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 and s[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() Returns true 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, and is 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 string t.
  • 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 O(26n)=O(n)O(26n) = O(n) whereas the solution above is O(n)O(n). 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 element val 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 O(1)O(1) 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 O(1)O(1), 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 O(1)O(1) 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 time t, where t represents some time in milliseconds, and returns the number of requests that has happened in the past 3000 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 window size.
  • double next(int val) Returns the moving average of the last size 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() Returns true 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, and is 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 O(1)O(1). 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 O(n)O(n) 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 O(n)O(n) 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 to cache.
  • The variable designations will be swapped so now cache is empty and storage 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 O(n)O(\sqrt{n}) amortized time. There are two cases: if cache<storage|\texttt{cache}| < \sqrt{|\texttt{storage}|}, then push takes O(n)O(\sqrt{n}) time. If cachestorage|\texttt{cache}| \geq \sqrt{|\texttt{storage}|}, then push takes O(n)O(n) time, but after this operation cache will be empty. It will take O(n)O(\sqrt{n}) time before we get to this case again, so the amortized time is O(n/n)=O(n)O(n/\sqrt{n})=O(\sqrt{n}) 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 O(1)O(1) 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 O(1)O(1) 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:

  1. Ban one senator's right: A senator can make another senator lose all his rights in this and all the following rounds.
  2. 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 ith 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 ith 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 ith element is the final price you will pay for the ith 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 ith 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 ith person can see the jth 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 ith 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 ith 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

Reference tree for templates provided below
        __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:

  1. 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.
  2. 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 process node and its entire left subtree before moving on to process nodes in the right subtree of node. 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 child 2 and right child 3): 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 process node 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 process node 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 traversal

Recursive

Remarks

TBD

def preorder_recursive_LR(node):
if not node:
return

visit(node)
preorder_recursive_LR(node.left)
preorder_recursive_LR(node.right)
Examples

TBD

Iterative

Remarks

TBD

Analogy
Pseudocode (for reference)
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:

  1. Step 1 (start seeing attractions): Begin your sightseeing journey by visiting the town's main attraction (visit the root).
  2. 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).
  3. 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.
  4. 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.
  5. 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.
  6. 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).

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)
Examples

TBD

Post-order traversal

Recursive

Remarks

TBD

def postorder_recursive_LR(node):
if not node:
return

postorder_recursive_LR(node.left)
postorder_recursive_LR(node.right)
visit(node)
Examples

TBD

Iterative

Remarks

TBD

Analogy
Pseudocode (for reference)
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()
Python (bare bones for reference)
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 by peek_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 chamber B.

    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 chamber X.

    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 chamber E.

    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 to None.

    Explored chambers: []

  • Since node currently points to None, we do not need to check for a left tunnel. Instead, we need to check for a right tunnel. peek_node = stack[-1] means peek_node points to node E since E is on top of the stack. peek_node.right has no meaningful value since chamber E has no right tunnel; hence, no tunnels remain to explore from our current chamber. We can mark chamber E as "Explored". To keep track of which chamber we last visited and to update our stack of chambers we still need to explore, we let last_node_visited = stack.pop(), meaning last_node_visited now points to node E, and our updated stack looks as follows:

    | X |
    | B |
    | A |
    +---+

    Explored chambers: [ E ]

  • Since node still points to None, 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 Iteration

    It 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] means peek_node points to chamber X. This time peek_node.right does have a meaningful value since there is a right tunnel from chamber X that leads into chamber M. Before we visit chamber M, however, we need to ask ourselves, "Have we visited chamber M yet?" Since last_node_visited points to chamber E and not chamber M, we can safely assume we have not yet visited chamber M. As such, we should prepared to visit chamber M. Update node to point to chamber M.

    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 to None.

    Explored chambers: [ E ]

  • peek_node = stack[-1] now points to chamber M. There's no right tunnel from chamber M. Mark chamber M 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 to None, we look at chamber peek_node = stack[-1], which points again to chamber X. Note that peek_node.right gives a meaningful value, namely chamber M. But we just visited chamber M and marked it as explored. Visiting chamber M again would not make any sense. Fortunately, we noted which chamber we last visited with last_node_visited. This variable points to chamber M.

    Hence, the second part of the and portion of

    peek_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 chamber X 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 to None. peek_node = stack[-1] means peek_node now points to chamber B. We see that peek_node.right has a meaningful value, namely chamber S. Furthermore, last_node_visited points to chamber X, not chamber S. Hence, we should explore the right tunnel from chamber B that begins with chamber S.

    Explored chambers: [ E M X ]

  • node now points to chamber S. 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 to None.

    Explored chambers: [ E M X ]

  • node now points to None. And peek_node = stack[-1] points to chamber S. And peek_node.right does not give a meaningful value, meaning chamber S has no right tunnel. Mark chamber S 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 to None. peek_node = stack[-1] points to chamber B again. peek_node.right points to chamber S, but last_node_visited also points to chamber S. Hence, mark chamber B 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 to None. peek_node = stack[-1] points to chamber A. peek_node.right points to chamber W. Since last_node_visited points to chamber B and not chamber W, this means we should prepare to visit the right tunnel from chamber A that begins with chamber W. Update node to point to chamber W.

    Explored chambers: [ E M X S B ]

  • node points to chamber W. 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 chamber T.

    Explored chambers: [ E M X S B ]

  • node points to chamber T. 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 chamber P.

    Explored chambers: [ E M X S B ]

  • node points to chamber P. 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 to None.

    Explored chambers: [ E M X S B ]

  • node points to None. And peek_node = stack[-1] points to chamber P. Since peek_node.right does not have a meaningful value (i.e., chamber P has no right tunnel), we may mark chamber P 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 to None. And peek_node = stack[-1] points to chamber T. We look for a right tunnel and see that peek_node.right reveals chamber N. Since last_node_visited points to chamber P and not chamber N, we prepare to explore chamber N. Update node to point to chamber N.

    Explored chambers: [ E M X S B P ]

  • node points to chamber N. 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 to None.

    Explored chambers: [ E M X S B P ]

  • node points to None. And peek_node = stack[-1] points to chamber N. Since peek_node.right does not provide a meaningful value (i.e., chamber N has no right tunnel), we may mark chamber N 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 to None. And peek_node = stack[-1] points to chamber T again. And peek_node.right points to chamber N. But last_node_visited also points to chamber N, indicating we should not explore chamber N. Instead, we should mark chamber T 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 to None. And peek_node = stack[-1] points to chamber W. And peek_node.right points to chamber C. Since last_node_visited points to chamber T and not chamber C, we should prepare to visit chamber C. Update node to point to chamber C.

    Explored chambers: [ E M X S B P N T ]

  • node points to chamber C. 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 chamber H.

    Explored chambers: [ E M X S B P N T ]

  • node points to chamber H. 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 to None.

    Explored chambers: [ E M X S B P N T ]

  • node points to None. And peek_node = stack[-1] points to chamber H. Since peek_node.right does not provide a meaningful value, we may mark chamber H 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 to None. And peek_node = stack[-1] points to chamber C. Since peek_node.right does not provide a meaningful value, we may mark chamber C 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 to None. And peek_node = stack[-1] points to chamber W. Even though peek_node.right points to chamber C, we see that last_node_visited also points to chamber C, meaning we should not visit chamber C. Mark chamber W 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 to None. And peek_node = stack[-1] points to chamber A. Even though peek_node.right points to chamber W, we see that last_node_visited also points to chamber W, meaning we should not visit chamber W. Mark chamber A 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.

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()
Examples

TBD

In-order traversal

Recursive

Remarks

TBD

def inorder_recursive_LR(node):
if not node:
return

inorder_recursive_LR(node.left)
visit(node)
inorder_recursive_LR(node.right)
Examples

TBD

Iterative

Remarks

TBD

Analogy
Pseudocode (for reference)
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
note

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. The Bear and the Dragon (2000) - President Jack Ryan oversees a complex geopolitical situation involving China, Russia, and the prospect of World War III.

  10. 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.

  11. 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
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
Examples

TBD

Level-order traversal

Remarks

TBD

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)
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.

When the induction template is not applicable

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 equal None. The last line of the solution above could just as well be return 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 O(n)O(n) 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 (O(n)O(n) 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 the node parameter (we're not usually allowed to alter the solution 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 and node.right).

    Template usage: This means defining the visit function with more than just the node 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 res

    Whatever 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 value

    If, 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 O(n)O(n) run time because only nn iterations of the while loop may occur — the left and right pointers begin nn 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 O(1)O(1), then the result will be an O(n)O(n) 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 character c is alphanumeric if one of the following returns True: c.isalpha(), c.isdecimal(), c.isdigit(), or c.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 n>1n > 1 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:

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:

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:

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" and ch = "d", then you should reverse the segment that starts at 0 and ends at 3 (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 nn 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):

Quicksort Sorting Solution (LC 912)
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
Dutch National Flag Problem Solution (LC 75)
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 O(1)O(1):

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 O(n)O(n) time and O(n)O(n) space, but as the follow-up for this problem suggests, we can do better on the space, specifically O(1)O(1). 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 O(1)O(1) 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 O(1)O(1) 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 O(1)O(1) operation. Checking for existence in an array, however, is an O(n)O(n) operation. This means a number of algorithms can often be improved from O(n2)O(n^2) to O(n)O(n) 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 letter b if the second occurrence of a is before the second occurrence of b.
  • 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 nn 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 nn 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 in lookup with lookup[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
  • 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 (all a's turn into b's, and all b's turn into a's)

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 0s. 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 ith 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 0s and 1s, 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 of curr - k should count towards our final answer since curr - (curr - k) == k:

      lookup[curr - kkey representing subarrayswith sum of curr - k]number or frequency of subarraysseen so far with a sum of curr - k\overbrace{\texttt{lookup}[\underbrace{\texttt{curr - k}}_{\substack{\text{key representing subarrays}\\\text{with sum of curr - k}}}]}^{\substack{\text{number or frequency of subarrays}\\\text{seen so far with a sum of curr - k}}}
    • initialization: We need to set lookup[0] = 1 to represent the number of times we have seen a subarray with a sum of 0 (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, if nums = [4,1,2], k = 4, then the subarray [4] meets the condition but lookup[4 - 4] would not return the correct value of 1 unless we explicitly set lookup[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 of curr - k should count towards our final answer since curr - (curr - k) == k:

      lookup[curr - kkey representing subarrayswith odd integer count of curr - k]number or frequency of subarraysseen so far with odd integer count of curr - k\overbrace{\texttt{lookup}[\underbrace{\texttt{curr - k}}_{\substack{\text{key representing subarrays}\\\text{with odd integer count of curr - k}}}]}^{\substack{\text{number or frequency of subarrays}\\\text{seen so far with odd integer count of curr - 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 of 0 (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, if nums = [3,1,2], k = 2, then the subarray [3,1] meets the condition but lookup[2 - 2] would not return the correct value of 1 unless we explicitly set lookup[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 all 0s and 1s seen thus far (0 means balanced, positive means more 1s than 0s, and negative means more 0s than 1s)

    • lookup:

      • keys: Balance of 0s and 1s seen in previous subarrays
      • values: Earliest index of a subarray containing the specified balance of 0s and 1s (the index recorded is the right endpoint of the subarray, inclusive)
    • complementary relationship: Suppose we encounter a balance of curr = 3 for a subarray (whose index we note in our lookup hash map) and later encounter the same balance of curr = 3. This means the number of 0s and 1s added in the interim must be equivalent since the balance is the same as it was previously:

      [1,1,1curr = 3index = 2,x,x,x,x,x,x0s and 1s addedin the interimmust be balancedcurr = 3index = 8length = 8 - 2 = 6,][\underbrace{\underbrace{1,1,1}_{\substack{\text{curr = 3}\\\text{index = 2}}},\underbrace{\overbrace{x,x,x,x,x,x}^{\substack{\text{0s and 1s added}\\\text{in the interim}}}}_{\text{must be balanced}}}_{\substack{\text{curr = 3}\\\text{index = 8}\\\text{length = 8 - 2 = 6}}},\ldots]

      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, if nums = [0,1], then curr = 0 and i - 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 O(n)O(n) pre-processing operation at the beginning that allows summation queries to be answered in O(1)O(1) time (i.e., as opposed to each summation query taking O(n)O(n) time). Building the prefix sum can take O(n)O(n) or O(1)O(1) space depending on whether or not the input array itself is transformed or "mutated" into a prefix sum.

The O(n)O(n) 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 O(1)O(1) 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 array queries where queries[i] = [x, y] and an integer limit, return a boolean array that represents the answer to each query. A query is true if the sum of the subarray from x to y is less than limit, or false otherwise.

For example, given nums = [1, 6, 3, 2, 7, 2], queries = [[0, 3], [2, 5], [2, 4]], and limit = 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 last n - 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 O(n)O(n) time and O(1)O(1) 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, and 5 is (2 + 3 + 1 + 5) / 4 = 11 / 4 = 2.75, which truncates to 2.

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 array nums.
  • int sumRange(int left, int right) returns the sum of the elements of the nums 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