Coding Interview Patterns - Nails your interview - Alex Xu - 2024
Coding Interview Patterns - Nails your interview - Alex Xu - 2024
BONUS PDF
All rights reserved. This PDF or any portion thereof may not be reproduced or used in any manner
whatsoever without the express written permission of the publisher except for the use of brief
quotations in a book review.
Come join us and introduce yourself to the community today! Use the link below or scan the
barcode:
bit.ly/coding-patterns-discord
2
Contents
Two Pointers........................................................................................................................4
Shift Zeros to the End................................................................................................................................................. 4
Next Lexicographical Sequence.............................................................................................................................. 9
Hash Maps and Sets........................................................................................................16
Longest Chain of Consecutive Numbers.......................................................................................................... 16
Geometric Sequence Triplets................................................................................................................................20
Linked Lists........................................................................................................................27
Palindromic Linked List............................................................................................................................................27
Flatten a Multi-Level Linked List..........................................................................................................................31
Fast and Slow Pointers.................................................................................................. 36
Happy Number Time Complexity Analysis......................................................................................................36
Binary Search................................................................................................................... 39
Find the Median From Two Sorted Arrays.......................................................................................................39
Matrix Search.............................................................................................................................................................. 46
Local Maxima in Array..............................................................................................................................................52
Weighted Random Selection.................................................................................................................................58
Stacks.................................................................................................................................. 65
Repeated Removal of Adjacent Duplicates.....................................................................................................65
Implement a Queue Using Stacks........................................................................................................................68
Maximums of Sliding Window.............................................................................................................................. 74
Heaps.................................................................................................................................. 81
Sort a K-Sorted Array............................................................................................................................................... 81
Trees.................................................................................................................................... 88
Binary Tree Symmetry............................................................................................................................................. 88
Binary Tree Columns................................................................................................................................................ 92
Kth Smallest Number in a Binary Search Tree................................................................................................97
Serialize and Deserialize a Binary Tree.......................................................................................................... 102
3
Graphs.............................................................................................................................. 109
Shortest Path............................................................................................................................................................ 109
Connect the Dots.................................................................................................................................................... 117
Backtracking.................................................................................................................. 124
Combinations of a Sum......................................................................................................................................... 124
Phone Keypad Combinations.............................................................................................................................129
Dynamic Programming...............................................................................................133
Largest Square in a Matrix...................................................................................................................................133
Sort and Search............................................................................................................. 141
Dutch National Flag............................................................................................................................................... 141
Math and Geometry.................................................................................................... 146
The Josephus Problem........................................................................................................146
Triangle Numbers....................................................................................................................................................150
4
Two Pointers
Example:
Input: nums = [0, 1, 0, 3, 2]
Output: [1, 3, 2, 0, 0]
Intuition
This problem has three main requirements:
1. Move all zeros to the end of the array.
2. Maintain the relative order of the non-zero elements.
3. Perform the modification in place.
A naive approach to this problem is to build the output using a separate array (temp). We can add
all non-zero elements from the left of nums to this temporary array and leave the rest of it as zeros.
Then, we just set the input array equal to temp.
By identifying and moving the non-zero elements from the left side of the array first, we ensure
their order is preserved when we add them to the output:
Unfortunately, this solution violates the third requirement of modifying the input array in place.
However, there's still valuable insight to be gained from this approach. In particular, notice that
this solution focuses on the non-zero elements instead of zeros. This means if we change our goal
to move all non-zero elements to the left of the array, the zeros will consequently end up on the
right. Therefore, we only need to focus on non-zero elements:
If there was a way to iterate over the above range of the array where the non-zero elements go, we
could iteratively place each non-zero element in that range.
Two pointers
We can use two pointers for this:
● A left pointer to iterate over the left of the array where the non-zero elements should be
placed.
● A right pointer to find non-zero elements.
Consider the example below. Start by placing the left and right pointers at the start of the array.
Before we move non-zero elements to the left, we need the right pointer to be pointing at a
non-zero element. So, we ignore the zero at the first element and increment right:
Now, the value at the right pointer is non-zero. Let’s discuss how to handle this case.
1. Swap the elements at left and right: First, we’d like to move the element at the right pointer
to the left of the array. So, we swap it with the element at the left pointer.
We can apply this logic to the rest of the array, incrementing the right pointer at each step to find
the next non-zero element:
Implementation
You might have noticed that we always move the right pointer forward, regardless of whether it
points to a zero or a non-zero. This allows us to use a for-loop to iterate the right pointer.
Complexity Analysis
Time complexity: The time complexity of shift_zeros_to_the_end is 𝑂(𝑛), where 𝑛 denotes the
length of the array. This is because we iterate through the input array once.
Space complexity: The space complexity is 𝑂(1) because shifting is done in place.
Test Cases
In addition to the examples discussed, below are more examples to consider when testing your
code.
Input Expected output Description
Example 1:
Input: s = "abcd"
Output: "abdc"
Explanation: "abdc" is the next sequence in lexicographical order after rearranging "abcd".
Example 2:
Input: s = "dcba"
Output: "abcd"
Explanation: Since "dcba" is the last sequence in lexicographical order, we return the first
sequence: "abcd".
Constraints:
● The string contains at least one character.
Intuition
Before devising a solution, let’s first make sure we understand what the next lexicographical
sequence of a string is.
It’s useful to think about the next lexicographical sequence as the first string that’s
lexicographically larger than the original string. Consider the string “abc” and all its permutations in
a lexicographically ordered sequence:
From this, we also notice the next string in the sequence after “abc” is “acb”, which is the first string
larger than the original string:
This gives us some indication of what we need to find. The next lexicographical string:
1. Incurs the smallest possible lexicographical increase from the original string.
2. Uses the same letters as the original string.
To understand why, imagine trying to “increase” a string’s value. Increasing the rightmost letter
results in a smaller increase than increasing the leftmost letter.
Therefore, we should focus on rearranging characters on the right-hand side of the string first, if
possible.
How does this help us? We know we need to rearrange the characters on the right of the string,
but we don’t know how many to rearrange. From now on, let’s refer to the rightmost characters
that should be rearranged as the suffix.
Take the string "abcedda" as an example. We traverse it from right to left with the goal of finding
the shortest suffix that can be rearranged to form a larger permutation. The last 4 characters
form a non-increasing suffix, and cannot be rearranged to make the string larger:
If no pivot is found, it means the string is already the last lexicographical sequence. In this case, we
need to obtain its first lexicographical permutation as the problem states. This can be done by
reversing the string:
To make the character at the pivot position larger, we’d need to swap the pivot with a character
larger than it on the right.
In our example, which character should our pivot (‘c’) swap with? We want to swap it with a
character larger than 'c', but not too much larger because the increase should be as small as
possible. Let’s figure out how we find such a character.
Since the substring after the pivot is lexicographically non-increasing, we can find the closest
character larger than ‘c’ by traversing this suffix from right to left and stopping at the first
character larger than it. In other words, we’re finding the rightmost successor to the pivot, which
is ‘d’:
The character at the pivot has increased, so to get the next permutation, we should make the
substring after the pivot as small as possible.
An important observation is that after the previous swap, the substring after the pivot is still
lexicographically non-increasing.
Many steps are involved in identifying the next lexicographical sequence. So, here’s a summary:
3. Swap the rightmost successor with the pivot to increase the lexicographical order of the
suffix.
Implementation
Complexity Analysis
Time complexity: The time complexity of next_lexicographical_sequence is 𝑂(𝑛), where 𝑛
denotes the length of the input string. This is because we perform a maximum of two iterations
across the string: one to find the pivot and another to find the rightmost character in the suffix
that’s greater in value than the pivot. We also perform one reversal (either at the end or if no pivot
is found), which takes 𝑂(𝑛) time.
Space complexity: The space complexity is 𝑂(𝑛) due to the space taken up by the letters list. In
Python, this list is created because strings are immutable, which necessitates storing the input
string as a list. Note, the final output string is not considered in the space complexity.
Test Cases
In addition to the examples discussed, below are more examples to consider when testing.
Input Expected output Description
Interview Tip
Tip: Be precise with your language.
It’s crucial to be precise with your choice of words during an interview, especially for technical
descriptions. For instance, in this problem, we use “non-increasing” instead of “decreasing,” as
“decreasing” implies each term is strictly smaller than the previous one, which isn’t true in this
case since adjacent characters can be equal.
Example:
Input: nums = [1, 6, 2, 5, 8, 7, 10, 3]
Output: 4
Explanation: The longest chain of consecutive numbers is 5, 6, 7, 8.
Intuition
A naive approach to this problem is to sort the array. When all numbers are arranged in ascending
order, consecutive numbers will be placed next to each other. This allows us to traverse the array
to identify the longest sequence of consecutive numbers.
This approach requires sorting, which takes 𝑂(𝑛𝑙𝑜𝑔(𝑛)) time, where 𝑛 denotes the length of the
array. Let’s see how we could do better.
It’s important to understand that every number in the array can represent the start of some
consecutive chain. One approach is to treat each number as the start of a chain and search through
the array to identify the rest of its chain.
To do this, we can leverage the fact that for any number num, its next consecutive number will be
num + 1. This means we’ll always know which number to look for when trying to find the next
number in a sequence. The code snippet for this approach is provided below:
3
This brute force approach takes 𝑂(𝑛 ) time because of the nested operations involved:
● The outer for-loop iterates through each element, which takes 𝑂(𝑛) time.
● For each element, the inner while-loop can potentially run up to 𝑛 iterations if there’s a
long consecutive sequence starting from the current number.
● For each, while-loop iteration, an 𝑂(𝑛) check is performed to see if the next consecutive
number exists in the array.
This is slower than the sorting approach, but we can make a couple of optimizations to improve the
time complexity. Let’s discuss these.
3 2
This reduces the time complexity from 𝑂(𝑛 ) to 𝑂(𝑛 ).
We can determine if a number is the smallest number in its chain by checking the array doesn’t
contain the number that precedes it (curr_num - 1). We can also use the hash set for this check.
2
This reduces the time complexity from 𝑂(𝑛 ) to 𝑂(𝑛), as now every chain is searched through only
once. This is explained in more detail in the complexity analysis.
Implementation
Complexity Analysis
Time complexity: The time complexity of longest_chain_of_consecutive_numbers is 𝑂(𝑛)
because, although there are two loops, the inner loop is only executed when the current number is
the start of a chain. This ensures each chain is iterated through only once in the inner while-loop.
Thus, the total number of iterations for both loops combined is 𝑂(𝑛): the outer for-loop runs 𝑛
times, and the inner while-loop runs a total of 𝑛 times across all iterations, resulting in a combined
time complexity of 𝑂(𝑛 + 𝑛) = 𝑂(𝑛).
Space complexity: The space complexity is 𝑂(𝑛) since the hash set stores each unique number
from the array.
Given an array of integers and a common ratio r, find all triplets of indexes (i, j, k) that follow a
geometric sequence for i < j < k. It’s possible to encounter duplicate triplets in the array.
Example:
Intuition
For a triplet to form a geometric sequence, it has to adhere to two main rules:
1. It consists of three values that follow a geometric sequence with a common ratio r.
2. The three values forming the triplet must appear in the same order within the array as they
do in the geometric sequence. This means for a geometric triplet (nums[i], nums[j],
nums[k]), the indexes must follow the order i < j < k.
A brute force approach is to iterate over all possible triplet in the array to check if any of them
follow a geometric progression. However, it would take three nested for-loops to search through
3
all the triplets, resulting in a time complexity of 𝑂(𝑛 ), where 𝑛 denotes the length of the input
array. Can we do better?
An important observation here is that if we know one value of a triplet, we can calculate what
the other two values should be.
This is because all three values are related by the common ratio r. So, for any number x in the
array, we just need to find the values x·r and x·r2 to form a geometric triplet (x, x·r, x·r2).
However, we could run into issues when using this triplet representation. While it’s clear the
values x·r and x·r2 must be positioned to the right of x, we have to be careful since the order of
these values matters: we don’t want to accidentally identify a triplet such as (x, x·r2, x·r),
which is invalid:
We can work around this issue by using the (x/r, x, x·r) triplet representation, which allows
us to always maintain order by looking for x/r to the left of x and x·r to the right:
One way we can find the x/r and x·r values is by linearly searching through the left and right
subarrays. This linear search would need to be done for each number in the array, resulting in an
2
𝑂(𝑛 ) time complexity. While this is an improvement from the brute force solution, it would be
great if we had a way to find those values faster.
Hash maps
A hash map would be a great way to solve this problem, as it allows us to query specific values in
constant time.
Hash maps allow us to query for both x/r in the left hash map and query for x·r in the right hash
map in constant time on average. Note that a hash map would be preferred over a hash set
because hash maps can also store the frequency of each value it stores. This is crucial since the
array might contain duplicates, and we need to know the frequency of each value to accurately
identify all possible triplets.
Before we find a triplet’s x/r value, we need to check if x is divisible by r. If it’s not, it’s impossible
to form a triplet from the current value of x. Otherwise, we can proceed to look for the triplet.
For any element x, there could be multiple instances of x/r in left_map and multiple instances of
x·r in right_map, implying that multiple triplets can be formed using x as the middle value. So, to
get the total number of triplets that can be formed with x in the middle, multiply the frequencies
of x·r and x/r:
To ensure the hash maps always contain the correct values, we’d need to incorporate a dynamic
strategy that involves updating the hash maps as we go because the values in both hash maps will
be different depending on the position of x in the array.
Since we’re traversing the array from left to right, we should initially fill the right hash map with
all values in the array. This is because, before the start of the iteration, every element is a
potential candidate for x·r. Meanwhile, the left hash map is initially empty because there are no
preceding elements to consider as potential x/r values:
Now let’s look for triplets. Start by representing the first value as the middle value (x) of a triplet.
First, let’s update right_map. We should remove the current value (2) from right_map since this
2 is not to the right of itself. There are two 2’s in right_map, so let’s reduce its frequency to 1:
Next, check if x/r is an integer. In this case, it is, so let’s find the number of triplets with x as the
middle number by multiplying the frequencies of x/r and x·r, which we can get from the
respective hash maps. Since left_map doesn’t contain x/r at this point, its frequency is 0:
Repeating this process for the rest of the array allows us to find all geometric triplets with a ratio
of r. To clarify, the hash maps in the upcoming diagrams represent their state at the current
position of x in the array. This means that left_map includes values to the left of the current x, and
the right_map includes values to the right of it.
Complexity Analysis
Time complexity: The time complexity of geometric_sequence_triplets is 𝑂(𝑛) because we
iterate through the nums array and perform constant-time hash map operations at each iteration.
Space complexity: The space complexity is 𝑂(𝑛) because the hash maps can grow up to 𝑛 in size.
Example 1:
Output: True
Example 2:
Output: False
Intuition
A linked list would be palindromic if its values read the same forward and backward. A naive way
to check this would be to store all the values of the linked list in an array, allowing us to freely
traverse these values forward and backward to confirm if it’s palindromic. However, this uses
linear space. Instead, it would be better if we had a way to traverse the linked list in reverse order
to confirm if it's a palindrome. Is there a way to go about this?
Going off the above definition, we know that if a linked list is a palindrome, reversing it would
result in the same sequence of values.
An important observation is that we only need to compare the first half of the original linked list
with the reverse of the second half (if there are an odd number of elements, we can just include
the middle node in both halves) to check if the linked list is a palindrome:
Notice that step 2 involves modifying the input. In this problem, let’s assume this is acceptable.
However, it's always good to check with the interviewer if changing the input is allowed before
moving forward with the solution.
Now, let’s see how these two steps can be applied. Start by obtaining the middle node (mid) of the
linked list.
To learn how to get to the middle of a linked list, read the explanation in the Linked List Midpoint
problem in the Fast and Slow Pointers chapter.
Then, reverse the second half of the linked list starting at mid. The last node of the original linked
list becomes the head of the second half. This second head is used to traverse the newly reversed
second half.
The last thing we need to do is check if the first half matches the now-reversed second half. We can
do this by simultaneously traversing both halves node by node, and comparing each node from the
first half to the corresponding node from the second half. If at any point the node values don't
match, it indicates the linked list is not a palindrome.
We can use two pointers (ptr1 and ptr2) to iterate through the first and the reversed second half
of the linked list, respectively:
Implementation
Complexity Analysis
Time complexity: The time complexity of palindromic_linked_list is 𝑂(𝑛), where 𝑛 denotes
the length of the linked list. This is because it involves iterating through the linked list three times:
once to find the middle node, once to reverse the second half, and once more to compare the two
halves.
Interview Tip
Tip: Confirm if it’s acceptable to modify the linked list.
In our solution, we reversed the second half of the linked list which dismantled the input’s initial
structure. Why does this matter? Oftentimes, the input data structure should not be modified,
particularly if it's shared or accessed concurrently. As such, it’s important to confirm with your
interviewer whether input modification is acceptable and to briefly address the implications of
this.
Flatten the multi-level linked list into a single-level linked list by linking the end of each level to
the start of the next one.
Example:
Intuition
Consider the two main conditions required to form the flattened linked list:
1. The order of the nodes on each level needs to be preserved.
2. All the nodes in one level must connect before appending nodes from the next level.
The challenge with this problem is figuring out how we process linked lists in lower levels. One
strategy that might come to mind is level-order traversal using breadth-first search. However,
breadth-first search usually involves the use of a queue, which would result in at least a linear
space complexity. Is there a way we could merge the levels of the linked lists in place?
A key observation is that for any level of the multi-level linked list, we have direct access to all
the nodes on the next level. This is because each node’s child node at any given level ‘L’ has direct
access to nodes on the next level ‘L + 1’:
So, with all the nodes on level ‘L + 1’ appended to level ‘L’, we can continue this process by
appending nodes from level ‘L + 2’ to level ‘L + 1’, and so on.
Now that we have a high-level idea about what we should do, let’s try this strategy on the following
example:
We’ll start by appending level 2’s nodes to the end of level 1. Before we can do this, we would need
a reference to level 1’s tail node so we can easily add nodes to the end of the linked list. To set this
reference, advance through level 1's linked list using a tail pointer until it reaches the last node,
which happens when tail.next is equal to null:
To add this child linked list to the end of the tail node, set tail.next to the head of the child list:
Before incrementing curr to find the next node with a child linked list, we need to readjust the
position of the tail pointer so it’s pointing at the last node of the newly extended linked list (node 6
in this case). Again, we can do this by advancing the tail pointer until its next node is null:
With the tail pointer now repositioned, we can continue this process of:
After the process is complete, we can return head, which is the head of the flattened linked list.
One last important detail to mention is that after appending any child linked list to the tail, we
should nullify the child attribute to ensure the linked list is fully flattened.
Implementation
The definition of the MultiLevelListNode class is provided below:
Complexity Analysis
Time complexity: The time complexity of flatten_multi_level_list is 𝑂(𝑛), where 𝑛 denotes
the number of nodes in the multi-level linked list. This is because we iterate through each node in
the multi-level linked list at most twice: once to iterate tail and once to iterate curr.
Space complexity: We only allocated a constant number of variables, so the space complexity is
𝑂(1).
1 9 81
2 99 162
3 999 243
4 9999 324
5 99999 405
6 999999 486
Let's call 𝑛’s next number 𝑛2. The next number after 𝑛2 (𝑛3) will take approximately 𝑙𝑜𝑔(𝑛2) steps to
calculate. The next number after 𝑛3 (𝑛4) will take approximately 𝑙𝑜𝑔(𝑛3) steps to calculate, and so
on. From this, we can summarize the time complexity of this process as
𝑂(𝑙𝑜𝑔(𝑛) + 𝑙𝑜𝑔(𝑛2) + 𝑙𝑜𝑔(𝑛3) + …). Since we've established that 𝑛 > 𝑛2 > … > 𝑛𝑘 where 𝑛𝑘 is
the last number greater than 243, the dominant component of this time complexity is 𝑂(𝑙𝑜𝑔(𝑛)).
So, the time complexity for numbers greater than 243 is 𝑂(𝑙𝑜𝑔(𝑛)).
Conclusion
When 𝑛 is less than 243, the time complexity is 𝑂(1), and when 𝑛 is greater than 243, the time
complexity is 𝑂(𝑙𝑜𝑔(𝑛)). Therefore, the overall time complexity of the algorithm is 𝑂(𝑙𝑜𝑔(𝑛)).
Interview Tip
Tip: Don’t waste time on complex proofs if it isn’t an important part of the interview.
During an interview, correctly deciphering the exact time of an algorithm like the one used to
solve this problem isn't usually expected. In situations like this, you can instead make an
educated guess about how the algorithm's runtime would grow with larger inputs based on the
behavior of the algorithm. Mention any assumptions you make when discussing your estimates.
Example 1:
Input: nums1 = [0, 2, 5, 6, 8], nums2 = [1, 3, 7]
Output: 4.0
Explanation: Merging both arrays results in [0, 1, 2, 3, 5, 6, 7, 8], which has a
median of (3 + 5) / 2 = 4.0.
Example 2:
Input: nums1 = [0, 2, 5, 6, 8], nums2 = [1, 3, 7, 9]
Output: 5.0
Explanation: Merging both arrays results in [0, 1, 2, 3, 5, 6, 7, 8, 9], which has a
median of 5.0.
Constraints:
● At least one of the input arrays will contain an element.
Intuition
The brute force approach to this problem involves merging both arrays and finding the median in
this merged array. This approach takes 𝑂((𝑚 + 𝑛)𝑙𝑜𝑔(𝑚 + 𝑛)) time, where 𝑚 and 𝑛 denote the
lengths of each array, respectively. This is primarily due to the cost of sorting the merged array of
length 𝑚 + 𝑛. This approach can be improved to 𝑂(𝑚 + 𝑛) time by merging both arrays in order,
which is possible because both arrays are already individually sorted. However, is there a way to
find the median without merging the two arrays?
Consider the following two arrays that have an even total length:
Below is what these two arrays would look like when merged. Let’s see if we can draw any insights
from this.
Observe that the merged array can be divided into two halves, which reveals the median values on
the inner edge of each half.
A challenge here is identifying which values in either input array belong to the left half of the
merged array, and which belong to the right half. One thing we do know is the size of each half of
the merged array: 4, half of the total length.
As we can see, there are several ways to slice the arrays to produce two partitions of equal size (4).
However, only one of these slices corresponds to the halves of the merged array. In our example,
it’s this slice:
We can assess this by comparing the two end values of the left partition with the start values of
the right partition (illustrated below). Let’s refer to the end values of the left partition as L1 and L2,
respectively. Similarly, let’s call the start values of the right partition R1 and R2.
Since the values in each array are sorted, we know that conditions L1 ≤ R1 and L2 ≤ R2 are always
true. Then, all we have to do is check that L1 ≤ R2 and L2 ≤ R1. We can observe how this
comparison reveals the correct slice among the previous three example slices:
Notice that in the third example above, the second array does not contribute any values to the left
partition. So, to work around this, we set the second array's left value to -∞ so that L2 ≤ R1 is true
by default.
Let’s take a closer look at how this works. Once we identify L1’s index, we can calculate L2’s index
based on L1’s index, which is demonstrated in the diagram below. R1 and R2 are just the values
immediately to the right of L1 and L2, respectively.
Since we search for L1 over nums1, which is a sorted array, we can use binary search instead of
searching for it linearly. The search space will encompass all values of the nums1.
Let’s figure out how to narrow the search space. Here, we’ll define the midpoint as L1_index,
since it’s also the index of L1. Let’s discuss how the search space is narrowed based on these
conditions:
● If L1 > R2, then L1 is larger than it should be as we expect L1 to be less than or equal to R2.
To search for a smaller L1, narrow the search space toward the left:
● If L2 > R1, then R1 is smaller than it should be as we expect R1 to be greater than or equal
to L2. To search for a larger R1, narrow the search space toward the right:
So, to return the median, we just return the sum of these two values, divided by 2 using
floating-point division.
So, after the binary search narrows down the correct slice, we can just return the smallest value
between R1 and R2.
Implementation
Complexity Analysis
Time complexity: The time complexity of the find_the_median_from_two_sorted_arays
function is 𝑂(𝑙𝑜𝑔(𝑚𝑖𝑛(𝑚, 𝑛))) because we perform binary search over the smaller of the two input
arrays.
Note: this explanation refers to the two middle values as “median values” to keep things simple. However,
it’s important to understand that these two values aren’t technically “medians,” as there's only ever one
median. These are just the two values used to calculate the median.
Example:
Output: True
Intuition
A naive solution to this problem is to linearly scan the matrix until we encounter the target value.
However, this isn’t taking advantage of the sorted properties of the matrix.
A key observation is that all values in a given row are greater than or equal to all values in the
previous row. This indicates the entire matrix can be considered as a single, continuous, sorted
sequence of values:
If we were able to flatten this matrix into a single, sorted array, we could perform a binary search
on the array. Creating a separate array and populating it with the matrix’s values still takes
Let’s map the indexes of the flattened array to their corresponding cells in the matrix:
This index mapping would give us a way to access the elements of the matrix in a similar way to
how we would access them in the flattened array. To figure out how to do this, let’s find a way to
map any cell (r, c) to its corresponding index in the flattened array.
Let’s start by examining the mapped indexes of each row of the matrix:
● Row 0 starts at index 0.
● Row 1 starts at index n.
● Row 2 starts at index 2n.
From the above observations, we see a pattern: for any row r, the first cell of the row corresponds
to the index r·n.
When we also consider the column value c, we can conclude that for any cell (r, c), the
corresponding index in the flattened array is r·n + c.
Now that we have these formulas, let’s use binary search to find the target.
Binary search
To define the search space, we need to idenitfy the first and last indexes of the flattened array. The
first index is 0, and the last index is m·n - 1. So, we set the left and right pointers to 0 and m·n -
1, respectively.
To figure out how to narrow the search space, let’s explore an example matrix that contains the
target of 21.
We can calculate mid using the formula: mid = (left + right) // 2. Then, determine the
corresponding row and column values. Here, the value at the midpoint (10) is less than the target,
which means the target is to the right of the midpoint. So, let’s narrow the search space toward the
right:
The midpoint value is now larger than the target, which means the target is to the left of the
midpoint. So, let’s narrow the search space toward the left:
Note that our exit condition should be while left ≤ right in order to also examine the above
search space where there’s just value in it (i.e., when left == right).
Implementation
Example:
Constraints:
● No two adjacent elements in the array are equal.
Intuition
A naive way to solve this problem is to linearly search for a local maxima by iteratively comparing
each value to its neighbors and returning the first local maxima we find. A linear solution isn't
terrible, but since we can return any maxima, there’s likely a more efficient approach.
The first important thing to notice is that since this is an array with no adjacent duplicates, it will
always contain at least one local maxima. If it's not at one of the edges of the array, there'll be at
least one somewhere in the middle:
The opposite applies if points i and i + 1 form a descending slope. This would imply a maxima
exists somewhere to the left or at i. Notice here that the point at index i itself could be a maxima
too:
Once we know whether a local maxima exists to the left or to the right, we can continue searching
in that direction until we find it. In other words, we narrow our search toward the direction of the
maxima. Doesn't this type of reasoning sound similar to how we narrow search space in a binary
search? This indicates that it might be possible to find a local maxima using binary search.
To figure out how we narrow the search space, let’s use the below example, setting left and right
pointers at the boundaries of the array:
The midpoint is initially set at index 3, which forms a descending slope with its right neighbor since
nums[mid] > nums[mid + 1]. This suggests that either a maxima exists to the left of index 3 or
that index 3 itself is a maxima). So, we should continue our search to the left, while including the
midpoint in the search space:
The next midpoint is set at index 1, which forms an ascending slope with its right neighbor since
nums[mid] < nums[mid + 1]. This suggests that a maxima exists somewhere to the right of the
midpoint. So, let’s continue the search to the right, while excluding the midpoint:
Now that the left and right pointers have met, locating index 2 as a local maxima, we return this
maxima’s index (left).
Summary
Case 1: The midpoint forms a descending slope with its right neighbor, indicating the midpoint is a
local maxima, or that a local maxima exists to the left. Narrow the search space toward the left
while including the midpoint:
Implementation
Complexity Analysis
Time complexity: The time complexity of local_maxima_in_array is 𝑂(𝑙𝑜𝑔(𝑛)), where 𝑛
denotes the length of the array. This is because we use binary search to find a local maxima.
Example:
Input: weights = [3, 1, 2, 4]
Explanation:
sum(weights) = 10
3 has a 3/10 probability of being selected.
1 has a 1/10 probability of being selected.
2 has a 2/10 probability of being selected.
4 has a 4/10 probability of being selected.
For example, we expect index 0 to be returned 30% of the time.
Constraints:
● The weights array contains at least one element.
Intuition
A completely uniform random selection implies every index has an equal chance of being selected.
A weighted random selection means some items are more likely to be picked than others. If we
repeatedly perform a random selection many times, the frequency of each index being picked will
match their expected probabilities.
The challenge with this problem is determining a method to randomly select an index based on its
probability.
A useful observation is that all probabilities have the same denominator (which is 5 in this case).
Now, imagine we had a line with the same length as this denominator, and we divided this line into
two segments of size 1 and 4, respectively:
If we were to randomly pick a number on this line, we’d pick the first segment with a probability of
1/5 and the second segment 4/5 times. Now, imagine index 0 represents the first segment, and
index 1 represents the second segment:
If we randomly select a number on this line, we’ll select index 0 with a probability of 1/5, and index
1 with a probability of 4/5. This reflects their expected probabilities.
What we need now is a way to identify which numbers on the number line correspond to which
index so that when we pick a random number on this line, we know which index to return.
Before we continue, let’s establish the definitions of terms used in this explanation:
● “Weights” refers to the values of the elements in the weights array.
● “Indexes” refers to the indexes of the weights array.
● “Numbers” or “numbers on the number line” refers to the numbers from 1 to
sum(weights).
This method uses a lot of space because we need to store a key-value pair for each number on the
number line. Let's consider some other more space-efficient methods.
A more efficient strategy is to store only the endpoints of each segment instead.
Naturally, the endpoint of a segment marks where that segment ends. It also helps us know where
the next segment begins, as each new segment starts right after the previous one ends. This way,
we can determine the start and end of each index’s segment.
By storing only the endpoints, we just need to store just n values – one for each endpoint. When
storing these endpoints in an array, the array index itself is the same as the endpoint’s associated
index value on the number line:
As we can see, the prefix sum array stores the endpoint of each segment.
Now, let's see how the prefix sum array helps us. When we pick a random number from 1 to 10, we
need to determine which index it corresponds to using the prefix sum array. Let's see how we can
do this.
Using the prefix sums to determine which numbers correspond to which indexes
Let’s say we pick a random number from 1 to 10 and get 5. How can we use the prefix sum array to
determine which index that 5 corresponds to? To determine the segment, we’ll need to find its
corresponding endpoint. We know that:
● Either 5 itself is the endpoint, since 5 could be the endpoint of its own segment, or:
● The endpoint is somewhere to the right of 5 since its endpoint cannot be to the left.
Among all endpoints to the right of 5, the closest one to 5 will be the endpoint of its segment.
Endpoints farther away belong to different segments:
This means for any target, we’re looking for the first prefix sum (endpoint) greater than or equal
to the target. Below, we can see which prefix sum first meets this condition for a target of 5:
Therefore, the first prefix sum that satisfies this condition is the same as the lower-bound prefix
sum that satisfies this condition. Therefore, we can perform a lower-bound binary search to find
it, since the prefix sum array will always be a sorted array in this problem.
Let’s begin narrowing the search space. Remember, we’re looking for the lower-bound prefix sum
which satisfies the condition prefix_sums[mid] ≥ target.
The initial midpoint value is 4, which is less than the target of 5. This means the lower bound is
somewhere to the right of the midpoint, so let’s narrow the search space toward the right:
The midpoint value is now 6, which is greater than the target. This midpoint satisfies our condition,
so it could be the lower bound. If it isn’t, then the lower bound is somewhere to the left. So, let’s
narrow the search space toward the left while including the midpoint:
Implementation
class WeightedRandomSelection:
def __init__(self, weights: List[int]):
self.prefix_sums = [weights[0]]
for i in range(1, len(weights)):
self.prefix_sums.append(self.prefix_sums[-1] + weights[i])
Complexity Analysis
Time complexity: The time complexity of the constructor is 𝑂(𝑛) because we iterate through each
weight in the weights array once. The time complexity of select is 𝑂(𝑙𝑜𝑔(𝑛)) since we perform
binary search over the prefix_sums array.
Space complexity: The space complexity of the constructor is 𝑂(𝑛) due to the prefix_sums array.
The space complexity of select is 𝑂(1).
Example 1:
Input: s = "aacabba"
Output: "c"
Example 2:
Input: s = "aaa"
Output: "a"
Intuition
One challenge in solving this problem is how we handle characters which aren’t currently adjacent
duplicates but will be in the future.
A solution we can try is to iteratively build the string character by character and immediately
remove each pair of adjacent duplicates that get formed as we’re building the string.
It’s also possible an adjacent duplicate may be formed after another adjacent duplicate gets
removed. For example, with the string “abba”, removing “bb” will result in “aa”. Building the string
character by character ensures the formation of “aa” gets noticed and removed. To better
understand how this works, let’s dive into an example.
At the second ‘a’, we notice that adding it would result in an adjacent duplicate forming (i.e., “aa”).
So, let’s remove this duplicate before adding any new characters. We’ll do this for all adjacent
duplicates we come across as we build the string:
Once the smoke clears, the resulting string we were building ends up being “c”, which is the
expected output.
Now that we know how this strategy works, we just need a data structure that'll allow us to:
1. Add letters to one end of it.
2. Remove letters from the same end.
The stack data structure is a strong option because it allows for both operations.
● Push the current character onto the stack if it’s different from the character at the top (i.e.,
not a duplicate character.)
● Pop off the character at the top of the stack if it's the same as the current character (i.e., a
duplicate.)
Once all characters have been processed, the last thing to do is return the content of the stack as a
string, since the final state of the stack will contain all characters that weren’t removed.
Implementation
Complexity Analysis
Time complexity: The time complexity of repeated_removal_of_adjacent_duplicates is 𝑂(𝑛),
where 𝑛 denotes the length of the string. This is because we traverse the entire string, and we
perform a join operation of up to 𝑛 characters in the stack. The stack push and pop operations
contribute 𝑂(1) time.
Space complexity: The space complexity is 𝑂(𝑛) because the stack can store at most 𝑛 characters.
Note that the space taken up by the final output string is not considered in the space complexity.
You may not use any other data structures to implement the queue.
Example:
Input: [enqueue(1), enqueue(2), dequeue(), enqueue(3), peek()]
Output: [1, 2]
Constraints:
● The dequeue and peek operations will only be called on a non-empty queue.
Intuition
A queue is a first-in-first-out (FIFO) data structure, whereas stacks are a first-in-last-out (FILO)
data structure:
The main difference between these data structures is how items are evicted from them. In a
queue, the first value to enter is the first to leave, whereas it would be the last to leave in a stack.
Now that we understand how they work, let’s dive into the problem. Let’s start by seeing if it’s
possible to replicate the functionality of a queue with just one stack.
We now encounter a problem with attempting a dequeue operation since popping off the top of
the stack would return 3. The value we actually want popped off is 1, since it was the first value
that entered the data structure. However, 1 is all the way at the bottom of the stack.
To get to the bottom, we need to pop off all the values from the top of the stack and temporarily
store these values in a separate data structure (temp) so we can add them back to the stack later:
Once we've popped and returned the bottom value (1), push the values stored in temp back onto
the stack in reverse order to ensure they're added back correctly:
We know that if we were to use a data structure such as temp, it’d have to be a stack, as the
problem specifies only stacks can be used. In this temporary data structure, we remove values in
the opposite order in which we added them. In other words, it follows the LIFO principle, which is
conveniently how a stack works. This means we can use a stack for our temporary storage.
Now, even though we have a solution that works, having to pop off every single value from the top
of the stack whenever we want to access the bottom value is quite time-consuming. To find a way
In our initial solution, we would now move the values from temp back to the main stack. However,
notice the top of the temp stack now contains the value we expect to return in the next dequeue
call. This is because it’s the second value to have entered the data structure, and according to the
FIFO eviction policy, it should be the next one to be removed.
So, instead of adding these values back to the main stack, we could just leave them in temp and
return the stack’s top value at the next dequeue call.
In the above logic, we ended up using two stacks which each serve a unique purpose. In particular,
we used:
1. A stack to push values onto during each enqueue call (enqueue_stack).
2. A stack to pop values from during each dequeue call (dequeue_stack).
An important thing to realize here is that the dequeue stack won’t always be populated with
values. So, what should we do when it’s empty? We can just populate it by transferring all the
values from the enqueue stack to the dequeue stack, just like we did in the example. To understand
this more clearly, let’s dive into a full example.
Let’s start with two enqueue calls and push each number onto the enqueue stack.
Then, we just return the top value from the dequeue stack:
If we call dequeue again, we return the value from the top of the dequeue stack:
Regarding the peek function, we follow the same logic as the dequeue function, but instead, we
return the top element of the dequeue stack without popping it.
Implementation
As mentioned before, the dequeue and peek functions have mostly the same behavior, with the
only difference being that dequeue pops the top value while peek does not. To avoid duplicate
code, the shared logic between these functions of transferring values from the enqueue stack to
the dequeue stack has been extracted into a separate function, transfer_enqueue_to_dequeue.
class Queue:
def __init__(self):
self.enqueue_stack = []
self.dequeue_stack = []
Complexity Analysis
Time complexity: The time complexity of:
● enqueue is 𝑂(1) because we add one element to the enqueue stack in constant time.
● dequeue is amortized 𝑂(1).
○ In the worst case, all elements from the enqueue stack are moved to the dequeue
stack. This takes 𝑂(𝑛) time, where 𝑛 denotes the number of elements currently in
the queue.
○ However, each element is only ever moved once during its lifetime. So, over 𝑛
dequeue calls, at most 𝑛 elements are moved between stacks, averaging the cost to
𝑂(1) time per dequeue operation.
● peek is amortized 𝑂(1) for the same reasons as dequeue.
Space complexity: The space complexity is 𝑂(𝑛) since we maintain two stacks that collectively
store all elements of the queue at any given time.
Example:
Intuition
A brute-force approach to solving this problem involves iterating through each element within a
window to find the maximum value of that window. Repeating this for each window will take
𝑂(𝑛 · 𝑘) time because we traverse 𝑘 elements for up to 𝑛 windows.
The main issue is that as we slide the window, we keep re-examining the same elements we’ve
already looked at in previous windows. This is because two adjacent windows share mostly the
same values.
A more efficient solution likely involves keeping track of values we see in any given window so that
at the next window, we don’t have to iterate over previously seen values again. Specifically, at each
window, we should maintain a record of only values that have the potential to become the
maximum of a future window. Let’s call these values candidates, where all values that aren’t
candidates can no longer contribute to a maximum. How can we determine which numbers are
candidates?
Consider the window of size 4 in the following array. We’ll use left and right pointers to define the
window:
3: Number 3 is a candidate for the current window, but once we move to the next window, we
can ignore it since it will no longer be included in the window.
2: Could number 2 be a maximum of a future window? The answer is no. This is because of the
4 to its right: all future windows which contain this 2 will also contain 4, and since 4 is
larger, it means 2 could never be a maximum of any future windows.
4: This is the maximum value in the current window, and it’ll be included in some future
windows. Therefore, 4 could potentially be a maximum for a future window.
1: Could 1 become the maximum of a future window? The answer is yes. While 4 is larger in
the current window, it’s positioned to the left of 1. As the window shifts to the right, there
will eventually be a point where 1 remains in the window while 4 is excluded, making 1 a
potential maximum in the future.
Based on the above analysis, we can derive the following strategy whenever the window
encounters a new candidate:
1. Remove smaller or equal candidates: Any existing candidates less than or equal to the new
candidate should be discarded because they can no longer be maximums of future
windows.
2. Adding the new candidate: Once smaller candidates are discarded, the new value can be
added as a new candidate.
3. Removing outdated candidates: When the window moves past a value, that value should
be discarded to ensure we don’t consider values outside the window.
Observe how this strategy is applied to the list of candidates below as the window advances one
index to the right:
This consequently means the maximum value for a window is always the first value in the
candidate list.
Therefore, to store the candidates, we need a data structure that can maintain a monotonic
decreasing order of values.
Deque
We know that typically, a stack allows us to maintain a monotonic decreasing order of values, but
in this case, it has a critical limitation: it doesn’t provide a way to remove outdated candidates. A
stack is a last-in-first-out (LIFO) data structure, which means we only have access to the last (i.e.,
most recent) end of the data structure. From the diagram above, we know we need access to both
ends of the list of candidates, so a stack won’t be sufficient.
Is there a data structure that allows us to add and remove from both ends? A double-ended queue,
or deque for short, is a great candidate for this. A deque is essentially a doubly linked list under the
hood. It allows us to push and pop values from both ends of the data structure in 𝑂(1) time.
Now that we have our data structure, let’s see how we can use it over the following example. Note
that our deque will store tuples containing both a value and its corresponding index. We keep
track of indexes in the deque because they allow us to determine whether a value has moved
outside the window. We’ll see how this works later in the example.
When we reach 4, we can’t add it to the deque straight away because adding it would violate the
decreasing order of the deque. So, let’s pop any candidates from the right of the deque that are
less than 4 before pushing 4 in.
The next expansion of the window will set it at the expected fixed size of k:
With the window now at a fixed size of k, our approach shifts from expanding the window to
sliding it. As we slide, we should remove any values from the deque whose index is before the left
pointer, since those values will be outside the window:
The maximum value of the above window after the three operations are performed is revealed to
be 4, as it’s the leftmost candidate value.
The maximum value of the above window after the three operations are performed is also 4, as it’s
the leftmost candidate value.
Implementation
Complexity Analysis
Time complexity: The time complexity of maximums_of_sliding_window is 𝑂(𝑛) because we
slide over the array in linear time, and we push and pop values of nums into the deque at most once
for each number, with each stack operation taking 𝑂(1) time.
Space complexity: The space complexity is 𝑂(𝑘) because the deque can store up to 𝑘 elements.
Note, we don’t consider res in the space complexity.
Interview Tip
Tip: If you're unsure about what data structure to use for a problem, first identify what
attributes or operations you want from the data structure.
Use these attributes and operations to pinpoint a data structure that satisfies them and can be
used to solve the problem. In this problem, we wanted a data structure that could add and
remove elements from both ends of it efficiently, and the best data structure that matched these
requirements was a deque.
The past 16 years may have been maximally insufferable, but I can’t deny that they’ve
undoubtedly played a part in bringing me here. Thank you. You still need to work much harder
for that $20k, though.
Example:
Input: nums = [5, 1, 9, 4, 7, 10], k = 2
Output: [1, 4, 5, 7, 9, 10]
Intuition
In a k-sorted array, each element is at most k indexes away from where it would be in a fully sorted
array. We can visualize this with the following example where k = 2, and no number is more than
k indexes away from its sorted position:
A trivial solution to this problem is to sort the array using a standard sorting algorithm. However,
since the input is partially sorted (k-sorted), we should assume there's a faster way to sort the
array.
We can think about this problem backward. For any index i, the element that belongs at index i in
the sorted array is located within the range [i - k, i + k]. Below, we visualize how number 7,
This is a good start, but we can reduce this range even further. Consider index 0 from the above
array. We know the number which belongs at index 0 when sorted is somewhere in the range [0, 0
+ k]:
Note that the sorted array in the diagrams is purely provided as a reference point. We don’t yet
know which number in the range [0, 0 + k] belongs at index 0. However, one fact remains
consistent: in a sorted array, index 0 always holds the smallest number. This means the value
needed at index 0 is also the smallest number within the range [0, 0 + k] of the k-sorted array,
which is 1 in this example.
So, let’s swap 1 with the number at index 0 to position 1 as the first value in the sorted array:
Now let’s find the number that belongs at index 1: the second smallest number. Since index 0
currently contains the smallest value in the array, we won’t need to consider index 0 in our search.
Therefore, we can find the value that belongs at index 1 in the range [1, 1 + k]. The smallest value in
this range will be the second smallest value overall, which is 4 in this case.
So, let’s swap 4 with the number at index 1 to position 4 as the second value in the sorted array:
The main inefficiency with this approach is finding the minimum number in the range [i, i + k] at
each index i. Linearly searching for it will take 𝑂(𝑘) time at each index.
To improve this approach, we’d need a way to efficiently access the minimum value at each of these
ranges. A min-heap would be perfect for this.
Min-heap
For a min-heap to determine the minimum value within each range [i, i + k], it will always need
to be populated with the values in these ranges as we iterate through the array. Let’s see how this
works over the same example.
Before we can determine which value belongs at index 0, we’ll need to populate the heap with all
the values in the range [0, k], which are the first k + 1 values (where k = 2 in this example):
Now, let’s begin inserting the smallest elements from the heap into the array, using the
insert_index pointer. The value that belongs at index 0 in sorted order is the value currently at
the top of the heap, which is 1:
Once we insert 1 at index 0, push the value at index i to the heap before incrementing both
pointers:
Implementation
Complexity Analysis
Time complexity: The time complexity of sort_a_k_sorted_array is 𝑂(𝑛𝑙𝑜𝑔(𝑘)), where 𝑛
denotes the length of the array. Here’s why:
● We perform heapify on a min_heap of size 𝑘 + 1 which takes 𝑂(𝑘) time.
● Then, we perform push and pop operations on approximately 𝑛 − 𝑘 values using the heap.
Since the heap can grow up to a size of 𝑘 + 1, each push and pop operation takes
𝑂(𝑙𝑜𝑔(𝑘)) time. Therefore, this loop takes 𝑂(𝑛𝑙𝑜𝑔(𝑘)) time in the worst case.
● The final while-loop runs in 𝑂(𝑘𝑙𝑜𝑔(𝑘)) time since we pop 𝑘 + 1 values from the heap.
Therefore, the overall time complexity is 𝑂(𝑘) + 𝑂(𝑛𝑙𝑜𝑔(𝑘)) + 𝑂(𝑘𝑙𝑜𝑔(𝑘)) = 𝑂(𝑛𝑙𝑜𝑔(𝑘)), since 𝑘
is upper-bounded by 𝑛 in each operation above. This is because the heap can only ever contain at
most 𝑛 values.
Space complexity: The space complexity is 𝑂(𝑘) because the heap can grow up to 𝑘 + 1 in size.
Example:
Output: True
Intuition
To check a binary tree's symmetry, we need to assess its left and right subtrees. The first thing to
note is that the root node itself doesn’t affect the symmetry of the tree. Therefore, we don't need
to consider the root node. We now have the task of comparing two subtrees to check if one
vertically mirrors the other.
Consider the root node’s left and right subtrees in the following example:
We’ve learned from the problem Invert Binary Tree that an inversion is performed by swapping the
left and right child of every node. This suggests the value of each node's left child in the left
subtree should match the value of the right child of the corresponding node in the right subtree,
and vice versa.
We can start by using DFS to traverse both subtrees. During this traversal, we compare the left
and right children of each node in the left subtree with the right and left children of its
corresponding node in the right subtree, respectively.
● If the values of any two nodes being compared are not the same, the tree is not symmetric.
● If, at any point, one of the child nodes being compared is null while the other isn’t, the tree
is also not symmetric.
Initially, we see the values of the root nodes of the left and right subtrees are equal, as we know:
We proceed by comparing their children through recursive DFS calls. Specifically, make two
recursive DFS calls to compare the left child of one node with the right child of the other. This
checks that node2’s children contain the same values as node1’s children, but inverted.
Implementation
Space complexity: The space complexity is 𝑂(𝑛) due to the space taken up by the recursive call
stack, which can grow as large as the height of the binary tree. The largest possible height of a
binary tree is 𝑛.
Interview Tip
Tip: Cover null cases.
Always check for null or empty inputs before using their attributes in a function. In this problem,
the dfs function accesses the left and right attributes of the input node, necessitating initial null
checks.
Example:
Intuition
First and foremost, to get the columns of a binary tree, we'll need a way to identify what column
each node is in.
Column ids
One way to distinguish between different columns is to represent each column by a distinct
numerical value: an id. Initially, we don't know how many columns there are to the left or to the
right of the root node, but we at least know the column which contains the root node itself. Let's
give this column an id of 0. From here, we can set positive column ids for nodes to the right of the
root and negative ids to the left:
How can we identify what column id a node is associated with? A handy observation is that every
time we move to the right, the column id increases by 1, and every time we move to the left, it
decreases by 1. This allows us to assign ids as we traverse the tree: for any node, the column ids of
node.left and node.right are column - 1 and column + 1, respectively:
Now, let’s consider what traversal algorithm we should use to populate this hash map.
Breadth-first search
In general, we can employ any traversal method to assign column ids to each node, as long as we
increment the column id whenever we move right and decrement it whenever we move left.
However, we need to be cautious about the following two requirements:
1. Nodes in the same column should be ordered from top to bottom.
2. Nodes in the same row and column should be ordered from left to right.
So, we’ll need an algorithm that traverses the tree from top to bottom and then from left to right.
This calls for BFS.
BFS processes nodes level by level, starting from the root and moving horizontally across the tree
at each level. This method ensures nodes are visited from top to bottom, and for nodes in the same
row (level), they are visited from left to right.
We can see how the hash map is populated level by level below:
Once BFS concludes, the hash map is populated with lists of values for each column id. However,
the hash map itself is not the expected output. So, let’s discuss what to do next.
Implementation
Complexity Analysis
Time complexity: The time complexity of binary_tree_columns is 𝑂(𝑛), where 𝑛 denotes the
number of nodes in the tree. This is because we process each node of the tree once during the
level‐order traversal.
Space complexity: The space complexity is 𝑂(𝑛) due to the space taken up by the queue. The
queue’s size will grow as large as the level with the most nodes. In the worst case, this occurs at the
final level when all the last‐level nodes are non‐null, totaling approximately 𝑛/2 nodes. Note that
the output array created at the return statement does not contribute to the space complexity.
Example:
Output: 6
Constraints:
● n ≥ 1, where n denotes the number of nodes in the tree.
● 1≤k≤n
Intuition - Recursive
A naive approach to this problem is to traverse the tree and store all the node values in an array,
sort the array, and return the kth element. This approach, however, does not take advantage of the
fact that we're dealing with a BST.
We know that in a BST, each node’s value is larger than all the nodes to its left and smaller than all
the nodes to its right. This structure means that BSTs inherently possess a sorted order. Given this,
it should be possible to construct a sorted array of the tree's values by traversing the tree, without
the need for additional sorting.
We now need a method to traverse the binary tree that allows us to encounter the nodes in their
sorted order.
This leads us to an ideal traversal algorithm: inorder traversal, where for each node, the left
subtree is processed first, followed by the current node, and then the right subtree.
To build the sorted list of values using inorder traversal, we can design a recursive function. When
called on the root node, it returns a sorted list of all values in the BST.
When the function is called for any node during the recursive process, it constructs a sorted list of
the values in the subtree rooting from that node. This is achieved by first obtaining the sorted
values from its left subtree, then adding the current node's value, and finally appending the sorted
values from its right subtree.
Once we have the full list of sorted values, we can simply return the value at the (k - 1)th index to
get the kth smallest value.
Implementation - Recursive
# Inorder traversal function to attain a sorted list of nodes from the BST.
def inorder(node: TreeNode) -> List[int]:
if not node:
return []
return inorder(node.left) + [node.val] + inorder(node.right)
Space complexity: The space complexity is 𝑂(𝑛) due to the space taken up by sorted_list, as
well as the recursive call stack, which can grow as large as the height of the binary tree. The largest
possible height of a binary tree is 𝑛.
Intuition - Iterative
Since we only need the kth smallest value, storing all n values in a list might not be necessary.
Ideally, we’d like to find a way to traverse through k nodes instead of n. How can we modify our
approach to achieve this?
If we had a way to stop inorder traversal once we've reached the kth node in the traversal, we
would land on our answer. An iterative approach would allow for this since we’d be able to exit
traversal once we’ve reached the kth node — something that’s quite difficult to achieve using
recursion.
We know inorder traversal is a DFS algorithm and that DFS algorithms can be implemented
iteratively using a stack. Let’s explore this idea further.
Consider what happened during recursive inorder traversal in the previous approach:
● Make a recursive call to the left subtree.
● Process the current node.
● Make a recursive call to the right subtree.
1. Move as far left as possible, adding each node to the stack as we move left.
○ We do this because, at the start of each recursive call in the recursive approach, a
new call is made to the current node’s left subtree, continuing until the base case (a
null node) is reached. This implies that to mimic this process iteratively, we’ll need
to move as far left as possible.
○ The reason we push nodes onto the stack as we go is so they can be processed later.
2. Once we can no longer move left, we pop the node off the top of the stack. Let's call it the
current node. Initially, this node represents the smallest node. After this, the next node on
the stack subsequently represents the next smallest node, and so on until we reach the kth
smallest node.
○ Decrement k, indicating that we now have one less node to visit until we reach the
kth smallest node.
○ Once k == 0, we found the kth smallest node. Return the value of this node.
Implementation - Iterative
Space complexity: The space complexity is 𝑂(ℎ) since the stack can store up to 𝑂(ℎ) space during
the traversal to the leftmost node. In the worst case, the height of the tree is 𝑛, resulting in a space
complexity of 𝑂(𝑛).
Example:
Intuition
The primary challenge of this problem lies in how we serialize the tree into a string, as this will
determine if it’s possible to accurately reconstruct the tree using this string alone.
Let's first decide on a traversal strategy because the method we use to serialize the tree will
impact how we deserialize the string. Two options:
● Use BFS to serialize the tree level by level.
● Use DFS. In this case, we’d need to choose between inorder, preorder, and postorder
traversal.
There’s flexibility in choosing the traversal algorithm because serializing with a specific traversal
method allows us to rebuild (deserialize) the tree using the same traversal algorithm.
Serialization
An important piece of information needed in our serialized string is the node values. In addition,
we’ll need to ensure we can identify the root node's value, since this is the first node to create
when we deserialize the string.
As such, a traversal algorithm like preorder traversal is a good choice because it processes the root
node first, then the left subtree, and finally the right subtree. This ensures the first value in our
serialized string is the root node's value.
The issue with this serialization is that it doesn't guarantee we can reconstruct the original tree
accurately from this serialized string representation. This is because the string could represent
multiple different trees, which means preorder deserialization could result in the creation of an
invalid tree:
This is a consequence of excluding some critical information from the string: null child nodes. For
instance, after placing node 5 as the root in the above example, we don't yet know where to place
the node of value 9, which is the next value in the string. That is, we can't determine whether node
9 should be the left or right child of node 5:
Deserialization
To deserialize a string created with preorder traversal, we also need to use preorder traversal to
reconstruct the tree.
The first step is to split the string using the comma delimiter, so each node value and ‘#’ is stored as
separate elements in a list:
The first value in the list is the root node of the tree:
Starting from this root value, recursively construct the tree node by node using preorder traversal.
Each new node will be created with the next value in the list of preorder values. Whenever we
encounter a ‘#’, we return null. The code snippet for this:
Postorder traversal:
When we serialize the tree using postorder traversal, we get the following string (ignoring the null
nodes in this discussion to focus on the node values and their order):
As we see, one big difference is that the root node will be the final value in the string, since
postorder traversal processes the left subtree, then the right subtree, and finally the root node.
During deserialization, we’d build the tree by iterating through the node values from right to left
instead of left to right, since the root value is at the right. In addition, we’d need to create each
node’s right subtree before we create its left subtree, as we go through the string in reverse order.
Inorder traversal:
A lot more care needs to be taken when serializing a tree using inorder traversal. The main reason
is that it’s unclear where the root node of the tree is in the string, and where the root node of each
subtree is, as we can see below:
Similarly to preorder traversal, we just need to follow the exact traversal order for reconstructing
the tree when deserializing the string.
Implementation
In the following implementation, we opt for preorder traversal for serialization and
deserialization.
Space complexity: The space complexity of both serialize and deserialize is 𝑂(𝑛) due to the
space taken up by the recursive call stack, which can grow as large as the height of the binary tree.
The largest possible height of a binary tree is 𝑛. In addition, the serialize function uses a
serialized_list to store the values of the string.
Note: some interviewers might include the serialized string in the space complexity analysis, while
others may not, as it is a required data structure specified by the problem and is not always
considered as additional space. To ensure a thorough discussion, it’s best to address both
perspectives when analyzing space complexity during your interview.
Shortest Path
Given an integer n representing nodes labeled from 0 to n - 1 in an undirected graph, and an
array of non-negative weighted edges, return an array where each index i contains the
shortest path length from a specified start node to node i. If a node is unreachable, set its
distance to -1.
Each edge is represented by a triplet of positive integers: the start node, the end node, and the
weight of the edge.
Example:
Input: n = 6, edges = [[0, 1, 5], [0, 2, 3], [1, 2, 1], [1, 3, 4], [2, 3, 4],
[2, 4, 5]], start = 0
Output: [0, 4, 3, 7, 8, -1]
Intuition
There are a few algorithms that can be employed to find the shortest path in a graph. Let’s consider
some of our options:
● BFS works well for finding the shortest path when the graph has edges with no weight, or
uniform weight across the edges, as BFS doesn’t take weight into account in its traversal
strategy.
Among these options, Dijkstra’s algorithm suits this problem the most since we’re dealing with a
graph with non-negative weighted edges, and we need to find the shortest path from a start node
to all other nodes. This algorithm uses a greedy strategy, which we’ll explore during this
explanation.
Consider the undirected weighted graph below, with node 0 as the starting node.
Initially, since we don't know any of the distances between node 0 and the other nodes, we'll set
them to infinity. The only distance we do know is from the start node to itself, which is just 0:
Let’s begin with the start node. Consider its immediate neighbors, nodes 1 and 2. The distances
from node 0 to these nodes are 5 and 3, respectively. We don't know if these are the shortest
distances from 0 to them, but they’re definitely shorter than infinity. So, let's update the distances
to those nodes from node 0:
The current node is now node 2. Keep in mind that, so far, we’ve traversed a distance of
distance[2] = 3 from node 0 to reach node 2.
The immediate unvisited neighbors of the current node are nodes 1, 3, and 4. The distances to
them from the start node are 1 + 3, 4 + 3, and 5 + 3 (where the +3 accounts for the distance
traveled so far). Let’s update the distance array with these distances since they are smaller than
the distances currently set for them:
Right now, among the unvisited nodes, the node with the shortest distance from the start node is
node 1, with a distance of 4. This means the shortest distance to node 1 from the start node is 4,
since all other paths to node 1 involve traversing through distances larger than 4. So, let’s move to
node 4:
The final step is to convert all infinity values in this array to -1, indicating we weren’t able to reach
that node:
To implement the above strategy, we’d like an efficient way to access the unvisited node with the
shortest known distance at any point in the process. We can use a min-heap for this, allowing us
logarithmic access to the node with the minimum distance.
Begin by popping the start node from the top of the heap and setting it to the current node. Then,
add the current node’s neighbors to the min-heap with their corresponding distances from the
start node, and update the distances of these neighbors:
This check allows us to avoid using an additional data structure to keep track of visited nodes.
Dijkstra's algorithm is considered greedy because, at each step, it selects the unvisited node with
the shortest known distance from the start node, based on the assumption that this distance is the
shortest possible path to that node. This choice is made as a local optimum, with the belief that it
will lead to the global optima: the shortest path to all nodes. Can we always guarantee that this
assumption is true? Consider the following graph, where the current node is node 0:
The node with the shortest known distance from the start node is node 2, with a distance of 3. We
assume this is the shortest distance to node 2, and choose node 2 as the local optimum. Here’s a
question we could ask regarding the validity of this choice: is it possible to find a path with a
The answer is no. This is because we have to pass through one of these neighboring nodes to find
any other paths, which would require us to add a distance of at least 3 to our total distance
traversed from the start node. To reduce this traversed distance, we would need to encounter
negatively weighted edges in the graph, which we know isn’t possible in this graph.
This analysis also demonstrates that Dijkstra’s algorithm is only applicable when the graph has
no edges with negative weights.
Implementation
Complexity Analysis
Time complexity: The time complexity of the shortest_path is 𝑂((𝑛 + 𝑒)𝑙𝑜𝑔(𝑛)), where 𝑒
represents the number of edges. Here’s why:
● Creating the adjacency list takes 𝑂(𝑒) time.
● Dijkstra's algorithm traverses up to all 𝑛 nodes and explores each edge of the graph. To
access each node, we pop it from the heap, and for each edge, up to one node is pushed to
the heap (when we process each node’s neighbors). Since each push and pop operation
takes 𝑂(𝑙𝑜𝑔(𝑛)) time, the time complexity of Dijkstra’s algorithm is 𝑂((𝑛 + 𝑒)𝑙𝑜𝑔(𝑛)).
Therefore, the overall time complexity is 𝑂(𝑒) + 𝑂((𝑛 + 𝑒)𝑙𝑜𝑔(𝑛)) = 𝑂((𝑛 + 𝑒)𝑙𝑜𝑔(𝑛)).
Space complexity: The space complexity is 𝑂(𝑛 + 𝑒), since the adjacency list takes up 𝑂(𝑛 + 𝑒)
space, whereas the distances array and min_heap take up 𝑂(𝑛) space.
References
[1] Bellman-Ford algorithm: https://en.wikipedia.org/wiki/Bellman%E2%80%93Ford_algorithm
The cost of connecting two points is equal to the Manhattan distance between them, which is
calculated as |x1-x2|+|y1-y2| for two points (x1, y1) and (x2, y2).
Example:
Input: points = [[1, 1], [2, 6], [3, 2], [4, 3], [7, 1]]
Output: 15
Constraints:
● There will be at least 2 points on the plane.
Intuition
Let’s treat this problem as a graph problem, imagining each point as a node, and the cost of
connecting any two points as the weight of an edge between those nodes.
The goal is to connect all nodes (points) in such a way that the total cost is minimized. This is
essentially the minimum spanning tree (MST) problem:
The MST of a weighted graph is a way to connect all points in the graph, ensuring each point is
reachable from any other point while minimizing the total weight of the connections.
There are two main algorithms that are used to find the MST of a graph:
● Kruskal's algorithm
● Prim's algorithm
Kruskal’s algorithm
Kruskal's algorithm is a greedy method for finding the MST. It essentially builds the MST by
connecting nodes with the lowest-weighted edges first while skipping any edges that could cause a
cycle.
To do this, we first need to identify all the possible edges and sort them by their Manhattan
distance:
Now, let’s implement Kruskal’s algorithm. Start by including the lowest weighted edge in the MST,
which is the edge from (3, 2) to (4, 3):
Next, add the next lowest weighted edge, which is the edge from (1, 1) to (3, 2):
Continuing this process until all points are connected gives us the MST:
We’ll know that all points are connected once we’ve added a total of n - 1 edges to the MST,
because this is the least number of edges needed to connect n points without any cycles. To attain
Now that we have a strategy to find the MST of a set of points, we just need a way to determine
when connecting two points leads to a cycle.
Avoiding cycles
A cycle is formed when we add an edge to nodes that are already connected in some way. Consider
the following set of points, where the group of points to the left are connected, and the group of
points to the right are connected:
Connecting any two points in the same group causes a cycle, and connecting two points from two
separate groups will result in both groups merging into one:
What would be useful here is a way to determine if two points belong to the same group, and a way
to merge two groups together. The Union-Find data structure is perfect for this.
This way, we can use this boolean return value as a way to determine if attempting to connect two
points causes a cycle.
If you’re not familiar with Union-Find, study the solution to the Merging Communities problem.
Implementation
In this implementation, we'll identify each point using their index in the points array. This means
that for n points, each point is represented by an index from 0 to n - 1. By doing this, we can set
up a Union-Find data structure with a capacity of n so that there are n groups initially, with each
group containing one of the n points.
The Union-Find data structure remains the same as the implementation provided in Merging
Communities, with a slight modification made to the union function, adding a boolean return value
to it.
class UnionFind:
def __init__(self, size):
self.parent = [i for i in range(size)]
self.size = [1] * size
2
Space complexity: The space complexity is 𝑂(𝑛 ) due to the space taken up by the edges array. In
addition, the UnionFind data structure takes up 𝑂(𝑛) space.
Combinations of a Sum
Given an integer array and a target value, find all unique combinations in the array where the
numbers in each combination sum to the target. Each number in the array may be used an
unlimited number of times in the combination.
Example:
Input: nums = [1, 2, 3], target = 4
Output: [[1, 1, 1, 1], [1, 1, 2], [1, 3], [2, 2]]
Constraints:
● All integers in nums are positive and unique.
● The target value is positive.
● The output must not contain duplicate combinations. For example, [1, 1, 2] and
[1, 2, 1] are considered the same combination.
Intuition
Since we can use each integer in the input array as many times as we like, we can create an infinite
number of combinations. We certainly cannot explore combinations infinitely. So, to manage this,
we need to narrow our search.
An important point that will help us with this is that all values in the integer array are positive
integers. This means that as we add more values to a combination, its sum will increase. Therefore,
we should stop building a combination once its sum is equal to or exceeds the target value.
Another thing we should be mindful of is duplicate combinations. Consider the input array [1, 2,
3]. The combinations [1, 3, 2, 1] and [2, 1, 3, 1] represent the same combination. To
ensure a universal representation, we can represent this combination as [1, 1, 2, 3], where
With those two things in mind, let’s think about how we find all combinations that sum to the
target value. Backtracking is ideal for exploring all possible combinations, so let’s start by
considering the state space tree for this problem.
To figure out how to branch out from here, let’s identify what decisions we can make. Each element
can be included in a combination an unlimited number of times. So, this means we can make three
decisions for an array of length 3: include each element from the array (remember that a branch in
the state space tree represents a decision):
Let’s make the same decisions for each of these combinations as well to continue extending the
state space tree. Remember that if any combination has a sum equal to 4 or exceeding 4, we stop
extending those combinations. These two conditions are effectively our termination conditions:
One issue with this approach is that it resulted in duplicate combinations in our tree:
Initially, for the root combination, start_index is set to 0. As we recursively build each
combination, start_index is updated to the index of the current element being added. By doing
this, we ensure that in the next recursive call, we only consider elements from the updated index
onward in the input array.
This maintains the required order and prevents duplicates because we never revisit previous
elements. Since each combination is built by only adding elements that come after the current
element in the input array, we avoid generating the same combination in a different order.
Implementation
In our algorithm, the termination condition requires us to know the sum of the current
combination. While we could use a separate variable to track the sum of each combination, this
isn't necessary. Instead, we can repurpose our target value. When we choose a number to add to a
combination, we reduce the target value by that number. This way, the target value dynamically
This means that when we reach a target of 0, we've found a valid combination. If the target
becomes negative, we can terminate the current branch of the search:
Complexity Analysis
𝑡𝑎𝑟𝑔𝑒𝑡/𝑚
Time complexity: The time complexity of combinations_of_sum_k is 𝑂(𝑛 ), where 𝑛
denotes the length of the array, and 𝑚 denotes the smallest candidate. This is because, in the worst
case, we always add the smallest candidate 𝑚 to our combination. The recursion tree will branch
down until the sum of the smallest candidates reaches or exceeds the target. This results in a tree
depth of target/m. Since the function makes a recursive call for up to 𝑛 candidates at each level of
𝑡𝑎𝑟𝑔𝑒𝑡/𝑚
the recursion, the branching factor is 𝑛, giving us the time complexity of 𝑂(𝑛 ).
1 2 3
abc def
4 5 6
ghi jkl mno
7 8 9
pqr stu wxyz
Return all possible letter combinations the input digits could represent.
Example:
Input: digits = "69"
Output: ["mw", "mx", "my", "mz", "nw", "nx", "ny", "nz", "ow", "ox", "oy",
"oz"]
Intuition
At each digit in the string, we have a decision to make: which letter will this digit represent? Based
on this decision, let’s illustrate the state space tree which represents the choices at each digit of
the input string.
At the first digit, 6, we have the choice of starting our combination with ‘m’, ‘n’, or ‘o’:
For each of these combinations, we now have a new decision to make: which letter of digit 9 (‘w’, ’x’,
‘y’, ‘z’) should we choose? These choices are illustrated below:
The final level of this decision tree (i.e., when i == n, where n denotes the length of the input
string) represents all possible combinations that can be created from the provided string. Similar
to our approach in Find All Subsets, let’s use backtracking to obtain these keypad combinations.
Complexity Analysis
𝑛
Time complexity: The time complexity of phone_keypad_combinations is 𝑂(𝑛 · 4 ). This is
because the state space tree will branch down until a decision is made for all 𝑛 elements. This
results in a tree of height 𝑛 with a branching factor of 4 since there are up to 4 decisions we can
𝑛
make at each digit. For each of the 4 combinations created, we convert it into a string and add it to
the output, which takes 𝑂(𝑛) time per combination. This results in a total time complexity of
𝑛
𝑂(𝑛 · 4 ).
Interview Tip
Tip: Check if you can skip trivial implementations.
During an interview, it’s crucial to manage your time effectively. If you encounter a trivial and
time-consuming task, such as creating the keypad_map in this problem, it’s possible the
interviewer may allow you to skip it or implement it later if there’s time left in the interview.
Ensure you at least briefly mention how the implementation you’re skipping would work before
requesting to move on to the core logic of the problem.
Example:
Output: 9
Intuition
The brute force solution to this problem involves examining every possible submatrix to determine
if it forms a square of 1s. This can be done by treating each cell as a potential top-left corner of a
square, and checking all possible squares that extend from that cell. For each of these squares,
we’ll need to verify that all cells within the square are 1’s. Repeating this process for each cell
allows us to find the largest square. This process is quite inefficient, so let’s explore alternatives.
One important thing to understand is that squares contain smaller squares inside them. This
indicates that subproblems might exist in this problem. Let’s see if we can find what the
subproblems are and how we can use them. Consider the following 6x6 matrix containing a 4×4
square of 1s:
Let’s consider a slightly different scenario, where this time, the input matrix contains one less 1,
meaning there’s no longer a 4×4 square of 1s:
Let’s see if our strategy of checking the three neighboring squares around the current cell (4, 4)
changes at all here. Keep in mind that this time, the square that ends directly to the left of the
current cell only has a length of 2. This means the square that ends at the current cell can, at most,
have a length of 3, with 1 unit from the current cell and 2 units from the smallest neighboring
square:
We now have all the information we need. Given this problem has an optimal substructure, we can
translate the above recurrence relation directly into a DP formula.
if matrix[i][j] == 1:
dp[i][j] = 1 + min(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1])
Now, let’s think about what the base cases should be.
What other base cases are there? Consider row 0 and column 0 of the matrix. These are special
because the length of a square ending at any of these cells is at most 1.
So, for the base cases, we can set all cells in row 0 and column 0 to 1 in our DP table, provided
those cells in the original matrix are also 1:
The largest value in the DP table represents the length of the largest square in our matrix.
Therefore, we just need to track the maximum DP value (max_len) as we populate the table. Once
done, we just return max_len2, which represents the area of the largest square.
Space complexity: The space complexity is 𝑂(𝑚 · 𝑛) since we're maintaining a 2D DP table that
has 𝑚 · 𝑛 elements.
Optimization
We can optimize our solution by realizing that, for each cell in the DP table, we only need to access
the cell directly above it, the cell to its left, and the top-left diagonal cell.
● To get the cell above it or the top-left diagonal cell, we only need access to the previous
row.
● To get the cell to its left, we just need to look at the cell to the left of the current cell in the
same row we're populating.
This effectively reduces the space complexity to 𝑂(𝑚). Below is the optimized code:
Example:
Input: nums = [0, 1, 2, 0, 1, 2, 0]
Output: [0, 0, 0, 1, 1, 2, 2]
Intuition
This problem is just asking us to sort three numbers in ascending order. A straightforward solution
would be to use an in-built sorting function. However, this is an 𝑂(𝑛𝑙𝑜𝑔(𝑛)) approach, where 𝑛
denotes the length of the array, and it isn’t taking advantage of an important problem constraint:
there are only three types of elements in the array.
To sort these numbers, we essentially want to position all 0s to the left, all 2s to the right, and any
1s in between. A key observation is that if we place the 0s and the 2s in their correct positions, the
1s will automatically be positioned correctly:
One strategy we could use is to iterate through the array and move any 0s we encounter to the
left, and any 2s we encounter to the right.
To understand how we should adjust these pointers after each swap, let’s use the following
example:
The first element is 2, so let’s swap it with nums[right]. Then, let’s move the right pointer inward
so it points to where the next 2 should be placed:
Notice that after this swap, there’s now a new element at index i. So, we should not yet advance i,
as we still need to decide whether this new element needs to be positioned elsewhere.
The pointer i is now pointing at a 1. We don’t need to handle any 1s we encounter, so let’s just
advance the i pointer:
After this swap, there’s a new element at index i. Since i is positioned after the left pointer, this
element can only be a 1 for the following reasons:
● Before the swap, all 0s originally to the left of i would have already been positioned to the
left of the left index.
● Before the swap, all 2s originally to the left of i would have already been positioned to the
right of the right index.
Therefore, we can also advance the i pointer while advancing the left pointer:
Note that we don’t stop the process when i == right because the i pointer could still be
pointing at a 0, which would need to be swapped.
Here, nums[i] == 0, so the first thing we do is swap nums[i] and nums[left], which doesn’t
change anything in this case since left and i point to the same element. Now, observe what
happens if we only advance the left pointer:
As we can see, the left pointer will surpass the i pointer, which shouldn’t happen since i needs to
stay between left and right throughout the algorithm. To avoid this, we advance both the i and
left pointers:
Complexity Analysis
Time complexity: The time complexity of dutch_national_flag is 𝑂(𝑛) because we iterate
through each element of nums once.
Example:
Input: n = 5, k = 2
Output: 2
Constraints:
● There will be at least one person in the circle.
● k will at least be equal to 1.
Intuition
The naive approach to solving this problem is to simulate the removal of people step by step. We
can create a circular linked list with n nodes. Starting from node 0, we iterate through the linked
list, removing every kth person. The last node remaining after all removals will represent the last
remaining person.
Consider an example where n = 12 and k = 4. For the first removal, we start counting k nodes
from node 0 and remove the person we end up on after counting.
As we can see, after the first removal, there is one less person in the circle. Additionally, after the
removal, our new start position is at the kth position (person 4).
Now, we effectively need to find the last person remaining in a circle of n - 1 people, where we
start counting at person k. This indicates that solving the subproblem josephus(n - 1, k) will
help us get the answer to the problem josephus(n, k). Note that the answer to subproblem
josephus(i, k) represents the last person standing in a circle of i people, where we start
counting at person 0.
Base case
The simplest version of this problem is when the circle contains only one person: n = 1. In this
case, the last person remaining is person 0, so we just return person 0 for this base case.
Implementation
Complexity Analysis
Time complexity: The time complexity of josephus is 𝑂(𝑛) because we make a total of 𝑛 recursive
calls to this function until we reach the base case.
Space complexity: The space complexity is 𝑂(𝑛) due to the recursive call stack, which grows up to
a depth of 𝑛.
The key observation here is that we only ever need access to the previous element of the res array
(at i - 1) to calculate the result of the current subproblem (at i). This means we don’t need to
store the entire array.
Instead, we can use a single variable to keep track of the solution to the previous subproblem. We
can then update this variable to store the solution for the current subproblem:
res = (res + k) % i
Note that the 'res' value used on the right-hand side of the equation represents the previous
subproblem’s result.
Implementation
Complexity Analysis
Time complexity: The time complexity of josephus_optimized is 𝑂(𝑛) because we iterate
through 𝑛 subproblems.
Given a value representing a row of this triangle, return the position of the first even number
in this row. Assume the first number in each row is at position 1.
Example:
Input: n = 4
Output: 3
Constraints:
● n will be at least 3.
Intuition
A naive solution to this problem is to generate the entire triangle and all of its values up to the nth
row. Then, we can iterate through the nth row until we encounter the first even number. However,
this approach is inefficient because it results in an excessive use of time and memory to build the
entire triangle. To find a more optimal solution, let’s consider how we can simplify the
representation of our triangle.
The next key observation is that we don’t necessarily care about the values themselves: we only
care about the parity of each number (i.e., if they’re even or odd). Given this, we can simplify the
triangle further by representing it as a binary triangle where 0 represents an even number and 1
represents an odd number:
Now that we’ve simplified the triangle, it’ll be easier to identify patterns in the positions of the first
even number in each row. Let’s explore this further.
Identifying patterns
Let's ignore rows 1 and 2 since even numbers only begin appearing from row 3 onward. A good
place to start looking for a pattern is to highlight the first even number at each row and observe
their positions:
So far, our hypothesis for odd-numbered rows is still true, but even-numbered rows seem to be
following a different pattern. It’s still hard to pinpoint what it could be.
Let's continue by displaying a few more rows to figure out what this pattern is:
To understand why this pattern repeats, it’s important to realize that the first four values of a row
are calculated solely from the four values of the previous row. We can see this visualized below,
using the initial representation of the triangle to make it clearer:
So, whenever a specific sequence of four numbers occurs at the beginning of a row, it will generate
a predictable sequence of four numbers in the following row. Extending this observation to the
This problem demonstrates how recognizing patterns and simplifying the problem can turn a
time-consuming solution into a quick, constant-time one.
Implementation