Esc
Keyboard shortcuts
?Show this help ⌘KSearch tToggle dark/light theme nOpen notes j / kScroll down / up bBack to top /Focus search EscClose panels
All Courses
Coding Interview Patterns
ENRUUZ
Notes
Current chapter
0 chars
Highlight color
Chapter 1

Two Pointers

~33 min read

Introduction to Two Pointers

Introduction to Two Pointers

Intuition

As the name implies, a two-pointer pattern refers to an algorithm that utilizes two pointers. But what is a pointer? It's a variable that represents an index or position within a data structure, like an array or linked list. Many algorithms just use a single pointer to attain or keep track of a single element:

The image represents a visual depiction of data insertion or modification within a data structure, likely an array or list.  A small, orange square containing the letter 'i' acts as an indicator or pointer, positioned above a sequence of numbers enclosed in square brackets: `[ ... 14 5 5 20 ... ]`.  A downward-pointing orange arrow originates from the 'i' indicator and points directly to the number '5', suggesting that the 'i' represents a position or index within the data structure, and the arrow indicates the insertion or modification is occurring at that specific index. The ellipses (...) indicate that there are additional elements before and after the visible portion of the sequence.  The overall diagram illustrates a simple data manipulation operation, focusing on the insertion or update of a value at a particular index within a larger dataset.

Introducing a second pointer opens a new world of possibilities. Most importantly, we can now make comparisons. With pointers at two different positions, we can compare the elements at those positions and make decisions based on the comparison:

Image represents a visual depiction of a comparison step within a sorting algorithm, likely a comparison-based sort like bubble sort or insertion sort.  The diagram shows a numerical array `nums` represented as `[... 14 5 5 20 ...]`, indicating a partial view of a larger array.  Two index variables, `i` (in an orange box) and `j` (in a light-blue box), point to elements 14 and 5 respectively within the array.  Arrows descend from `i` and `j` to highlight these selected elements.  To the right, a dashed-line box describes a function call `compare(nums[i], nums[j])`, which implies a comparison operation between the values at indices `i` and `j` (14 and 5 in this instance).  A right-pointing arrow from this box leads to the text 'make decision,' indicating that the result of the comparison (whether `nums[i]` > `nums[j]`, `nums[i]` < `nums[j]`, or `nums[i]` == `nums[j]`) will determine the next step in the sorting algorithm.

In many cases, such comparisons are made using two nested for-loops, which takes O(n2)O(n^2)O(n2) time, where n denotes the length of the data structure. In the code snippet below, i and j are two pointers used to compare every two elements of an array:

python
for i in range(n):
    for j in range(i + 1, n):
        compare(nums[i], nums[j])

Often, this approach does not take advantage of predictable dynamics that might exist in a data structure. An example of a data structure with predictable dynamics is a sorted array: when we move a pointer in a sorted array, we can predict whether the value being moved to is greater or smaller. For example, moving a pointer to the right in an ascending array guarantees we're moving to a value greater than or equal to the current one:

Image represents a diagram illustrating a coding pattern or prediction within a sorted array.  At the top, a small orange square containing the letter 'i' points downwards, indicating an index or iterator.  Below, a sorted integer array `[1 2 3 4]` is shown, with a grey line underneath labeled 'sorted array'.  A dashed-line box at the bottom contains a prediction statement written in a pseudo-code style: 'prediction: if nums[i] == 2, then nums[i + 1] ≥ 2'. This statement suggests that if the element at index 'i' in the `nums` array is equal to 2, then the element at the next index (i+1) must be greater than or equal to 2. The arrow from 'i' implies that 'i' is used to index into the array, likely as part of an algorithm or conditional check.

As you can see, data structures with predictable dynamics let us move pointers in a logical way. Taking advantage of this predictability can lead to improved time and space complexity, which we will illustrate with real interview problems in this chapter.

Two-pointer Strategies

Two-pointer algorithms usually take only O(n)O(n)O(n) time by eliminating the need for nested for-loops. There are three main strategies for using two pointers.

Inward traversal This approach has pointers starting at opposite ends of the data structure and moving inward toward each other:

Image represents a visual depiction of a two-pointer approach in a coding pattern, likely for array traversal or similar operations.  The diagram shows a sequence of eight black dots enclosed within square brackets, representing elements in a data structure.  Above the dots, two rectangular boxes are labeled 'left' (in orange) and 'right' (in light blue).  Dashed orange lines connect the 'left' box to the first four dots, indicating a pointer traversing from left to right across this section. Similarly, dashed light blue lines connect the 'right' box to the last four dots, showing a pointer moving from right to left.  Both pointers converge on the central dot, suggesting a meeting point in the algorithm. The arrows from the 'left' and 'right' boxes point downwards, indicating the direction of pointer movement towards the center. The overall structure illustrates a common algorithm strategy where two pointers start from opposite ends of a data structure and move towards each other until they meet or a specific condition is met.

The pointers move toward the center, adjusting their positions based on comparisons, until a certain condition is met, or they meet/cross each other. This is ideal for problems where we need to compare elements from different ends of a data structure.

Unidirectional traversal In this approach, both pointers start at the same end of the data structure (usually the beginning) and move in the same direction:

The image represents a visual depiction of a two-pointer approach to traversing a data structure, likely an array or list.  The structure is shown as a sequence of black dots enclosed within square brackets `[ ]`, representing individual elements.  An orange rectangle labeled 'left' points a downward arrow to the leftmost element within a subset of the dots.  Dashed orange lines with arrowheads connect this element to the next few elements to its right, indicating a traversal from left to right.  Similarly, a light-blue rectangle labeled 'right' points a downward arrow to the rightmost element within a different subset of the dots.  Dashed light-blue lines with arrowheads connect this element to the preceding few elements to its left, indicating a traversal from right to left.  The two pointers, indicated by the orange and light-blue arrows, are moving towards the center of the data structure, suggesting a merging or comparison process commonly used in algorithms like merge sort or two-pointer techniques for finding pairs with a specific sum.

These pointers generally serve two different but supplementary purposes. A common application of this is when we want one pointer to find information (usually the right pointer) and another to keep track of information (usually the left pointer).

Staged traversal In this approach, we traverse with one pointer, and when it lands on an element that meets a certain condition, we traverse with the second pointer:

Image represents a diagram illustrating a coding pattern, likely related to data processing or algorithm execution.  The diagram shows two stages separated by a large right arrow. The first stage depicts a sequence of black dots enclosed in square brackets, representing data elements.  An orange rectangular box labeled 'first' points downwards with an arrow to the second element in the sequence, indicating a process or function ('first') acting upon this specific element.  The connection is shown with a dashed orange line forming a curved arc above the data sequence. The second stage, also enclosed in square brackets, shows the same data sequence but with a different transformation. A light-blue rectangular box labeled 'second' points downwards with an arrow to the second element in this new sequence, indicating a different process or function ('second') acting upon the same element. The connection is shown with a dashed light-blue line forming a curved arc above the data sequence. A third, identical orange box labeled 'first' is shown above the second stage, suggesting the 'first' process is applied again, possibly iteratively or in parallel with the 'second' process.  The overall transformation shows how the initial data sequence is modified by the sequential or parallel application of the 'first' and 'second' processes.

Similar to unidirectional traversal, both pointers serve different purposes. Here, the first pointer is used to search for something, and once found, a second pointer finds additional information concerning the value at the first pointer.

We discuss all of these techniques in detail throughout the problems in this chapter.

When To Use Two Pointers?

A two-pointer algorithm usually requires a linear data structure, such as an array or linked list. Otherwise, an indication that a problem can be solved using the two-pointer algorithm, is when the input follows a predictable dynamic, such as a sorted array.

Predictable dynamics can take many forms. Take, for instance, a palindromic string. Its symmetrical pattern allows us to logically move two pointers toward the center. As you work through the problems in this chapter, you'll learn to recognize these predictable dynamics more easily.

Another potential indicator that a problem can be solved using two pointers is if the problem asks for a pair of values or a result that can be generated from two values.

Real-world Example

Garbage collection algorithms: In memory compaction – which is a key part of garbage collection – the goal is to free up contiguous memory space by eliminating gaps left by deallocated (aka dead) objects. A two-pointer technique helps achieve this efficiently: a 'scan' pointer traverses the heap to identify live objects, while a 'free' pointer keeps track of the next available space to where live objects should be relocated. As the 'scan' pointer moves, it skips over dead objects and shifts live objects to the position indicated by the 'free' pointer, compacting the memory by grouping all live objects together and freeing up continuous blocks of memory.

Chapter Outline

Image represents a hierarchical diagram illustrating different coding patterns categorized under the umbrella term 'Two Pointers'.  A rounded rectangle at the top labeled 'Two Pointers' acts as the root node, branching down via dashed lines to three subordinate rectangular boxes representing distinct traversal types.  The leftmost box, 'Inward Traversal,' lists four sub-problems: 'Pair Sum - Sorted,' 'Triplet Sum,' 'Largest Container,' and 'Is Palindrome Valid.' The rightmost box, 'Unidirectional Traversal,' contains a single sub-problem: 'Shift Zeros to the End.'  Finally, the bottom box, 'Staged Traversal,' lists one sub-problem: 'Next Lexicographical Sequence.'  The dashed lines indicate a hierarchical relationship, showing how each traversal type falls under the broader 'Two Pointers' category, and the listed items are specific problems solvable using that traversal technique.

The two-pointer pattern is very versatile and, consequently, quite broad. As such, we want to cover more specialized variants of this algorithm in separate chapters, such as Fast and Slow Pointers and Sliding Windows.

Pair Sum - Sorted

Pair Sum - Sorted

Given an array of integers sorted in ascending order and a target value, return the indexes of any pair of numbers in the array that sum to the target. The order of the indexes in the result doesn't matter. If no pair is found, return an empty array.

Example 1:

python
Input: nums = [-5, -2, 3, 4, 6], target = 7
Output: [2, 3]

Explanation: nums[2] + nums[3] = 3 + 4 = 7

Example 2:

python
Input: nums = [1, 1, 1], target = 2
Output: [0, 1]

Explanation: other valid outputs could be [1, 0], [0, 2], [2, 0], [1, 2] or [2, 1].

Intuition

The brute force solution to this problem involves checking all possible pairs. This is done using two nested loops: an outer loop that traverses the array for the first element of the pair, and an inner loop that traverses the rest of the array to find the second element. Below is the code snippet for this approach:

python
from typing import List
  
def pair_sum_sorted_brute_force(nums: List[int], target: int) -> List[int]:
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []

This approach has a time complexity of O(n2)O(n^2)O(n2), where nnn denotes the length of the array. This approach does not take into account that the input array is sorted. Could we use this fact to come up with a more efficient solution?

A two-pointer approach is worth considering here because a sorted array allows us to move the pointers in a logical way. Let's see how this works in the example below:

Image represents a sample input for a coding problem likely related to finding pairs of numbers within an array that sum to a target value.  The image shows an integer array `[-5, -2, 3, 4, 6]` enclosed in square brackets, with the index of each element subtly displayed below it (0 through 4).  A comma separates the array from the statement 'target = 7,' which specifies the target sum to be found.  The arrangement implies that the algorithm should search within the provided array for two numbers that add up to 7.  The indices are not explicitly part of the input data but are visually provided to aid understanding of the array's structure.

A good place to start is by looking at the smallest and largest values: the first and last elements, respectively. The sum of these two values is 1.

Image represents a diagram illustrating a simple summation operation on an array.  Two orange rectangular boxes labeled 'left' and 'right' point downwards with arrows towards a numerical array [-5, -2, 3, 4, 6].  The 'left' arrow points to the first two elements of the array, [-5, -2], while the 'right' arrow points to the last three elements, [3, 4, 6].  The array elements are indexed below with numbers 0 through 4, indicating their positions.  A separate, light-grey, dashed-line rectangular box displays the result of the summation, showing 'sum = 1'. This implies that the sum of all elements in the array (-5 + -2 + 3 + 4 + 6) equals 1.

Since 1 is less than the target, we need to move one of our pointers to find a new pair with a larger sum.

  • Left pointer: The left pointer will always point to a value less than or equal to the value at the right pointer because the array is sorted. Incrementing it would result in a sum greater than or equal to the current sum of 1.
  • Right pointer: Decrementing the right pointer would result in a sum that’s less than or equal to 1.

Therefore, we should increment the left pointer to find a larger sum:

Image represents a visual depiction of a two-pointer algorithm, likely used to find a pair of numbers in a sorted array that sum to a target value (not explicitly shown but implied).  The top section shows an initial state with two pointers, labeled 'left' and 'right,' pointing to the beginning and end, respectively, of a sorted array [-5, -2, 3, 4, 6].  The indices (0, 1, 2, 3, 4) are shown below the array elements.  A dashed box shows the current sum (1, calculated as -5 + 6) being compared to a target value (7, also implied).  Since the sum (1) is less than 7, a solid arrow indicates that the 'left' pointer is incremented (left += 1). The bottom section illustrates the updated state after the pointer increment, where the 'left' pointer now points to -2.  The 'right' pointer remains unchanged. The dotted arrow emphasizes the movement of the 'left' pointer.  The overall diagram demonstrates a single iteration of the algorithm, highlighting the pointer movement based on the comparison of the current sum with the target value.

Again, the sum of the values at those two pointers (4) is too small. So, let's increment the left pointer:

Image represents a visual depiction of a coding pattern, likely illustrating a two-pointer approach or a similar algorithm operating on an array.  The top section shows an array `[-5, -2, 3, 4, 6]` with pointers labeled 'left' and 'right' initially pointing to the first and last elements respectively.  The indices of the array elements are shown below the array. A dashed box displays a condition: `sum = 4 < 7`, indicating that the sum of elements pointed to by 'left' (3) and 'right' (6) is 4, which is less than 7.  A thick arrow connects this condition to another dashed box showing the consequence: `left += 1`, signifying that the 'left' pointer is incremented. The bottom section shows the state after this operation: the 'left' pointer has moved one position to the right, now pointing to the element 3, while the 'right' pointer remains unchanged. The 'left' pointer in the bottom section is highlighted in orange to emphasize its movement.  The overall diagram illustrates a step-by-step execution of a code segment that iterates through an array, potentially searching for a specific condition or performing a calculation based on the values pointed to by the 'left' and 'right' pointers.

Now, the sum (9) is too large. So, we should decrement the right pointer to find a pair of values with a smaller sum:

Image represents a visual depiction of a coding pattern, possibly illustrating a segment of an algorithm.  The top section shows an array `[-5, -2, 3, 4, 6]` with indices 0 through 4 labeled below.  Above the array, two rectangular boxes labeled 'left' and 'right' point downwards, indicating pointers or indices.  The 'left' pointer points to the element '3' (index 2), and the 'right' pointer points to the element '6' (index 4).  A dashed box to the right shows a calculation: 'sum = 9 > 7', implying the sum of the elements pointed to by 'left' and 'right' (3 + 6 = 9) is greater than 7.  An arrow then points to another box indicating 'right -= 1', suggesting the 'right' pointer's index is decremented. The bottom section shows the updated state after this operation. The array remains the same, but the 'right' pointer (now orange) points to the element '4' (index 3), illustrating the effect of the decrement operation.  A dotted line shows the movement of the 'right' pointer from index 4 to index 3. The 'left' pointer remains unchanged.

Finally, we found two numbers that yield a sum equal to the target. Let’s return their indexes:

Image represents a diagram illustrating a coding pattern, possibly related to array processing or a divide-and-conquer algorithm.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  Arrows descend from these boxes, pointing to the elements '3' and '4' respectively, within a horizontal array represented as `[-5, -2, 3, 4, 6]`.  The array indices (0, 1, 2, 3, 4) are displayed beneath the corresponding array elements. A light gray, dashed-bordered rectangle contains the expression 'sum = 7 == 7', indicating a comparison where the sum of some values (implied to be '3' and '4') equals 7.  A thick arrow points from this comparison to the text 'return [left, right]', suggesting that if the comparison is true, the function returns an array containing the values 'left' and 'right'.  The overall diagram visually depicts the process of selecting elements from an array based on some condition (here, implicitly the sum of selected elements), and returning a result based on those selections.

Above, we’ve demonstrated a two-pointer algorithm using inward traversal. Let’s summarize this logic. For any pair of values at left and right:

  • If their sum is less than the target, increment left, aiming to increase the sum towards the target value.
  • If their sum is greater than the target, decrement right, aiming to decrease the sum towards the target value.
  • If their sum is equal to the target value, return [left, right].

We can stop moving the left and right pointers when they meet, as this indicates no pair summing to the target was found.

Implementation

python
from typing import List
       
def pair_sum_sorted(nums: List[int], target: int) -> List[int]:
    left, right = 0, len(nums) - 1
    while left < right:
        sum = nums[left] + nums[right]
        # If the sum is smaller, increment the left pointer, aiming to increase the
        # sum towards the target value.
        if sum < target:
            left += 1
        # If the sum is larger, decrement the right pointer, aiming to decrease the
        # sum towards the target value.
        elif sum > target:
            right -= 1
        # If the target pair is found, return its indexes.
        else:
            return [left, right]
    return []

Complexity Analysis

Time complexity: The time complexity of pair_sum_sorted is O(n)O(n)O(n) because we perform approximately nnn iterations using the two-pointer technique in the worst case.

Space complexity: We only allocated a constant number of variables, so the space complexity is O(1)O(1)O(1).

Test Cases

In addition to the examples already discussed, here are some other test cases you can use. These extra test cases cover different contexts to ensure the code works well across a range of inputs. Testing is important because it helps identify mistakes in your code, ensures the solution works for uncommon inputs, and brings attention to cases you might have overlooked.

InputExpected outputDescription
nums = [] target = 0[]Tests an empty array.
nums = [1] target = 1[]Tests an array with just one element.
nums = [2, 3] target = 5[0, 1]Tests a two-element array that contains a pair that sums to the target.
nums = [2, 4] target = 5[]Tests a two-element array that doesn’t contain a pair that sums to the target.
nums = [2, 2, 3] target = 5[0, 2] or [1, 2]Testing an array with duplicate values.
nums = [-1, 2, 3] target = 2[0, 2]Tests if the algorithm works with a negative number in the target pair.
nums = [-3, -2, -1] target = -5[0, 1]Tests if the algorithm works with both numbers of the target pair being negative.

Interview Tip

Tip: Consider all information provided. When interviewers pose a problem, they sometimes provide only the minimum amount of information required for you to start solving it. Consequently, it’s crucial to thoroughly evaluate all that information to determine which details are essential for solving the problem efficiently. In this problem, the key to arriving at the optimal solution is recognizing that the input is sorted.

Triplet Sum

Triplet Sum

Given an array of integers, return all triplets [a, b, c] such that a + b + c = 0 . The solution must not contain duplicate triplets (e.g., [1, 2, 3] and [2, 3, 1] are considered duplicates). If no such triplets are found, return an empty array.

Each triplet can be arranged in any order, and the output can be returned in any order.

Example:

python
Input: nums = [0, -1, 2, -3, 1]
Output: [[-3, 1, 2], [-1, 0, 1]]

Intuition

A brute force solution involves checking every possible triplet in the array to see if they sum to zero. This can be done using three nested loops, iterating through each combination of three elements.

Duplicate triplets can be avoided by sorting each triplet, which ensures that identical triplets with different representations (e.g., [1, 3, 2] and [3, 2, 1]) are ordered consistently (e.g., [1, 2, 3]). Once sorted, we can add these triplets to a hash set. This way, if the same triplet is encountered again, the hash set will only keep one instance. Below is the code snippet for this approach:

python
from typing import List
   
def triplet_sum_brute_force(nums: List[int]) -> List[List[int]]:
    n = len(nums)
    # Use a hash set to ensure we don't add duplicate triplets.
    triplets = set()
    # Iterate through the indexes of all triplets.
    for i in range(n):
        for j in range(i + 1, n):
            for k in range(j + 1, n):
                if nums[i] + nums[j] + nums[k] == 0:
                    # Sort the triplet before including it in the hash set.
                    triplet = tuple(sorted([nums[i], nums[j], nums[k]]))
                    triplets.add(triplet)
    return [list(triplet) for triplet in triplets]

This solution is quite inefficient with a time complexity of O(n3)O(n^3)O(n3), where nnn denotes the length of the input array. How can we do better?

Let’s see if we can find at least one triplet that sums to 0. Notice that if we fix one of the numbers in a triplet, the problem can be reduced to finding the other two. This leads to the following observation:

For any triplet [a, b, c], if we fix 'a', we can focus on finding a pair [b, c] that sums to '-a' (a + b + c = 0 → b + c = -a).

Sound familiar? That's because the problem of finding a pair of numbers that sum to a target has already been addressed by Pair Sum - Sorted. However, we can only use that algorithm on a sorted array. So, the first thing we should do is sort the input. Consider the following example:

Image represents a visual depiction of a sorting operation.  The image shows an unsorted array `[-1, 2, -2, 1, -1, 2]` enclosed in square brackets on the left.  A rightward-pointing arrow labeled 'sort' connects this initial array to a second array `[-2, -1, -1, 1, 2, 2]` also enclosed in square brackets on the right. The arrow indicates the transformation of the input array (on the left) into the sorted output array (on the right) using a sorting algorithm (implied by the label 'sort').  The numbers within each array represent the elements of the array, and their order demonstrates the before-and-after effect of the sorting process.  The output array shows the elements of the input array arranged in ascending order.

Now, starting at the first element, -2 (i.e., 'a'), we'll use the pair_sum_sorted method on the rest of the array to find a pair whose sum equals 2 (i.e., '-a'):

Image represents a visual depiction of the `pair_sum_sorted` function's execution on a sorted array `[-2, -1, -1, 1, 2, 2]`, aiming to find triplets summing to a target of 2.  The top shows the input array `a` labeled with 'i' indicating an index. An arrow points downwards to the function call `pair_sum_sorted([-1, -1, 1, 2, 2], target = 2)`, excluding the initial -2. The function iteratively uses two pointers, `left` and `right`, initially pointing to the first and last elements of the sub-array, respectively.  Each step shows the current array section, the positions of `left` and `right` highlighted in orange boxes, the calculated `sum` of the elements at these positions, and the resulting adjustment to `left` or `right` based on whether the `sum` is less than, greater than, or equal to the target.  The process continues until `left` and `right` cross, at which point 'no pairs found' is indicated, implying no triplet starting with -2 sums to 2.  Finally, a concluding statement 'no triplet exists that starts with -2' summarizes the function's outcome.

As you can see, when we called pair_sum_sorted, we did not find a pair with a sum of 2. This indicates that there are no triplets starting with -2 that add up to 0.

So, let's increment our main pointer, i, and try again.

Image represents a flowchart illustrating the `pair_sum_sorted` algorithm.  The algorithm starts with a sorted input array `[-2, -1, -1, 1, 2, 2]` labeled 'a'.  The function `pair_sum_sorted([-1, 1, 2, 2], target = 1)` is then called, indicating a search for pairs summing to 1 within the sub-array `[-1, 1, 2, 2]`.  Two pointers, `left` and `right`, initially point to the first and last elements of this sub-array respectively. The algorithm iteratively checks the sum of the elements pointed to by `left` and `right`. If the sum equals the target (1), a pair is found ([-1, 2], labeled 'b' and 'c'), and `left` is incremented to find more pairs. If the sum is greater than the target, `right` is decremented. If the sum is less than the target, `left` is incremented (not explicitly shown but implied). This process continues until `left` and `right` cross, at which point the algorithm terminates. The final result, pairs found `[-1, 2]`, is shown, along with a note indicating how these pairs are added to a result array.  The flowchart visually depicts the flow of control and data manipulation within the algorithm, showing the changes in pointer positions and the conditional logic based on the sum of the elements.

This time, we found one pair that resulted in a valid triplet.

If we continue this process for the rest of the array, we find that [-1, -1, 2] is the only triplet whose sum is 0.

There’s an important difference between the pair_sum_sorted implementation in Pair Sum - Sorted and the one in this problem: for this problem, we don’t stop when we find one pair, we keep going until all target pairs are found.

Handling duplicate triplets Something we previously glossed over is how to avoid adding duplicate triplets. There are two cases in which this happens. Consider the example below:

Image represents a one-dimensional array or list of integers enclosed within square brackets.  The array contains nine integer elements: -4, -4, -2, 0, 0, 1, 2, and 3. These elements are arranged sequentially from left to right, separated by spaces. No other components, connections, or information flow are depicted; the image solely presents the data structure itself.  There are no URLs, parameters, or other labels beyond the numerical values and the enclosing brackets.

Case 1: duplicate ‘a’ values‾\underline{\text{Case 1: duplicate ‘a’ values}}Case 1: duplicate ‘a’ values​

The first instance where duplicates may occur is when seeking pairs for triplets that start with the same ‘a’ value:

Image represents two identical examples of a data transformation process.  Each example shows a sequence of numbers arranged into three labeled groups: 'a', 'b', and 'c'. Group 'a' contains the numbers [-4, -4, -2, 0], group 'b' contains [0, 1, 2], and group 'c' contains [3].  A rightward arrow indicates a transformation where the first element from each group ('a', 'b', and 'c') – specifically -4, 0, and 3 – are extracted and combined to form a new data structure labeled 'triplet [-4 1 3]'.  The arrangement is replicated identically below the first example, emphasizing the consistent application of the transformation rule.  The labels 'a', 'b', and 'c' are consistently placed above their respective number groups in both examples.

Since pair_sum_sorted would look for pairs that sum ‘-a’ in both instances, we’d naturally end up with the same pairs and, hence, the same triplets.

To avoid picking the same 'a' value, we keep increasing i (where num[i] represents the value 'a') until it reaches a different number from the previous one. We do this before we start looking for pairs using the pair_sum_sorted method. This logic works because the array is sorted, meaning equal numbers are next to each other. The code snippet for checking duplicate 'a' values looks like this:

python
# To prevent duplicate triplets, ensure 'a' is not a repeat of the previous element
# in the sorted array.
if i > 0 and nums[i] == nums[i - 1]:
    continue
... Find triplets ...

Case 2: duplicate ‘b’ values‾\underline{\text{Case 2: duplicate ‘b’ values}}Case 2: duplicate ‘b’ values​

As for the second case, consider what happens during pair_sum_sorted when we encounter a similar issue. For a fixed target value (‘-a’), pairs that start with the same number ‘b’ will always be the same:

Image represents two examples of extracting a triplet from a larger array.  Each example shows a numerical array enclosed in square brackets, containing the elements [-4, -4, -2, 0, 0, 1, 2, 3].  Above each element within the array, labels 'a', 'b', and 'c' are placed to identify sections. In both examples, a sub-array consisting of three elements is highlighted with a cyan-colored box.  The first example highlights the elements 0, 0, and 1 (labeled b, b, and c respectively), while the second example highlights the elements 0, 0, and 1 (labeled a, b, and c respectively). A rightward arrow connects each array to the resulting 'triplet,' which is represented as [-2, 0, 2] in both cases.  Note that the triplet's values are not directly taken from the highlighted sub-array but rather seem to be a result of some transformation or operation not explicitly shown in the diagram.

The remedy for this is the same as before: ensure the current ‘b’ value isn’t the same as the previous value.

It’s important to note that we don’t need to explicitly handle duplicate 'c' values. The adjustments made to avoid duplicate 'a' and 'b' values ensure each pair [a, b] is unique. Since 'c' is determined by the equation c = -(a + b), each unique [a, b] pair will result in a unique 'c' value. Therefore, by just avoiding duplicates in 'a' and 'b', we automatically avoid duplicates in the [a, b, c] triplets.

Optimization An interesting observation is that triplets that sum to 0 cannot be formed using positive numbers alone. Therefore, we can stop trying to find triplets once we reach a positive ‘a’ value since this implies that ‘b’ and ‘c’ would also be positive.

Implementation

From the above intuition, we know we need to slightly modify the pair_sum_sorted function to avoid duplicate triplets. We also need to pass in a start value to indicate the beginning of the subarray on which we want to perform the pair-sum algorithm. Otherwise, the two-pointer logic remains nearly identical to that of Pair Sum - Sorted.

python
from typing import List
  
def triplet_sum(nums: List[int]) -> List[List[int]]:
    triplets = []
    nums.sort()
    for i in range(len(nums)):
        # Optimization: triplets consisting of only positive numbers will never sum
        # to 0.
        if nums[i] > 0:
            break
        # To avoid duplicate triplets, skip 'a' if it's the same as the previous
        # number.
        if i > 0 and nums[i] == nums[i - 1]:
            continue
        # Find all pairs that sum to a target of '-a' ('-nums[i]').
        pairs = pair_sum_sorted_all_pairs(nums, i + 1, -nums[i])
        for pair in pairs:
            triplets.append([nums[i]] + pair)
    return triplets
  
def pair_sum_sorted_all_pairs(nums: List[int], start: int, target: int) -> List[int]:
    pairs = []
    left, right = start, len(nums) - 1
    while left < right:
        sum = nums[left] + nums[right]
        if sum == target:
            pairs.append([nums[left], nums[right]])
            left += 1 # To avoid duplicate '[b, c]' pairs, skip 'b' if it’s the same as the # previous number.
            while left < right and nums[left] == nums[left - 1]:
                left += 1
        elif sum < target:
            left += 1 
        else:
            right -= 1
    return pairs

Complexity Analysis

Time complexity: The time complexity of triplet_sum is O(n2)O(n^2)O(n2). Here’s why:

  • We first sort the array, which takes O(nlog⁡(n))O(n\log(n))O(nlog(n)) time.
  • Then, for each of the nnn elements in the array, we call pair_sum_sorted_all_pairs at most once, which runs in O(n)O(n)O(n) time.

Therefore, the overall time complexity is O(log⁡(n))+O(n2)=O(n2)O(\log(n))+O(n^2)=O(n^2)O(log(n))+O(n2)=O(n2).

Space complexity: The space complexity is O(n)O(n)O(n) due to the space taken up by Python’s sorting algorithm. It's important to note that this complexity does not include the output array triplets because we’re only concerned with the additional space used by the algorithm, not the space needed for the output itself.

If the interviewer asks what the space complexity would be if we included the output array, it would be O(n2)O(n^2)O(n2). This is because the pair_sum_sorted_all_pairs function, in the worst case, can add approximately nnn pairs to the output. Since this function is called approximately nnn times, the overall space complexity is O(n2)O(n^2)O(n2).

Test Cases

In addition to the examples already covered in this explanation, below are some others to consider when testing your code.

InputExpected outputDescription
nums = [][]Tests an empty array.
nums = [0][]Tests a single-element array.
nums = [1, -1][]Tests a two-element array.
nums = [0, 0, 0][0, 0, 0]Tests an array where all three of its values are the same.
nums = [1, 0, 1][]Tests an array with no triplets that sum to 0.
nums = [0, 0, 1, -1, 1, -1][-1, 0, 1]Tests an array with duplicate triplets.

Is Palindrome Valid

Is Palindrome Valid

A palindrome is a sequence of characters that reads the same forward and backward.

Given a string, determine if it's a palindrome after removing all non-alphanumeric characters. A character is alphanumeric if it's either a letter or a number.

Example 1:

python
Input: s = 'a dog! a panic in a pagoda.'
Output: True

Example 2:

python
Input: s = 'abc123'
Output: False

Constraints:

  • The string may include a combination of lowercase English letters, numbers, spaces, and punctuations.

Intuition

Identifying palindromes A string is a palindrome if it remains identical when read from left to right or right to left. In other words, if we reverse the string, it should still read the same, disregarding spaces and punctuation:

Image represents a simple data flow diagram illustrating a string reversal operation.  The diagram shows the input string 'racecar' enclosed in double quotes, positioned on the left.  A rightward-pointing arrow connects this input to the word 'reverse', indicating the operation being performed.  Another rightward-pointing arrow then connects 'reverse' to the output string 'racecar', also enclosed in double quotes, positioned on the right.  The arrangement visually depicts the transformation of the input string 'racecar' into its reversed form, 'racecar,' through the application of the 'reverse' function.  The entire diagram is linear, showing a clear, unidirectional flow of data from input to output via a clearly labeled process.

An important observation is that if a string is a palindrome, the first character would be the same as the last, the second character would be the same as the second-to-last, etc:

Image represents a visual depiction of a nested structure, likely illustrating a coding pattern such as nested loops or recursive function calls.  The image shows the letters 'b y t e e t y b' arranged horizontally.  Each letter is vertically connected to a horizontal line segment extending downwards. These line segments are nested, with the line under 'y t e e t y' being the longest, encompassing shorter lines beneath 't e e t'. The shortest lines are directly under 't' and 'e', and 'e' and 't'. The outermost line segment connects the 'b' at each end, creating a visual representation of a hierarchical or nested structure where the inner segments are contained within the outer ones.  The arrangement suggests a parent-child relationship, where the outer structure contains and encompasses the inner structures.

A palindrome of odd length is different because it has a middle character. In this case, the middle character can be ignored since it has no “mirror” character elsewhere in the string.

Image represents a visual depiction of a palindrome check using a nested loop approach.  The word 'racecar' is displayed at the top, with each letter positioned above a corresponding vertical bar.  The letter 'e' is highlighted in orange, representing the central character of the palindrome.  A series of nested rectangular boxes are drawn underneath the letters. The outermost box encloses all the letters, representing the entire string.  Inner boxes are nested around the 'a', 'c', and 'r' letters on both sides of the central 'e', visually demonstrating the comparison of characters from the beginning and end of the string moving inwards. The structure implies that the algorithm iteratively compares characters from the outer ends, working its way towards the center, to determine if the string is a palindrome.  The nested boxes visually represent the nested loop structure of the algorithm, where the outer loop iterates through the string from both ends, and the inner loop (implied) compares the characters at each iteration.

Palindromes provides an ideal scenario for using two pointers (left and right). By initially setting the pointers at the beginning and end of the string, we can compare the characters at these positions. Ignoring non-alphanumeric characters for the moment, the logic can be summarized as follows:

  • If the alphanumeric characters at left and right are the same, move both pointers inward to process the next pair of characters.
  • If not, the string is not a palindrome: return false.

If we successfully compare all character pairs without returning false, the string is a palindrome, and we should return true.

Processing non-alphanumeric characters Now, let's explore how to find palindromes that include non-alphanumeric characters.

Since non-alphanumeric characters don’t affect whether a string is a palindrome, we should skip them. This can be achieved with the following approach, which ensures the left and right pointers are adjusted to focus only on alphanumeric characters:

  • Increment left until the character it points to is alphanumeric.
  • Decrement right until the character it points to is alphanumeric.

With this in mind, let’s check if the string below is a palindrome using all the information we know so far:

Image represents a diagram illustrating a coding pattern, possibly related to array or string manipulation.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above a line of text 'a + 2 c ! 2 a'.  Arrows descend from each box, pointing to the respective ends of this text string. Below, a light-grey, dashed-bordered rectangle contains the conditional statement 's[left] == s[right]', which checks for equality between elements at indices 'left' and 'right' of an array or string 's'. A right-pointing arrow follows this condition, leading to the consequent action 'left += 1, right -= 1', indicating that the 'left' index is incremented and the 'right' index is decremented.  The overall diagram suggests an algorithm that iterates through a sequence from both ends, comparing elements until a mismatch is found or the indices cross.
Image represents a diagram illustrating a coding pattern.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  A downward-pointing arrow extends from each box. Below the boxes, a string 'a + 2 c ! 2 a' is displayed. The 'left' arrow points to the 'a + 2' portion of the string, and the 'right' arrow points to the 'c ! 2 a' portion.  A light gray, dashed-bordered rectangle contains the text 's[left] is not alphanumeric → left += 1,' indicating a conditional statement.  This statement checks if the character at the index specified by the variable `left` within string `s` is not alphanumeric. If the condition is true, the value of `left` is incremented by 1.  The arrow within the gray rectangle visually represents the flow of control based on the condition's outcome.
Image represents a diagram illustrating a coding pattern, possibly related to string manipulation or array processing.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  Arrows descend from each box pointing to a string 'a + 2 c ! 2 a'. This string appears to be the input data, with 'left' pointing to the 'a + 2' segment and 'right' pointing to the '2 a' segment. Below the input string, a light gray, dashed-bordered rectangle contains the core logic: 's[left] == s[right] → left += 1, right -= 1'. This expression signifies a comparison: if the element at index 'left' in string 's' is equal to the element at index 'right', then the 'left' index is incremented by 1, and the 'right' index is decremented by 1.  This suggests an algorithm that iterates from both ends of the string, comparing elements until a mismatch is found or the indices cross. The arrow ('→') indicates the conditional action taken upon successful comparison.
Image represents a diagram illustrating a coding pattern, specifically a conditional assignment.  At the top, two rectangular boxes labeled 'left' and 'right' (in orange) are shown.  Downward arrows from these boxes point to a string 'a + 2 c ! 2 a'. This string appears to represent an input, with 'left' pointing to 'a + 2 c' and 'right' pointing to '! 2 a'. Below this, a light gray, dashed-bordered rectangle contains the conditional statement: 's[right] is not alphanumeric → right -= 1'. This statement indicates that if the substring referenced by `s[right]` (which is '! 2 a' in this example) is not alphanumeric, then the value of the variable `right` is decremented by 1.  The arrow ('→') signifies the conditional flow, showing the consequence of the condition being met.  The diagram visually demonstrates how the 'right' variable's value might change based on the content of the input string.
Image represents a diagram illustrating a coding pattern.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above a sequence of characters: 'a + 2 c ! 2 a'.  Arrows descend from each box, pointing to the characters 'a', '2', 'c', '!', '2', and 'a' respectively, suggesting a mapping or association between the labels and the character sequence.  To the right, a light gray, dashed-bordered rectangle contains the expression 'left == right → break'. This indicates a conditional statement: if the value of 'left' is equal to the value of 'right', then the program will 'break' (likely exiting a loop or conditional block). The arrow signifies the flow of control based on the comparison's outcome.  The overall diagram likely depicts a scenario where the equality of 'left' and 'right' (potentially variables or values) determines the execution path, influencing the processing of the character sequence below.

As shown above, when the left and right pointers meet, it signals our exit condition. When these pointers meet, we've reached the middle character of the palindrome, at which point we can exit the loop since the middle character doesn’t need to be evaluated. However, we need to keep in mind that exiting when left equals right won't always be sufficient as an exit condition. For example, if the number of alphanumeric characters is even, the pointers won’t meet. This can be observed below:

Image represents a diagram illustrating a coding pattern, possibly related to string manipulation or palindrome checking.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown.  Arrows point downwards from these boxes to a sequence of characters 'a b b a' below.  This sequence appears to be a string being processed. A light gray, dashed-bordered rectangle contains the core logic: 's[left] == s[right] → left += 1, right -= 1'. This expression indicates a comparison:  the character at index `left` in string `s` is compared to the character at index `right`. If they are equal (indicated by '=='), then the `left` index is incremented by 1 ('left += 1') and the `right` index is decremented by 1 ('right -= 1'). This suggests an iterative approach, moving inwards from both ends of the string, likely to check for palindromic properties. The arrow ('→') signifies the conditional execution of the index updates based on the comparison result.
Image represents a simple diagram illustrating a coding pattern, likely related to data manipulation or sorting.  Two orange rectangular boxes, labeled 'right' and 'left' respectively, are positioned side-by-side.  A downward-pointing arrow extends from each box.  The 'right' box's arrow points to a vertically stacked 'a' and 'b', while the 'left' box's arrow points to a vertically stacked 'b' and 'a'. This arrangement visually depicts a swapping or inversion operation where the data elements 'a' and 'b' are exchanged depending on their position relative to the 'right' and 'left' labels, suggesting a potential algorithm involving comparison and rearrangement of data based on a defined criteria.

Therefore, we need to ensure we exit the loop when left equals right, or when left passes right. In other words, the algorithm continues while left is less than right:

python
while left < right:

Implementation

In Python, we can use the inbuilt isalnum method to check if a character is alphanumeric.

python
def is_palindrome_valid(s: str) -> bool:
    left, right = 0, len(s) - 1
    while left < right:
        # Skip non-alphanumeric characters from the left.
        while left < right and not s[left].isalnum():
            left += 1
        # Skip non-alphanumeric characters from the right.
        while left < right and not s[right].isalnum():
            right -= 1
        # If the characters at the left and right pointers don’t match, the string is
        # not a palindrome.
        if s[left] != s[right]:
            return False
        left += 1
        right -= 1
    return True

Complexity Analysis

Time complexity: The time complexity of is_palindrome_valid is O(n)O(n)O(n), where nnn denotes the length of the string. This is because we perform approximately nnn iterations using the two-pointer technique.

Space complexity: We only allocated a constant number of variables, so the space complexity is O(1)O(1)O(1).

Test Cases

In addition to the examples discussed, below are more examples to consider when testing your code.

InputExpected outputDescription
s = ""TrueTests an empty string.
s = "a"TrueTests a single-character string.
s = "aa"TrueTests a palindrome with two characters.
s = "ab"FalseTests a non-palindrome with two characters.
s = "!, (?)"TrueTests a string with no alphanumeric characters.
s = "12.02.2021"TrueTests a palindrome with punctuation and numbers.
s = "21.02.2021"FalseTests a non-palindrome with punctuation and numbers.
s = "hello, world!"FalseTests a non-palindrome with punctuation.

Interview Tips

Tip 1: Clarify problem constraints. It's common to not receive all the details of a problem from an interviewer. For example, you might only be asked to "check if a string is a palindrome." But before diving into a solution, it's important to clarify details with the interviewer, such as the presence of non-alphanumeric characters, their treatment, the role of numbers, the case sensitivity of letters, and other relevant details.

Tip 2: Confirm before using significant in-built functions. This problem is made easier by using in-built functions such as .isalnum (or equivalent). Before using an in-built function that simplifies the implementation, ask the interviewer if it's okay to use it, or if they would prefer you implement it yourself.

The interviewer will most likely allow the use of an in-built function, or ask you to implement it as an exercise for later in the interview. If you use an in-built function, make sure you understand its time and space complexity.

Remember that interviewers are looking for team players, and this shows them you're considerate of their preferences and can adapt your approach based on the requirements.

Largest Container

Largest Container

You are given an array of numbers, each representing the height of a vertical line on a graph. A container can be formed with any pair of these lines, along with the x-axis of the graph. Return the amount of water which the largest container can hold.

Example:

Image represents a bar chart or histogram with a horizontal x-axis ranging from 0 to 5 and a vertical y-axis ranging from 0 to 8.  The y-axis represents frequency or count, while the x-axis likely represents categories or data points.  The chart displays several vertical bars of varying heights. A bar at x=0 has a height of 2.  Bars at x=1, x=2, x=4, and x=5 all reach a height of 6, forming a plateau of light-blue filled area.  A shorter bar at x=3 reaches a height of 3.  The bars are represented by vertical black lines extending from the x-axis to their respective heights on the y-axis.  The area under the bars from y=0 to y=6 between x=1 and x=5 is filled with light blue, visually highlighting the consistent frequency across those data points.
python
Input: heights = [2, 7, 8, 3, 7, 6]
Output: 24

Intuition

If we have two vertical lines, heights[i] and heights[j], the amount of water that can be contained between these two lines is min(heights[i], heights[j]) * (j - i), where j - i represents the width of the container. We take the minimum height because filling water above this height would result in overflow.

Image represents a graphical illustration of a calculation alongside its formula and result.  The left side shows a 2D Cartesian coordinate system with a light-blue rectangle representing a volume of water. The rectangle's base extends from x=0 to x=2 on the x-axis and its height is 3 units along the y-axis, reaching from y=0 to y=3.  A vertical line segment at x=1 is also present within the rectangle.  A curved grey arrow originates from the top-right corner of the rectangle and points to the right, indicating the direction of the calculation's result. To the right of the graph, the formula 'water = min(4, 3) ⋅ (2 - 0)' is displayed, calculating the volume of water.  'min(4, 3)' finds the minimum value between 4 and 3 (which is 3), and this is multiplied by the base length (2 - 0 = 2). The subsequent lines show the calculation steps: '3 ⋅ 2' and the final result '6', representing the total volume of water (likely in some arbitrary units).

In other words, the area of the container depends on two things:

  • The width of the rectangle.
  • The height of the rectangle, as dictated by the shorter of the two lines.

The brute force approach to this problem involves checking all pairs of lines, and returning the largest area found between each pair:

python
from typing import List
    
def largest_container_brute_force(heights: List[int]) -> int:
   n = len(heights)
   max_water = 0
   # Find the maximum amount of water stored between all pairs of lines.
   for i in range(n):
       for j in range(i + 1, n):
           water = min(heights[i], heights[j]) * (j - i)
           max_water = max(max_water, water)
   return max_water

Searching through all possible pairs of values takes O(n2)O(n^2)O(n2) time, where nnn denotes the length of the array. Let's look for a more efficient solution.

We would like both the height and width to be as large as possible to have the largest container.

It’s not immediately obvious how to find the container with the largest height, as the heights of the lines in the array don’t follow a clear pattern. However, we do know the container with the maximum width: the one starting at index 0 and ending at index n - 1.

So, we could start by maximizing the width by setting a pointer at each end of the array. Then, we can gradually reduce the width by moving these two pointers inward, hoping to find a container with a larger height that potentially yields a larger area. This suggests we can use the two-pointer pattern to solve the problem.

Moving a pointer inward means shifting either the left pointer to the right, or the right pointer to the left, effectively narrowing the gap between them.

Consider the following example:

Image represents a bar chart visualizing the data contained within the Python list `heights = [2, 7, 8, 3, 7, 6]`.  The horizontal axis represents the index of each element in the list (0 through 5), while the vertical axis represents the value of each element, ranging from 0 to 8.  Each vertical bar corresponds to an element in the list; its height corresponds to the element's value. For example, the first bar at index 0 has a height of 2, the second bar at index 1 has a height of 7, and so on. The vertical axis is marked with numerical labels (0 through 8) indicating the height of the bars, and the horizontal axis is implicitly marked by the position of the bars, corresponding to the list indices.  The arrowheads on the axes indicate the direction of increasing values.

The widest container can store an area of water equal to 10. Since this is the largest container we’ve found so far, let’s set max_water to 10.

Image represents a diagram illustrating a calculation of maximum trapped water.  The left side shows a bar chart with a horizontal axis labeled from 0 to 5 and a vertical axis representing height from 0 to 8.  Vertical bars of varying heights (2, 7, 8, 3, 6) are plotted at each integer position on the horizontal axis. A light-blue area represents the trapped water, extending horizontally between the bars and reaching a height of 2 units.  Arrows point upwards from the labels 'left' and 'right' below the horizontal axis, indicating the direction of calculation. On the right, a light-grey box displays a calculation: 'water = min(2, 6) . (5 - 0) = 10' and 'max_water = 10', showing the minimum height between the leftmost (height 2) and rightmost (height 6) bars multiplied by the width (5-0) to determine the total trapped water, which is 10 units.

How should we proceed? Moving either pointer inward yields a container with a shorter width. This leaves height as the determining factor. In this case, the left line is shorter than the right line, which means that the left line limits the water's height. Therefore, to find a larger container, let's move the left pointer inward:

Image represents a visual explanation of a coding pattern, likely related to algorithms operating on arrays or lists.  The left side shows a bar chart with a horizontal axis labeled from 0 to 5 and a vertical axis representing height from 0 to 8.  Several bars of varying heights are displayed; the heights are implicitly represented by the vertical extent of the bars. A light-blue rectangle fills the area below the height of the bars, representing a water level.  An orange box labeled 'left' points to the leftmost bar, and another orange box labeled 'right' points to the rightmost bar.  These labels 'left' and 'right' likely represent index pointers within an array. On the right side, a light-grey dashed-bordered box contains a conditional statement in code-like notation:  `heights[left] < heights[right] → left += 1`. This indicates that if the height at the index `left` is less than the height at the index `right`, then the `left` index is incremented by 1.  The arrow implies a flow of information or control; the condition's evaluation dictates the update of the `left` pointer. The overall diagram illustrates a step within an algorithm, possibly for finding the largest rectangular area in a histogram or a similar problem involving array traversal and comparison.

The current container can hold 24 units of water, the largest amount so far. So, let’s update max_water to 24. Here, the right line is shorter, limiting the water's height. To find a larger container, move the right pointer inward:

Image represents a bar chart illustrating a coding pattern, likely related to finding the maximum area of water trapped between vertical bars.  The horizontal axis shows indices (0 to 5), and the vertical axis represents heights.  Vertical bars of varying heights are drawn at each index; their heights are 2, 6, 6, 3, 6, and 0 respectively. A light-blue area is filled between the bars at indices 1, 2, 3, and 4, representing the trapped water.  Arrows labeled 'left' and 'right' point towards the x-axis, indicating the direction of traversal in an algorithm. A separate box contains code snippets: `water = min(7, 6) * (5 - 1) = 24` calculates the water trapped between specific bars (likely using the minimum height as the limiting factor and the distance between them), `max_water = 24` stores the maximum water found so far, and `heights[left] > heights[right] → right -= 1` shows a conditional statement, suggesting an algorithm iterates from left and right, moving the right pointer inwards if the left bar is taller.  The overall diagram visually explains a two-pointer approach to solving a 'trapping rain water' problem.

After this, we encounter a situation where the height of the left and right lines are equal. In this situation, which pointer should we move inward? Well, regardless of which one, the next container is guaranteed to store less water than the current one. Let’s try to understand why.

Moving either pointer inward yields a container of shorter width, leaving height as the determining factor. However, regardless of which pointer we move inward, the other pointer remains at the same line. So, even if a pointer is moved to a taller line, the other pointer will restrict the height of the water, as we take the minimum of the two lines.

Therefore, since we can’t increase height by moving just one pointer, we can just move both pointers inward:

Image represents a bar chart illustrating a 'two-pointer' approach to finding the maximum area that can be contained between vertical bars. The horizontal axis shows indices (0 to 5), and the vertical axis represents height.  Several vertical bars of varying heights are depicted; one bar at index 0 has a height of 2, a group of bars from index 1 to 3 have a height of 7, and a bar at index 3 has a height of 6. The area between the bars at indices 1 and 3 is shaded light blue, representing the calculated water trapped between them.  Two orange rectangular boxes labeled 'left' and 'right' point to the indices 1 and 4 respectively, indicating the pointers used in the algorithm. A separate box displays calculation details: `water = min(7, 7) * (4 - 1) = 21`, showing the calculation of the water trapped between the pointers; `max_water = 24` indicates the maximum water trapped found so far; and `heights[left] == heights[right] => left += 1 and right -= 1` describes the algorithm's logic: if the heights at the left and right pointers are equal, both pointers move inwards.

Now, the right line is limiting the height of the water. So, we move the right pointer inward:

Image represents a diagram illustrating a coding pattern, likely related to finding the maximum trapped rainwater between vertical bars.  The left side shows a bar chart with vertical bars of varying heights (1, 7, 3, 7, 6) at positions 0, 1, 2, 3, 4, 5 respectively on the x-axis and heights on the y-axis. A light-blue rectangle is filled between bars of height 3 and 7 at positions 2 and 3, representing trapped water.  Below the chart, orange rectangular boxes labeled 'left' and 'right' indicate pointers or indices used to iterate through the bars.  An arrow points upwards from 'left' to the bar at position 2 and another from 'right' to the bar at position 3. The right side contains a light-grey box with code-like annotations.  These annotations show a calculation: `water = min(8, 3) * (3 - 2) = 3`, indicating the calculation of water trapped between two bars; `max_water = 24`, representing the maximum trapped water; and `heights[left] > heights[right] => right -= 1`, suggesting a conditional statement controlling the movement of the 'right' pointer based on the heights of the bars at the 'left' and 'right' indices.  The overall diagram visually explains an algorithm for calculating maximum trapped rainwater using two pointers.

Finally, the left and right pointers meet. We can conclude our search here and return max_water:

Image represents a diagram illustrating a coding pattern, likely within a search or sorting algorithm.  The left side shows a bar chart with the x-axis ranging from 0 to 5 and the y-axis representing a value, with bars of varying heights at different x-axis positions (0, 1, 2, 3, 4, and 5).  Below the chart, two rectangular boxes labeled 'left' and 'right' are shown, with arrows pointing upwards towards the x-axis, suggesting these labels represent index pointers or boundaries within a data structure. On the right side, a dashed-line rectangle contains the condition 'left == right' with a solid arrow pointing to the word 'break,' indicating that when the condition 'left' equals 'right' is met, the algorithm terminates or exits the loop using a 'break' statement.  The overall diagram visually depicts the process of a binary search or similar algorithm where two pointers converge until a condition is met, resulting in the termination of the process.

Based on the decisions taken in the example, we can summarize the logic:

  1. If the left line is smaller, move the left pointer inward.
  2. If the right line is smaller, move the right pointer inward.
  3. If both lines have the same height, move both pointers inward.

Implementation

python
from typing import List
  
def largest_container(heights: List[int]) -> int:
    max_water = 0
    left, right = 0, len(heights) - 1
    while (left < right):
        # Calculate the water contained between the current pair of lines.
        water = min(heights[left], heights[right]) * (right - left)
        max_water = max(max_water, water)
        # Move the pointers inward, always moving the pointer at the shorter line. If
        # both lines have the same height, move both pointers inward.
        if (heights[left] < heights[right]):
            left += 1
        elif (heights[left] > heights[right]):
            right -= 1
        else:
            left += 1
            right -= 1
    return max_water

Complexity Analysis

Time complexity: The time complexity of largest_container is O(n)O(n)O(n) because we perform approximately nnn iterations using the two-pointer technique.

Space complexity: We only allocated a constant number of variables, so the space complexity is O(1)O(1)O(1).

Test Cases

In addition to the examples discussed throughout this explanation, below are some other examples to consider when testing your code.

InputExpected outputDescription
heights = []0Tests an empty array.
heights = [1]0Tests an array with just one element.
heights = [0, 1, 0]0Tests an array with no containers that can contain water.
heights = [3, 3, 3, 3]9Tests an array where all heights are the same.
heights = [1, 2, 3]2Tests an array with strictly increasing heights.
heights = [3, 2, 1]2Tests an array with strictly decreasing heights.

Shift Zeros to the End

Shift Zeros to the End

Given an array of integers, modify the array in place to move all zeros to the end while maintaining the relative order of non-zero elements.

Example:

python
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:

python
def shift_zeros_to_the_end_naive(nums: List[int]) -> None:
    temp = [0] * len(nums)
    i = 0
    # Add all non-zero elements to the left of 'temp'.
    for num in nums:
        if num != 0:
            temp[i] = num
            i += 1
    # Set 'nums' to 'temp'.
    for j in range(len(nums)):
        nums[j] = temp[j]

Unfortunately, this solution breaks 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:

The image represents a data transformation process.  It shows an input array `[0 1 0 2 3]` enclosed in square brackets.  A light-blue rectangular box highlights the non-zero elements (1, 2, and 3) within this array. A solid black arrow points from the input array to an output array `[1 2 3 0 0]`.  Below the input array, the text 'non-zero numbers go here' in light-blue indicates that the transformation involves extracting only the non-zero numbers.  Light-blue horizontal lines are drawn under both the input and output arrays, visually connecting the non-zero elements in the input to their corresponding positions in the output, implying a direct mapping of non-zero values.  The zeros in the input array are preserved in the output array, maintaining the original array's length.

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:

Image represents a visualization of a coding pattern, likely illustrating pointer manipulation within an array.  The top section shows two gray boxes labeled 'left' and 'right' pointing downwards to an array `[0 1 0 2 3]`.  This represents the initial state of the array and the pointers. Below, a second similar arrangement shows the array after a step in the algorithm.  The 'left' pointer remains unchanged, but the 'right' pointer (shown in orange) has moved one position to the right.  A dashed gray box in the middle displays a conditional statement: `nums[right] == 0 → right += 1`, indicating that if the element at the index pointed to by 'right' is equal to 0, then the value of 'right' is incremented.  The dashed line connecting the 'right' pointer in the second array to the conditional statement visually connects the conditional check to the pointer's movement. The overall diagram illustrates a single iteration of an algorithm that likely involves traversing and manipulating an array using two pointers.

Remember, we only use the left pointer to keep track of where non-zero elements should be placed. So, until we find a non-zero element, we shouldn’t move this pointer.

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.

Image represents a visual depiction of a sorting algorithm's step.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal array `[0 1 0 2 3]`. Arrows descend from 'left' and 'right' pointing to the array elements at indices 0 and 1 respectively. A dashed-line rectangle to the right contains the conditional statement 'nums[right] != 0 → swap(nums[left], nums[right])', indicating that if the element at the index pointed to by 'right' (which is 1 in this case) is not equal to 0, then a swap operation occurs between the elements at the indices pointed to by 'left' and 'right'.  The 'nums' refers to the array, and 'left' and 'right' act as index pointers. The arrow '→' signifies the conditional execution of the swap function.
Image represents a visual depiction of a swap operation within a sorting algorithm, likely a variation of quicksort or similar.  At the top, two rectangular boxes labeled 'left' and 'right' are shown, each with a downward-pointing arrow. These arrows point to a numerical array represented as `[1 0 2 3]`, where the numbers 1 and 0 are highlighted in light peach circles. A gray horizontal line connects the numbers 1 and 0, indicating their positions within the array.  A curved orange arrow, labeled 'swapped,' arcs underneath the line, connecting the circled 1 and 0, illustrating the exchange of these two elements.  The overall diagram demonstrates a single swap step in a sorting process, where the values pointed to by 'left' and 'right' pointers are interchanged.

2. Increment the pointers:

  • After the swap is complete, we should increment the left pointer to position it where the next non-zero element should go.
  • Let’s also increment the right pointer in search of the next non-zero element.
The image represents a simple illustration of a coding pattern, possibly related to array manipulation or pointer movement.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal line segment.  Downward arrows connect each box to a number below the line.  The numbers below the line are arranged as an array-like structure: `[1 0 0 2 3]`.  A dashed-line rectangle to the right contains the text 'left += 1, right += 1', indicating an operation where the values associated with 'left' and 'right' are incremented by 1.  The arrows suggest that the 'left' and 'right' variables might represent indices or pointers into the array, and the operation in the dashed box describes how these indices are updated.  The overall diagram visually depicts a step in an algorithm that involves iterating or manipulating an array using two pointers.
Image represents a diagram illustrating a coding pattern, possibly related to array traversal or pointer manipulation.  Two rectangular boxes labeled 'left' (in orange) and 'right' (in gray) are positioned at the top.  A solid downward-pointing arrow extends from each box, indicating data flow.  These arrows point to the numbers 0 and 0 respectively, which are situated below on a horizontal gray line representing an array or sequence.  The number 1 is placed to the left of the 0 from the 'left' box, and the number 2 and 3 are placed to the right of the 0 from the 'right' box, all on the same horizontal line, enclosed within square brackets `[ ]` to denote an array structure. A dashed orange arrow connects the 'left' box's output (0) to the number 1, suggesting a possible indirect or conditional relationship or data flow.  The overall arrangement suggests a process where the 'left' and 'right' elements might represent pointers or indices that traverse the array, potentially converging or interacting at a central point (the 0s).

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:

Image represents a diagram illustrating a coding pattern, possibly within a sorting or searching algorithm.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal array represented by `[1 0 0 2 3]`.  Arrows descend from 'left' and 'right' pointing to the array elements at indices 1 and 2 respectively (the values 0 and 0).  A separate, light-grey, dashed-bordered rectangle to the right contains a conditional statement: `nums[right] == 0 -> right += 1`. This indicates that if the element at the index `right` in the `nums` array is equal to 0, then the value of `right` is incremented by 1.  The diagram visually shows the variables `left` and `right` potentially used as pointers or indices within the array, and the conditional statement describes a step in an algorithm that modifies the `right` pointer based on the value of the array element it points to.
Image represents a simplified illustration of a data structure, possibly an array, being manipulated.  Two rectangular boxes, labeled 'left' (in gray) and 'right' (in orange), represent pointers or indices. A gray horizontal line depicts an array containing the elements [1, 0, 0, 2, 3]. A solid downward-pointing arrow from 'left' points to the element '0' at index 1, indicating that the 'left' pointer is currently at this position. An orange downward-pointing arrow from 'right' points to the element '2' at index 3, showing the 'right' pointer's position. A dashed orange curved arrow connects the 'right' pointer to the element '0' at index 2, suggesting a potential movement or operation involving this element.  The overall arrangement visualizes the pointers' positions within the array and hints at a possible algorithm or process involving these pointers, perhaps related to partitioning or searching within the data structure.
Image represents a visual depiction of a step in a sorting algorithm, likely a comparison-based sort.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal array `[1, 0, 0, 2, 3]`. Arrows descend from 'left' and 'right' pointing to the array elements at indices 1 and 4 respectively (0 and 2). A dashed-line rectangle to the right displays the condition `nums[right] != 0` followed by a right arrow and the action `swap(nums[left], nums[right])`. This indicates that if the element at the index pointed to by 'right' (which is 2) is not equal to 0, then the elements at the indices pointed to by 'left' and 'right' (0 and 2) will be swapped.  The diagram illustrates a single comparison and potential swap operation within a larger sorting process.
Image represents a visual depiction of a swapping operation within an array.  The diagram shows an array `[1, 2, 0, 3]` with two pointers, labeled 'left' and 'right,' initially pointing to the elements 2 and 0 respectively.  Arrows from rectangular boxes labeled 'left' and 'right' indicate the pointers' positions. A gray horizontal line connects the elements 1, 2, 0, and 3, representing the array.  A curved orange arrow labeled 'swapped' shows the exchange of the values at the 'left' and 'right' pointer positions.  To the right, a light gray, dashed-bordered rectangle contains the operation `left += 1, right += 1`, indicating that after the swap, both pointers will increment their positions by one in the next iteration.  The overall image illustrates a single step in a sorting algorithm, likely involving a swap and pointer movement.
Image represents a diagram illustrating a simple coding pattern, possibly related to array manipulation or pointer movement.  The diagram shows a numerical array `[1 2 0 0 3]` represented horizontally. Above the array, two rectangular boxes labeled 'left' and 'right' in orange are positioned.  A solid orange arrow descends from 'left' pointing to the element '0' in the second position from the left within the array.  Similarly, a solid orange arrow descends from 'right' pointing to the element '3' at the right end of the array. Dashed orange lines curve from 'left' to the element '2' and from 'right' to the element '0' in the third position, suggesting a potential search or traversal pattern. The overall arrangement suggests a process where two pointers, one starting from the left and the other from the right, might be moving towards the center of the array, potentially for comparison, searching, or merging operations.
Image represents a visual depiction of a step in a sorting algorithm, likely a part of a larger process.  Two rectangular boxes labeled 'left' and 'right' are positioned above a numerical array `[1, 2, 0, 0, 3]`.  Arrows descend from 'left' and 'right' pointing to the '0' elements within the array, indicating these are the current indices being considered. A dashed-line rectangle to the right shows a conditional statement: `nums[right] != 0 → swap(nums[left], nums[right])`. This indicates that if the element at the index 'right' is not equal to 0, then a `swap` function is called to exchange the values at the 'left' and 'right' indices within the `nums` array.  The arrow represents the flow of control based on the condition.
Image represents a visual depiction of a swapping operation within an array.  The diagram shows an array `[1, 2, 3, 0]` with two pointers, 'left' and 'right', initially pointing to the elements 3 and 0 respectively.  Rectangular boxes labeled 'left' and 'right' above indicate the pointers' origins.  A downward-pointing arrow from each box connects to its corresponding element in the array.  The elements 3 and 0 are highlighted in light peach. A gray horizontal line connects the elements 1 and 2, visually representing the array's structure. A curved orange arrow labeled 'swapped' connects the highlighted elements 3 and 0, indicating that these elements have been exchanged. To the right, a light gray, dashed-bordered rectangle displays the operation performed: `→ left += 1, right += 1`, signifying that after the swap, both pointers will increment their positions by one.
Image represents a diagram illustrating a coding pattern, possibly related to array manipulation or pointer movement.  The diagram shows a light-blue rectangular array `[1 2 3]` with a light-blue underline indicating its boundaries.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above the array.  Dashed orange arrows point downwards from 'left' and 'right' to the array elements. The 'left' arrow points to the first element (1) and the 'right' arrow points to the last element (3).  The arrows suggest that the 'left' and 'right' variables might represent pointers or indices that can be manipulated to traverse the array.  The empty space `∅` between the array and the pointers suggests an initial state or a potential intermediate step in an algorithm.  The overall structure suggests a two-pointer approach commonly used in algorithms like merging or partitioning.

Once all swapping is done, all zeros will end up at the right end of the array as intended, without disturbing the order of the non-zero elements. The two-pointer strategy used in this problem is unidirectional traversal.

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.

python
def shift_zeros_to_the_end(nums: List[int]) -> None:
    # The 'left' pointer is used to position non-zero elements.
    left = 0
    # Iterate through the array using a 'right' pointer to locate non-zero
    # elements.
    for right in range(len(nums)):
        if nums[right] != 0:
            if right != left:
                nums[left], nums[right] = nums[right], nums[left]
            # Increment 'left' since it now points to a position already occupied
            # by a non-zero element.
            left += 1

Complexity Analysis

Time complexity: The time complexity of shift_zeros_to_the_end is O(n)O(n)O(n), where nnn denotes the length of the array. This is because we iterate through the input array once.

Space complexity: The space complexity is O(1)O(1)O(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.

InputExpected outputDescription
nums = [][]Tests an empty array.
nums = [0][0]Tests an array with one 0.
nums = [1][1]Tests an array with one 1.
nums = [0, 0, 0][0, 0, 0]Tests an array with all 0s.
nums = [1, 3, 2][1, 3, 2]Tests an array with all non-zeros.
nums = [1, 1, 1, 0, 0][1, 1, 1, 0, 0]Tests an array with all zeros already at the end.
nums = [0, 0, 1, 1, 1][1, 1, 1, 0, 0]Tests an array with all zeros at the start.

Next Lexicographical Sequence

Next Lexicographical Sequence

Given a string of lowercase English letters, rearrange the characters to form a new string representing the next immediate sequence in lexicographical (alphabetical) order. If the given string is already last in lexicographical order among all possible arrangements, return the arrangement that's first in lexicographical order.

Example 1:

python
Input: s = 'abcd'
Output: 'abdc'

Explanation: "abdc" is the next sequence in lexicographical order after rearranging "abcd".

Example 2:

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

An important detail is that a string’s next lexicographical sequence is lexicographically larger than the original string. Consider the string “abc” and all its permutations in a lexicographically ordered sequence:

Image represents a table of three columns and six rows containing permutations of the letters 'a', 'b', and 'c'.  Each row presents a unique arrangement of these three letters. A vertical arrow separates the table from the text 'lexicographical order'. This arrow indicates that the rows in the table are ordered lexicographically; that is, they are arranged in ascending order as they would appear in a dictionary.  The ordering starts with 'a' as the smallest element, followed by 'b', and then 'c'.  The first row is 'abc', the second is 'acb', and so on, systematically exhausting all possible permutations of the three letters in lexicographical order.

We can see the increasing order of the strings in the sequence when we translate each letter to its position in the alphabet:

Image represents a mapping of permutations of the letters 'a', 'b', and 'c' to their corresponding numerical representations.  Six rows display all possible orderings of these three letters.  Each row shows a permutation on the left side, followed by a grey arrow indicating a transformation.  On the right side, each permutation is mapped to a unique set of numbers (1, 2, and 3), representing the rank of each letter within its permutation.  For example, in the first row, 'a', 'b', and 'c' map to 1, 2, and 3 respectively, reflecting their increasing order.  The vertical orange arrow and the word 'increasing' highlight that the numerical mapping consistently assigns 1 to the smallest letter, 2 to the next smallest, and 3 to the largest within each row's permutation.  The arrangement visually demonstrates a one-to-one correspondence between letter permutations and their ranked numerical equivalents.

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:

Image represents a table illustrating a mapping between character sequences and numerical sequences.  Six rows display different permutations of the characters 'a', 'b', and 'c' in the leftmost column.  Each character sequence is connected via a grey arrow to a corresponding numerical sequence (1, 2, 3) in the rightmost column.  The numerical sequences represent rankings or assignments based on the input character sequence.  For example, the sequence 'abc' maps to '1 2 3', while 'acb' maps to '1 3 2'.  An orange arrow points from the numerical sequence '1 3 2' to the text 'next largest after 'abc'', indicating that this numerical sequence represents the next largest ranking after the sequence 'abc' based on some implied ordering or ranking system.  The image demonstrates a pattern or algorithm that transforms character permutations into ranked numerical sequences.

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.

Identifying which characters to rearrange Since our goal is to make the smallest possible increase, we need to somehow rearrange the characters on the right side of the string.

To understand why, imagine trying to “increase” a string’s value. Increasing the rightmost letter causes the resulting string to be closer to the original string lexicographically than increasing the leftmost letter does:

Image represents two rows of data, each showing a sequence of letters (a, b, c, d, e) with corresponding numerical values (1, 2, 3, 4, 5) beneath them.  The top row displays the sequence 'a b c e d d,' with values '1 2 3 5 4 4' respectively. The bottom row shows a slightly altered sequence: 'b a b c e d d a,' with values '2 1 2 3 5 4 4 1' respectively.  A diagonal strikethrough line connects 'a' (value 1) in the top row to 'b' (value 2) in the top row, and similarly connects 'a' (value 1) in the bottom row to 'b' (value 2) in the bottom row.  The top row is labeled '(small increase)' and the bottom row is labeled '(big increase),' indicating that the changes between the two rows represent different magnitudes of increase in the value associated with 'a'.  The numerical values under each letter represent the magnitude of each element in the sequence. The visual emphasis is on the change in the position and value of 'a' between the two rows, highlighting the concept of a small versus a big increase in a sequence.

Therefore, we should focus on rearranging characters on the right-hand side of the string first, if possible.

A key insight is that the last string in a lexicographical sequence (i.e., the largest permutation) will always follow a non-increasing order. We can see this with the string “abcc”, for example, with its largest possible permutation being “ccba”:

Image represents a diagram illustrating a coding pattern transformation.  The diagram shows two sequences of characters, 'a', 'b', 'c', 'c' and 'c', 'c', 'b', 'a', arranged vertically with their corresponding numerical indices (1, 2, 3, 3 and 3, 3, 2, 1 respectively) displayed below. A black arrow connects the first sequence to the second, labeled 'last permutation'.  Above this arrow and connected to the second sequence by an orange arrow is the label 'non-increasing', indicating the transformation's goal. The transformation shows the initial sequence being reordered into a non-increasing sequence, where the elements are arranged in descending order, or if equal, maintain their original order.  The numerical indices also reflect this reordering, demonstrating the change in position of the elements after the transformation.

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:

Image represents three rows of data illustrating a non-increasing subsequence pattern. Each row displays a sequence of characters (a, b, c, e, d, d, a) with corresponding numerical values (1, 2, 3, 5, 4, 4, 1) beneath them.  The arrangement shows the initial sequence followed by progressively identified non-increasing subsequences.  The non-increasing subsequences (d, d, a) are highlighted with a peach-colored background and the label 'non-increasing' is written in orange text above each subsequence. The numerical values remain consistent across all rows, demonstrating how the non-increasing subsequence is identified within the larger sequence.  The visual structure emphasizes the iterative identification of the non-increasing subsequence from the original sequence.

However, the next character, ‘c’, breaks the non-increasing sequence:

Image represents a sequence of characters 'a', 'b', 'c', 'e', 'd', 'd', 'a' displayed horizontally, each character having a corresponding numerical value (1, 2, 3, 5, 4, 4, 1) shown below.  The characters 'c', 'e' are highlighted in a peach-colored background, indicating they form a non-increasing subsequence (3, 5). A curved arrow connects the numerical values 3 and 5, pointing downwards to '3 < 5', explicitly showing the non-increasing relationship.  A horizontal arrow connects the entire character sequence to the text 'can rearrange to incur an increase' in orange, indicating that because the sequence is not monotonically non-increasing, it can be rearranged to create an increasing sequence. The text 'not non-increasing!' in orange further emphasizes the initial sequence's non-monotonic nature.

Let’s call this character the pivot:

Image represents a visual depiction of a pivot operation, likely within a sorting algorithm.  The top shows the word 'pivot' in light blue, with a downward-pointing arrow indicating the pivot element. Below, a horizontal sequence of elements is displayed: 'a', 'b', 'c', 'e', 'd', 'd', 'a'. Each element is associated with a numerical value (1, 2, 3, 5, 4, 4, 1) written beneath it in orange.  The element 'c' (with value 3) is highlighted in a peach-colored background, indicating it's the chosen pivot. The arrangement shows the initial state before the pivot operation, with the pivot element visually separated. The numbers likely represent the values being sorted, and the letters are placeholders or keys associated with those values. The image illustrates a single step in a sorting algorithm where the pivot is selected and the data is prepared for partitioning or comparison based on the pivot's value.

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:

Image represents a simple illustration of a data reversal operation.  On the left, a sequence of four lowercase letters, 'd', 'c', 'b', and 'a', are arranged from left to right. A thick, black arrow connects this sequence to another sequence on the right. The arrow is labeled 'reverse' indicating the transformation applied. The sequence on the right shows the same letters, but in reversed order: 'a', 'b', 'c', and 'd', arranged from left to right. The diagram visually demonstrates how the 'reverse' operation transforms the input sequence into its reversed counterpart.  No URLs or parameters are present.

Rearranging characters Having identified the shortest suffix to rearrange, the next objective is to rearrange this suffix to make the smallest increase possible. We have to start the rearrangement at the pivot since the rest of the suffix is already arranged in its largest permutation.

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’:

Image represents a diagram illustrating a concept likely related to data structures or algorithms, specifically focusing on finding a pivot and its rightmost successor.  The diagram shows two sequences of characters ('a', 'b', 'c', 'e', 'd', 'a') with associated numerical indices (1, 2, 3, 5, 4, 1, 4, 1) beneath them.  The character 'c' is highlighted in cyan and labeled 'pivot,' indicating it's a selected element. A downward-pointing arrow connects 'pivot' to 'c', showing its selection.  Separately, the text 'rightmost_successor' is shown above a downward-pointing arrow pointing to the second instance of 'd' in the sequence. This suggests that 'd' is identified as the rightmost successor of the pivot 'c' within the context of the algorithm or data structure being depicted. The arrangement implies a process where a pivot is chosen, and then a specific successor element is identified based on some criteria (in this case, the rightmost occurrence).

Now, we swap the pivot and the rightmost successor:

Image represents a visual depiction of a data transformation or rearrangement process.  The left side shows a sequence of characters ('a', 'b', 'c', 'e', 'd', 'a') each with a numerical subscript (1, 2, 3, 5, 4, 1 respectively).  The characters 'c' and 'a' are highlighted in cyan and black respectively.  These characters are connected by a curved arrow forming a loop, indicating a cyclical or iterative relationship. The arrow points from 'a' to 'c' and then back to 'a'. A straight arrow points from this looped sequence to a second sequence on the right. The right-side sequence is a rearrangement of the left-side sequence ('a', 'b', 'd', 'e', 'd', 'c', 'a'), maintaining the same numerical subscripts (1, 2, 4, 5, 4, 3, 1) but with a changed order.  The character 'c' remains highlighted in cyan, and 'a' in black, indicating that these elements retain their identity despite the shift in position within the sequence. The overall diagram illustrates a transformation where the order of elements changes, but the elements themselves and their associated numerical values remain consistent.

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. After the swap shown above, we see that substring “edca” is not at its smallest permutation. So, we need to minimize the permutation of this substring.

An important observation is that after the previous swap, the substring after the pivot is still lexicographically non-increasing.

Image represents a sequence of elements ('a', 'b', 'd', 'e', 'd', 'c', 'a') each associated with a numerical value (1, 2, 4, 5, 4, 4, 1 respectively) displayed below.  A downward-pointing arrow labeled 'pivot' in orange text points to the element 'd' (with value 4), indicating this element as a pivot point.  A horizontal arrow in orange text connects the elements 'e', 'd', 'c', 'a' (with values 5, 4, 4, 1 respectively), which are highlighted in a peach-colored background.  The text 'still non-increasing' describes the relationship between these highlighted elements, indicating their values are not increasing.  Another orange text phrase, 'make this as small as possible,' suggests an optimization goal: to minimize the range or size of this highlighted subsequence.  The overall diagram illustrates a concept related to finding a pivot point within a sequence and optimizing a subsequence based on a non-increasing condition.

This means we can minimize this substring’s permutation by reversing it:

Image represents a sequence transformation process.  The input is a sequence 'a b d e d c a' with corresponding numerical values '1 2 3 5 4 3 1' displayed below. This input sequence is highlighted in a peach-colored rectangle. An arrow labeled 'reverse' indicates the transformation applied to the input. The output sequence is 'a c d e' with values '1 3 4 5' shown below, highlighted in a light green rectangle. The transformation 'reverse' implies that the input sequence within the peach rectangle is reversed, resulting in the output sequence within the green rectangle.  The numerical values associated with each element remain consistent before and after the reversal, indicating that the transformation only affects the order of the elements, not their associated numerical values.

And just like that, we found the next lexicographical sequence! The two-pointer strategy used in this problem is staged traversal, where we first identify the pivot, and then identify the rightmost successor relative to it.

Many steps are involved in identifying the next lexicographical sequence. So, here’s a summary:

  1. Locate the pivot.
  • The pivot is the first character that breaks the non-increasing sequence from the right of the string.
  • If no pivot is found, the string is already at its last lexicographical sequence, and the result is just the reverse of the string.
  1. Find the rightmost successor to the pivot.
  2. Swap the rightmost successor with the pivot to increase the lexicographical order of the suffix.
  3. Reverse the suffix after the pivot to minimize its permutation.

Implementation

python
def next_lexicographical_sequence(s: str) -> str:
    letters = list(s)
    # Locate the pivot, which is the first character from the right that breaks
    # non-increasing order. Start searching from the second-to-last position.
    pivot = len(letters) - 2
    while pivot >= 0 and letters[pivot] >= letters[pivot + 1]:
        pivot -= 1
    # If pivot is not found, the string is already in its largest permutation. In
    # this case, reverse the string to obtain the smallest permutation.
    if pivot == -1:
        return ''.join(reversed(letters))
    # Find the rightmost successor to the pivot.
    rightmost_successor = len(letters) - 1
    while letters[rightmost_successor] <= letters[pivot]:
        rightmost_successor -= 1
    # Swap the rightmost successor with the pivot to increase the lexicographical
    # order of the suffix.
    letters[pivot], letters[rightmost_successor] = (letters[rightmost_successor], letters[pivot])
    # Reverse the suffix after the pivot to minimize its permutation.
    letters[pivot + 1:] = reversed(letters[pivot + 1:])
    return ''.join(letters)

Complexity Analysis

Time complexity: The time complexity of next_lexicographical_sequence is O(n)O(n)O(n), where nnn 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, which takes O(n)O(n)O(n) time.

Space complexity: The space complexity is O(n)O(n)O(n) due to the space taken up by the letters list. In Python, this additional space is used because strings are immutable, which necessitates storing the input string as a list.

Test Cases

In addition to the examples discussed, below are more examples to consider when testing your code.

InputExpected outputDescription
s = 'a''a'Tests a string with a single character.
s = 'aaaa''aaaa'Tests a string with a repeated character.
s = 'ynitsed''ynsdeit'Tests a string with a random pivot character.

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.

Chapter 2

Hash Maps and Sets

~32 min read

Introduction to Hash Maps and Sets

Introduction to Hash Maps and Sets

Intuition

Picture working in a grocery store. A customer asks for the price of a fruit. If you just have a paper list of all the fruits, you would need to look through it to find the specific fruit, which can be time-consuming. However, by memorizing the list, you can know the prices of all fruits instantly, enabling you to promptly give the correct price to the customer. This is similar to how hash maps work, enabling quick access to information.

Hash maps A hash map – also known as a hash table or dictionary, depending on the language – is a data structure that pairs keys with values. Let's illustrate this using the example of fruits and their prices:

Image represents a simple data mapping or lookup table illustrating a price list for fruits.  The table is implicitly structured with two columns: 'Fruit' on the left and 'Price' on the right.  Three rows represent individual fruit items.  The 'Fruit' column lists the items: 'apple', 'bananas', and 'carrot', each written in a monospace font.  The 'Price' column displays the corresponding prices: 5.00, 4.50, and 2.75 respectively, also in a monospace font.  A grey horizontal arrow connects each fruit item in the left column to its associated price in the right column, visually representing the mapping between fruit and its price.  The overall arrangement suggests a straightforward key-value relationship, where the fruit name acts as the key and the price as the value.

Having a mental map of fruit prices is conceptually similar to being able to instantly access fruit prices using a hash map, where the fruit name is the key, and its price is the value. When we look up a price using the fruit's name as the key, the hash map will immediately return its price:

Image represents a simple hashmap data structure, visually depicted as a table.  The table is labeled 'hashmap' at the top.  It consists of two columns: a 'key' column and a 'value' column. The key column contains string values representing fruit and vegetable names: 'apple,' 'bananas,' and 'carrot.'  The value column contains corresponding floating-point numbers representing numerical values, likely prices: 5.00, 4.50, and 2.75 respectively.  Each key-value pair is implicitly linked; for example, 'apple' is associated with 5.00.  The arrangement shows a direct mapping between keys and values, illustrating the fundamental principle of a hashmap:  efficiently storing and retrieving data based on a unique key.  The labels 'key' and 'value' at the bottom clarify the role of each column.

Hash maps are incredibly efficient for lookups, insertions, and deletions, as they typically perform these operations in constant time: O(1)O(1)O(1). They are one of the most versatile, widely-used data structures in computer science, used for tasks such as counting the frequency of elements, caching data, and more.

Properties of hash maps:

  • Data is stored in the form of key-value pairs.
  • Hash maps don't store duplicates. Every key in a hash map is unique, ensuring each value can be distinctly identified and accessed.
  • Hash maps are unordered data structures, meaning keys are not stored in any specific order.

Time complexity breakdown Below, n denotes the number of entries in the hash map.

OperationAverage caseWorst caseDescription
InsertO(1)O(1)O(n)O(n)Add a key-value pair to the hash map.
AccessO(1)O(1)O(n)O(n)Find or retrieve an element.
DeleteO(1)O(1)O(n)O(n)Delete a key-value pair.

In coding interviews, we generally consider hash map operations to have a fast average time complexity of O(1)O(1)O(1), as opposed to their worst-case complexities. This is based on the assumption that an efficient hash function minimizes collisions [HashCollision]. However, in the worst-case scenario where a poorly optimized hash function results in frequent collisions, the time complexity can deteriorate to O(n)O(n)O(n), necessitating a linear search through all entries.

Hash sets Hash sets are a simpler form of hash maps. Instead of storing key-value pairs, they store only the keys. Using the grocery store analogy, a hash set is like having a mental checklist of fruits without their prices. It's useful for quickly checking the presence or absence of an item, like checking whether a particular fruit is in stock.

When to use hash maps or sets Common use cases of hash maps include implementing dictionaries, counting frequencies, storing key-value pairs, and handling scenarios requiring quick lookups.

Common use cases of hash sets include storing unique elements, marking elements as used or visited, and checking for duplicates.

In the description of a problem, pay attention to keywords like "frequency", "unique", "map", "dictionary", or "fast lookup", because these often indicate that hash maps or sets could be useful.

Essential concepts for mastering hash maps and sets This chapter discusses the practical uses of hash maps and sets. For a more complete understanding of how they work and why they're so efficient, please explore topics that go beyond the scope of this chapter, such as:

  • Hash functions: explore the intricacies of how keys are mapped to specific values in a hash table [1].
  • Collision and collision-handling techniques: understand methods like chaining, or open addressing for resolving hash collisions [2].
  • Load factors and rehashing: these make it easier to understand how hash tables grow and change size [3].

Real-world Example

Web browser cache: Hash maps and sets are used everywhere in real-world systems. A classic example of hash maps in action is in caching systems within web browsers. When you visit a website, your browser stores data such as images, HTML, and CSS files in a cache so it can load much faster on future visits.

Chapter Outline

Image represents a hierarchical diagram illustrating the application of Hash Maps and Sets in coding.  A rounded rectangle at the top, labeled 'Hash Maps and Sets,' serves as the root node.  From this root, a dashed line extends downwards, branching into two separate rectangular boxes with dashed borders. The left box is titled 'Hash maps' and lists three example applications: 'Pair Sum,' 'Unsorted,' and 'Geometric Sequence Triplets.' The right box is titled 'Hash sets' and lists three different applications: 'Verify Sudoku Board,' 'Zero Striping,' and 'Longest Chain of Consecutive Numbers.'  The arrangement shows that 'Hash Maps and Sets' is a broader category encompassing the specific use cases detailed under 'Hash maps' and 'Hash sets,' indicating how these data structures are employed in solving various coding problems.

This chapter includes problems involving the most popular use cases of hash maps and sets, aiming to improve your understanding and effectiveness in utilizing them.

Pair Sum - Unsorted

Pair Sum - Unsorted

Given an array of integers, return the indexes of any two numbers that add up to a target. The order of the indexes in the result doesn't matter. If no pair is found, return an empty array.

Example:

python
Input: nums = [-1, 3, 4, 2], target = 3
Output: [0, 2]

Explanation: nums[0] + nums[2] = -1 + 4 = 3

Constraints:

  • The same index cannot be used twice in the result.

Intuition

A brute force approach is to iterate through every possible pair in the array to see if their sum is equal to the target. This is the same as the brute force solution described in the Pair Sum - Sorted problem, which has a time complexity of O(n2)O(n^2)O(n2), where nnn is the length of the array. We could also sort the array and then perform the two-pointer algorithm used in Pair Sum - Sorted, which would take O(nlog(n))O(nlog(n))O(nlog(n)) time due to sorting. Let’s see if we can find an even faster solution.

Complement We're asked to find a pair (x, y) such that x + y == target. In this equation, there are two unknowns: x and y. An important observation is that if we know one of these numbers, we can easily calculate what the other number should be.

For each number x in nums, we need to find another number y such that x + y = target, or in other words, y = target - x. We can call this number the complement of x.

Keep in mind, we need to return the indexes of the pair of numbers, not the pair itself. So, we’ll need a way to find a number's complement as well as its index.

One way we could do this is to loop through the array to find each number’s complement and corresponding index. But this takes O(n2)O(n^2)O(n2) time since we’d need to do a linear traversal to search for each number’s complement. Instead, we’d like an efficient way to determine the index of any number in the array without needing to search the array. Is there a data structure that can help with this?

Hash map A hash map works great because we can store and look up values in O(1)O(1)O(1) time. Each number and its index can be stored in the hash map as key-value pairs:

Image represents a transformation of a numerical array into a hashmap.  The input is an array `[-1, 3, 4, 2]` with indices `[0, 1, 2, 3]` displayed beneath each element. A rightward arrow indicates the transformation process. The output is a hashmap labeled 'hashmap' which is represented as a table with two columns: 'num' and 'index'. The 'num' column lists the numbers from the input array: -1, 3, 4, and 2. The corresponding 'index' column shows the original index of each number in the input array: 0, 1, 2, and 3 respectively.  The diagram illustrates how the original array's elements and their indices are reorganized into a key-value pair structure within the hashmap, where the number acts as the key and its original index acts as the value.

This allows us to retrieve the index of any number’s complement efficiently. Notice that duplicate numbers don’t need to be considered here since only one valid pair needs to be found.

The most intuitive way to incorporate a hash map is to:

  1. In the first pass, populate the hash map with each number and its corresponding index.
  2. In the second pass, scan through the array to check if each number's complement exists in the hash map. If it does, we can return the indexes of that number and its complement.

Below is the code snippet for this two-pass approach:

python
from typing import List
        
def pair_sum_unsorted_two_pass(nums: List[int], target: int) -> List[int]:
    num_map = {}
    # First pass: Populate the hash map with each number and its index.
    for i, num in enumerate(nums):
        num_map[num] = i
    # Second pass: Check for each number's complement in the hash map.
    for i, num in enumerate(nums):
        complement = target - num
        if complement in num_map and num_map[complement] != i:
            return [i, num_map[complement]]
    return []

This algorithm requires two passes. Is it possible to do this in only one? A one-pass solution implies that we would need to populate the hash map while searching for complements. Is this possible? Consider the example below:

Image represents a sample input for a search algorithm, likely a target-sum problem.  The input consists of a numerical array [-1, 3, 4, 2] enclosed in square brackets, with each element implicitly indexed from 0 to 3 shown in grey below the array.  This array is separated by a comma from the target value, which is explicitly stated as 'target = 3'. The arrangement suggests the algorithm's task is to find a subset of numbers within the array that sums up to the target value of 3.  There is no explicit flow of information depicted; the image simply presents the input data for the algorithm to process.

Start at index 0. Its complement would be 3 - (-1) = 4. Does our hash map have 4 in it? No, it's empty at the moment. So, let's add -1 and its index to the hash map:

Image represents a data flow diagram illustrating a step in a coding pattern, likely involving hashmaps.  An input array `[-1, 3, 4, 2]` with indices [0, 1, 2, 3] is shown. An orange arrow labeled 'x' points to the first element, -1. This array flows into a rectangular box labeled 'hashmap' with internal labels 'num' and 'index' suggesting it stores key-value pairs.  To the right of the hashmap is a dashed-line box detailing the processing steps:  first, it checks if the hashmap contains -1 (it doesn't); second, it calculates the complement of 4 (presumably within the context of the algorithm); and third, it adds the key-value pair (-1, 0) to the hashmap.  The diagram visually depicts the input array, its processing through a hashmap, and the specific operations performed on the hashmap based on the input.

Next, let’s look at index 1. Its complement (0) does not exist in the hash map. So, just add 3 and its index to the hash map:

Image represents a step-by-step illustration of a coding pattern involving a hashmap.  An input array `[-1, 3, 4, 2]` is shown, with a small orange arrow pointing downwards to the element '3' at index 1, indicating the current element being processed. This array is then processed, resulting in a hashmap represented as a table with two columns labeled 'num' and 'index'.  Currently, the hashmap contains only one entry: -1 at index 0. To the right of the hashmap is a dashed-line box containing three bullet points describing the processing logic:  '- hashmap doesn't contain 3's', indicating that the current element (3) is not yet in the hashmap; 'complement (0)', suggesting a calculation of the complement (likely 0 in this context); and '- add (3, 1) to hashmap', indicating that the pair (3, 1) – the element and its index – will be added to the hashmap as the next step.  The arrow between the array and the hashmap shows the data flow from the input array to the hashmap during processing.

At index 2, we notice 4's complement (-1) exists in the hash map. This means we found a pair that sums to the target:

Image represents a data transformation process.  An input array `[-1, 3, 4, 2]` with indices [0, 1, 2, 3] is shown.  A downward-pointing orange arrow labeled 'x' highlights the element '4' at index 2. This array is then transformed into a hashmap labeled 'hashmap' which has two key-value pairs. The key 'num' stores the number -1, and its corresponding value under the 'index' key is 3.  The second key-value pair has 'num' as 0 and 'index' as 1. A separate dashed-line box describes the hashmap's content, stating that it 'contains 4's complement (-1)'.  The arrow indicates the input array is processed to generate the hashmap, implying a mapping or counting operation where the complement of a number (in this case, 4's complement -1) and its index are stored.

Now, we can return the indexes of the two values. Fetch the index of 4 from the input array and the index of its complement from the hash map:

Image represents a visual depiction of a coding pattern, likely for finding pairs in an array that sum to a target value (here, implicitly zero).  The input is an array `[-1, 3, 4, 2]` with indices `0, 1, 2, 3` respectively, shown above a downward-pointing orange arrow labeled 'x', suggesting a target value or operation.  This array is processed, with the element '4' (at index 2) highlighted in light green.  A rightward arrow indicates the transformation of this array into a hashmap. The hashmap is represented as a box divided into two columns labeled 'num' and 'index'.  The 'num' column contains the unique numbers from the input array (-1 and 3), while the 'index' column shows their corresponding indices (0 and 1 respectively).  A curved grey arrow connects the highlighted '4' in the input array to the hashmap, implying that the algorithm uses the hashmap to store and retrieve information.  Finally, another curved grey arrow originates from the hashmap's 'index' column and points to `[0, 2]` labeled 'return', indicating that the algorithm returns the indices 0 and 2 as the solution, likely because -1 + 4 = 3 (or a similar logic involving the target value).  The overall diagram illustrates the use of a hashmap to efficiently find pairs with a specific sum within an array.

Implementation

python
from typing import List
   
def pair_sum_unsorted(nums: List[int], target: int) -> List[int]:
    hashmap = {}
    for i, x in enumerate(nums):
        if target - x in hashmap:
            return [hashmap[target - x], i]
        hashmap[x] = i
    return []

Complexity Analysis

Time complexity: The time complexity of pair_sum_unsorted is O(n)O(n)O(n) because we iterate through each element in the nums array once and perform constant-time hash map operations during each iteration.

Space complexity: The space complexity is O(n)O(n)O(n) since the hash map can grow up to nnn in size.

Interview Tip

Tip: Iterate through solutions. Don’t always jump straight to the most optimal or clever solution, as this won't give the interviewer much insight into your problem-solving process. Consider multiple approaches, starting with the more straightforward ones, and gradually refine them. This way, you demonstrate your thought process and how you arrive at a more optimal solution.

Verify Sudoku Board

Verify Sudoku Board

Given a partially completed 9×9 Sudoku board, determine if the current state of the board adheres to the rules of the game:

  • Each row and column must contain unique numbers between 1 and 9, or be empty (represented as 0).
  • Each of the nine 3×3 subgrids that compose the grid must contain unique numbers between 1 and 9, or be empty.

Note: You are asked to determine whether the current state of the board is valid given these rules, not whether the board is solvable.

Example:

Image represents a partially filled Sudoku grid with a highlighted 3x3 subgrid in light pink.  The grid contains various numbers (1-9) arranged in rows and columns. Two curved arrows originate from within the pink subgrid, specifically from the numbers '2' and '8' located in the same row and column respectively. These arrows point to a light gray rectangular box containing the text 'two 2s in the same row' stacked above 'two 8s in the same column'.  A further arrow extends from this gray box to the right, pointing to the text 'return False', indicating that the presence of two '2's in the same row and two '8's in the same column violates Sudoku rules, resulting in a 'False' return value.  The overall diagram illustrates a pattern recognition process within a Sudoku solver, where the algorithm detects rule violations based on the presence of duplicate numbers within rows and columns.
python
Output: False

Constraints:

  • Assume each integer on the board falls in the range of [0, 9].

Intuition

Our primary objective is to check every row, column, and each of the nine 3x3 subgrids, for any duplicate numbers. Let's first discuss the mechanism for finding duplicate elements, then look into how we can apply this to rows, columns, and subgrids.

Checking for duplicates Let’s start by figuring out how to check for duplicates on a single row of the board:

Image represents a visual depiction of data transformation.  A 9x9 grid, resembling a partially completed Sudoku puzzle, is shown on the left.  The grid contains various single-digit numbers (1-9) arranged within its cells, with some cells remaining empty.  A specific row of the grid, highlighted in a light peach color, contains the numbers 1, 2, 5, 3, and 2. A rightward-pointing arrow connects this grid to a single-row, rectangular array on the right. This array contains the same numbers (1, 2, 5, 3, 2) as the highlighted row from the grid, arranged sequentially in individual cells, mirroring the order in the highlighted row. The image illustrates the extraction of a specific row of data from a two-dimensional structure (the Sudoku grid) into a one-dimensional structure (the array), effectively demonstrating a data transformation or extraction process.

A naive way to do this is to take each number in the row and search the row to see if that number appears again. Performing a linear search for each number would result in a time complexity of O(n2)O(n^2)O(n2) to check all numbers in one row, which is quite time consuming.

We can use a hash set to improve the time complexity. By using a hash set, we can keep track of which numbers were previously visited as we iterate through the row. When we encounter a new number, we can check if it's already in the set in O(1)O(1)O(1) time. If it is, then it's a duplicate:

Image represents a visual depiction of data being processed to create a hashset.  A downward-pointing arrow originates from a small square containing the letter 'i', suggesting input data. This arrow points to a horizontal array or list divided into cells, each containing a single integer: 1 (highlighted in peach), 2, 5, 3, and 2.  To the right, a dashed-line rectangle represents a hashset, initially empty and denoted as 'hashset = {}.' The implication is that the integers from the array are being processed, and will populate the initially empty hashset.  The visual suggests a step-by-step process where the numbers from the input array are added to the hashset, potentially illustrating the concept of adding elements to a set data structure.
Image represents a visual depiction of a HashSet operation.  A small square labeled 'i' with an 'i' inside points downwards, indicating an input or iterator. This arrow points to a horizontal array or list divided into cells, each containing a single integer: 1, 2 (highlighted in peach/light orange), 5, 3, and 2.  The highlighted '2' suggests the current element being processed. To the right, a dashed-line rectangle displays the current state of a HashSet, represented as 'hashset = {1}'. This implies that the algorithm has iterated through the array, and so far, only the number '1' has been added to the HashSet, because HashSets only store unique values. The diagram illustrates a step-by-step process of populating a HashSet from an array, showing the current element under consideration and the resulting HashSet at that stage.
Image represents a visual depiction of a HashSet data structure operation.  A small square labeled 'i' with an arrow pointing downwards indicates input data. This input is directed into a horizontal array or list containing the integer values 1, 2, 5, 3, and 2, sequentially. The number 5 is highlighted in a light peach color, suggesting it's the element currently being processed. To the right, a dashed-line rectangle displays the resulting HashSet, represented as 'hashset = {1, 2}', indicating that the HashSet only contains the unique elements 1 and 2 from the input array, ignoring duplicates and the order of elements.  The arrow implies that the input array is processed to generate the HashSet, which only stores unique values.
Image represents a visual depiction of adding an element to a hash set.  A rectangular array, representing a data structure, displays the numbers 1, 2, 5, 3, and 2 in individual cells. A downward-pointing arrow originates from a small square containing the letter 'i', suggesting the input of a new element (3 in this case). The number 3 is highlighted in a peach-colored cell within the array. To the right, a dashed-line rectangle shows the resulting hash set, represented as 'hashset = {1, 2, 5}', indicating that despite the input of 3 (which is already present), the hash set remains unchanged because hash sets only store unique elements.  The arrangement visually demonstrates the process of attempting to add an element to a hash set and the hash set's behavior of ignoring duplicate entries.
Image represents a visual depiction of duplicate detection using a hash set.  A horizontal array or list is shown, divided into cells containing the numbers 1, 2, 5, 3, and 2.  A small, light peach-colored cell highlights the second instance of the number 2. A downward-pointing arrow originates from a small square labeled 'i', indicating an input or iterator pointing to this highlighted duplicate 2. To the right, a dashed-line box displays a hash set, `hashset = {1, 2, 5, 3}`, representing the unique elements encountered so far. A right-pointing arrow extends from this hash set box to a text label, '→ 2 already in hash set: duplicate,' indicating that the current element (2) is already present in the hash set, hence identified as a duplicate.  The overall diagram illustrates how a hash set can efficiently track unique elements and detect duplicates within a sequence.

If we had a hash set for each of the 9 rows, we could keep track of duplicates in each row separately. We can do this for columns and subgrids as well, with one hash set for each column, and one hash set for each subgrid. The challenge here is determining which hash sets correspond to each cell's row, column, and subgrid, so we know which hash sets to reference:

Image represents a 9x9 grid, subdivided into nine 3x3 subgrids, with thicker black lines demarcating the larger subgrids.  A single cell within the grid is highlighted in peach. A small, dark circle is centered within this peach-colored cell. An arrow originates from this circle and points to the right, connecting to the text '<code>which row/column/subgrid is this cell in?</code>'.  The arrow visually represents the flow of information: determining the row, column, and 3x3 subgrid to which the highlighted cell belongs. The overall diagram illustrates a common problem in coding, specifically finding the location of an element within a multi-dimensional structure like a matrix or grid.

So, let’s discuss how we can identify a cell’s row, column, or subgrid.

Identifying rows and columns Identifying rows is straightforward because each row has an index. The same applies to columns. Therefore, we can create an array of 9 hash sets, one for each row, allowing us to access a row’s hash set directly by its index. Similarly, we can set up an array of hash sets for each column.

Image represents two Python variable assignments.  The first line assigns a list named `row_sets` to a list containing multiple empty `hashset()` objects.  The indices 0, 1, and 8 are shown under the corresponding `hashset()` calls, indicating the positions of these sets within the list.  The ellipsis (...) signifies that there are additional `hashset()` objects between index 1 and 8. The second line similarly assigns a list named `col_sets` to another list of empty `hashset()` objects, again with indices 0, 1, and 8 shown, and an ellipsis indicating additional `hashset()` objects between index 1 and 8.  Both `row_sets` and `col_sets` appear to represent collections of sets, likely used to store data organized in a row-column structure, possibly for a matrix or grid-like data representation.  The use of `hashset()` suggests that the data within each set is unordered and contains only unique elements.

Identifying subgrids Subgrids pose an interesting challenge because we can’t immediately identify which subgrid a cell belongs to, unlike the straightforward index-based identification for rows and columns.

That said, as with rows and columns, there are still only 9 subgrids. If we visualize the subgrids, we can see them displayed in a 3x3 grid:

Image represents a 9x9 grid, visually divided into nine 3x3 subgrids.  The grid is labeled with numbers 0-8 along both the horizontal (x-axis) and vertical (y-axis) edges, representing coordinates.  Three of the 3x3 subgrids are shaded a light peach color; these are located in the top-right, bottom-left, and bottom-right corners of the larger 9x9 grid. The remaining six 3x3 subgrids are left unshaded, appearing white.  No other information, such as text, URLs, or parameters, is present within the grid itself. The lines forming the grid are thin, and the thicker lines delineate the boundaries of the 3x3 subgrids. The overall structure suggests a visual representation of a data structure or a pattern, possibly related to matrix operations or game boards like Sudoku, where the shaded areas might represent a specific pattern or data distribution.

What we'd like is a way to index each of these subgrids as if indexing a 3x3 matrix. To do this, we require a method to convert the indexes ranging from 0 to 8 to the corresponding adjusted indexes from 0 to 2, as illustrated below:

Image represents a 3x3 grid illustrating a data partitioning or mapping scheme.  The grid's rows and columns are numbered 0-2 and 0-8 respectively.  Each cell within the grid is either white or light peach/pink. The peach-colored cells represent a specific subset of data.  Arrows pointing towards the top of the grid indicate data input, grouped into three sets labeled 0, 1, and 2.  Similarly, arrows pointing towards the left of the grid show data output, also grouped into three sets labeled 0, 1, and 2. The arrangement of the peach-colored cells suggests a pattern where data from input group 0 maps to output group 0, input group 1 maps to output group 1, and input group 2 maps to output group 2, but with a specific internal mapping within each group.  The pattern is not fully defined by the coloring alone, but it implies a structured relationship between input and output data sets.

Since we got these adjusted indexes from shrinking a 9x9 grid to a 3x3 grid – which is effectively dividing the number of rows and columns by 3 – we can get the new subgrid row and column indexes by dividing by 3 (using integer division), as well:

Image represents three separate but similar computational processes. Each process involves a set of input numbers (0, 1, 2 in the first; 3, 4, 5 in the second; 6, 7, 8 in the third) represented by numerals at the top.  These input numbers are connected via grey curved arrows to a central node labeled '÷3' signifying division by 3.  The result of this division is shown below the '÷3' node, with the results being 0, 1, and 2 respectively for each of the three processes.  The arrangement visually demonstrates a pattern where consecutive sets of three integers are processed identically, resulting in a sequential output (0, 1, 2).

With these modified indexes, we can organize nine hash sets within a 3x3 table, one for each subgrid. Each cell in this table represents the corresponding subgrid in the above 3x3 representation. So, we can access the hash set of a subgrid at any cell by using our adjusted indexes (i.e., subgrid_sets[r // 3][c // 3]) (the // operator performs integer division).

One-pass Sudoku verification We now have everything needed for a one-pass solution. We can start by initializing hash sets, 9 for each row, 9 for each column, and 9 for each subgrid, using a 3x3 array.

As we iterate through each cell in the grid, we check if a previously encountered number already exists in the current row, column, or subgrid, by querying the appropriate hash sets:

  • If the number is in any of these hash sets, return false.
  • Otherwise, add it to the corresponding row, column, and subgrid hash sets.

This process helps us keep track of numbers in each row, column, and subgrid. If we successfully iterate through the board without encountering any duplicates, it indicates the Sudoku board is valid. Therefore, we can return true.

Implementation

python
from typing import List
  
def verify_sudoku_board(board: List[List[int]]) -> bool:
    # Create hash sets for each row, column, and subgrid to keep track of numbers
    # previously seen on any given row, column, or subgrid.
    row_sets = [set() for _ in range(9)]
    column_sets = [set() for _ in range(9)]
    subgrid_sets = [[set() for _ in range(3)] for _ in range(3)]
    for r in range(9):
        for c in range(9):
            num = board[r][c]
            if num == 0:
                continue
            # Check if 'num' has been seen in the current row, column, or subgrid.
            if num in row_sets[r]:
                return False
            if num in column_sets[c]:
                return False
            if num in subgrid_sets[r // 3][c // 3]:
                return False
            # If we passed the above checks, mark this value as seen by adding it to
            # its corresponding hash sets.
            row_sets[r].add(num)
            column_sets[c].add(num)
            subgrid_sets[r // 3][c // 3].add(num)
    return True

Complexity Analysis

In this problem, the length of the board is fixed at 9, effectively reducing all approaches to a time and space complexity of O(1)O(1)O(1). However, to better understand the efficiency of our algorithm in a broader context, let's use nnn to denote the board's length, allowing us to evaluate the algorithm's performance against arbitrary board sizes.

Time complexity: The time complexity of verify_sudoku_board is O(n2)O(n^2)O(n2) because we iterate through each cell in the board once, and perform constant-time hash set operations.

Space complexity: The space complexity is O(n2)O(n^2)O(n2) due to the row_sets, column_sets, and subgrid_sets arrays. Each array contains nnn hash sets, and each hash set is capable of growing to a size of nnn.

Zero Striping

Zero Striping

For each zero in an m x n matrix, set its entire row and column to zero in place.

Image represents a transformation of a 4x5 matrix.  The initial matrix on the left displays numerical values from 1 to 19, arranged in a grid with rows labeled 0 to 3 and columns labeled 0 to 4.  One cell in row 1, column 1 contains a 0, highlighted in peach. The bottom-right cell (row 3, column 4) also contains a 0, highlighted in light blue. A thick black arrow points to the right, indicating a transformation. The resulting matrix on the right shows the same numerical values, but with specific cells replaced by 0s.  The cells in row 1, column 1; row 1, column 2; row 2, column 1; row 2, column 2; row 3, column 0; row 3, column 1; row 3, column 2; row 3, column 3; and row 3, column 4 are all replaced with 0s.  These 0s are highlighted with peach, light blue, and light grey shading, indicating a pattern of replacement based on their position within the original matrix.  The row and column labels remain consistent in both matrices.

Intuition - Hash Sets

A brute-force solution involves recording the positions of all 0s initially in the matrix and, for each of these 0s, iterating over their row and column to set them to zero. However, imagine an input array that's filled with many zeros. In the worst case, iterating over every row and column for each zero will take O(m⋅n(m+n))O(m\cdot n(m+n))O(m⋅n(m+n)), where m⋅nm \cdot nm⋅n denotes the number of 0s, and (m+nm+nm+n) represents the total number of cells in a row and column combined. This approach is quite inefficient, so let’s look for a better solution.

Imagine any cell in the matrix. After the matrix is transformed, this cell will either retain its original value, or become zero. Is there a way to tell if a cell is going to become zero?

The key observation is that if a cell is in a row or column containing a zero, that cell will become zero.

We could search a cell’s row and column to check if they contain a zero, meaning each search would take O(m+n)O(m+n)O(m+n) time. But it would be more efficient if we had a way to check this in constant time, and this is where hash sets would be useful. If we create two hash sets – one to track all the rows containing a zero and another to track all the columns containing a zero – we can determine if a specific cell's row or column contains a zero in O(1)O(1)O(1) time.

With these hash sets created, the next step is to populate them. As we iterate through the matrix, when encountering a cell containing zero, we:

  • Add its row index to the row hash set (zero_rows).
  • Add its column index to the column hash set (zero_cols).
Image represents a 4x5 matrix with integer values from 0 to 19,  indexed by rows (0 to 3) and columns (0 to 4). Two elements at positions (1,1) and (3,4) have a value of 0 and are highlighted in peach.  Two arrows pointing downwards from above indicate input to the matrix, likely representing the number of rows and columns. Two arrows pointing to the left from the sides represent input to the matrix, likely representing the row and column indices.  To the right of the matrix, a separate box displays two sets: `zero_rows = {1, 3}` and `zero_cols = {1, 4}`, indicating the row indices (1 and 3) and column indices (1 and 4) where the zero values are located within the matrix.  The overall diagram likely illustrates a data structure and the identification of zero elements within it.

Next, we identify the cells whose row or column indexes are present in the respective hash sets, and change their values to zero. Let’s look at how this works with a few examples:

Image represents a step-by-step illustration of a coding pattern, likely related to setting elements of a matrix to zero.  The image shows two 4x5 matrices, labeled with row and column indices from 0 to 3 and 0 to 4 respectively. The left matrix contains numerical values from 1 to 20, with a highlighted cell (1,2) containing the value 8.  To the right of the matrices is a description of two sets: `zero_rows = {1, 3}` and `zero_cols = {1, 4}`, indicating rows 1 and 3, and columns 1 and 4 should be set to zero. A dashed box explains the process: `cell(1, 2): row 1 is in zero_rows → set to 0`, indicating that because row 1 is in `zero_rows`, the value in cell (1,2) is changed to 0. A thick arrow points from the left matrix to the right matrix, which shows the result of this operation: the value in cell (1,2) is now 0, while the other cells remain unchanged.  The right matrix also has the value in cell (3,1) and (3,4) changed to 0 because of the `zero_rows` and `zero_cols` sets.
Image represents a transformation of a 4x5 matrix.  The initial matrix displays numerical values from 1 to 20 arranged in a grid with rows labeled 0-3 and columns labeled 0-4.  A highlighted cell (2,4) containing the value '15' is shown in peach color.  To the right,  `zero_rows` is defined as the set {1, 3} and `zero_cols` as {1, 4}. A separate box explains the operation:  `cell(2, 4): col 4 is in zero_cols → set to 0`. A thick black arrow indicates a transformation from the initial matrix to a modified matrix. The modified matrix is identical to the original, except that the cell (2,4) now contains '0', reflecting the operation described in the box, which sets the value of the cell to 0 because its column index (4) is present in the `zero_cols` set.
Image represents a coding pattern illustration explaining a specific algorithm.  The image features a 4x5 matrix (grid) on the left, with rows and columns numbered from 0 to 3 and 0 to 4 respectively.  Each cell in the matrix contains a numerical value.  A specific cell at row 2, column 2 (containing the value 13) is highlighted in a light peach color. To the right of the matrix, two lines of code define `zero_rows` as a set containing {1, 3} and `zero_cols` as a set containing {1, 4}. Below this, a light gray, dashed-bordered box explains the logic for a cell at coordinates (2, 2):  because neither row 2 nor column 2 are present in the `zero_rows` or `zero_cols` sets respectively, the value of this cell (13) is not set to 0.  An arrow visually connects this explanation to the highlighted cell in the matrix, illustrating the decision-making process within the algorithm.

This provides a general strategy:

  1. In one pass of the matrix, identify each cell containing a zero and add its row and column indexes to the zero_rows and zero_cols hash sets, respectively.
  2. In a second pass, set any cell to zero if its row index is in zero_rows or its column index is in zero_cols.

Implementation - Hash Sets

python
from typing import List
    
def zero_striping_hash_sets(matrix: List[List[int]]) -> None:
    if not matrix or not matrix[0]:
        return
    m, n = len(matrix), len(matrix[0])
    zero_rows, zero_cols = set(), set()
    # Pass 1: Traverse through the matrix to identify the rows and columns
    # containing zeros and store their indexes in the appropriate hash sets.
    for r in range(m):
        for c in range(n):
            if matrix[r][c] == 0:
                zero_rows.add(r)
                zero_cols.add(c)
    # Pass 2: Set any cell in the matrix to zero if its row index is in 'zero_rows'
    # or its column index is in 'zero_cols’.
    for r in range(m):
        for c in range(n):
            if r in zero_rows or c in zero_cols:
                matrix[r][c] = 0

Complexity Analysis

Time complexity: The time complexity of zero_striping_hash_sets is O(m⋅n)O(m\cdot n)O(m⋅n) because we perform two passes over the matrix and perform constant-time operations in each pass.

Space complexity: The space complexity is O(m+n)O(m+n)O(m+n) due to the growth of the hash sets used to track zeros: one hash set scales with the number of rows, and the other scales with the number of columns. In the worst case, every row and column has a zero.

Intuition - In-place Zero Tracking

The previous solution was time efficient mainly due to the use of hash sets. However, this came at the cost of extra space used to store the hash set values. Is there an alternate way to keep track of which rows and columns contain a zero?

A key observation is that if a row or column contains a zero, all the cells in that row or column will be eventually replaced by zero. Therefore, there's no need to preserve the values in these rows or columns.

A strategy we can try is to use the first row and column (row 0 and column 0) as markers to track which rows and columns contain zeros.

To understand how this would work, consider the example below. For rows, we can use the first column to mark the rows that contain a zero. Specifically, this means if any cell in a row is zero, we set the corresponding cell in the first column to zero. This zero in the first column serves as a marker to indicate the entire row should eventually be set to zeros.

Image represents a visual explanation of a coding pattern.  It shows two 4x5 matrices, before and after an operation. The initial matrix contains numbers 1 through 20 arranged sequentially, with zeros at positions (1,1), (3,1), and (4,5).  Each cell is labeled with its row and column index (0-3, 0-4). The first matrix is transformed into the second matrix by using the first column (index 0) as a marker.  A horizontal arrow connects the two matrices, labeled 'use the first column to mark rows that have zeros'. In the second matrix, the first column cells corresponding to rows containing zeros (rows 1 and 3) are also marked with zeros.  These zeros in the first column act as indicators of rows containing at least one zero in the original matrix.  The remaining elements in the second matrix are identical to the first.

Similarly to how we marked rows, we can mark columns containing zeros using the first row:

Image represents a visual explanation of a coding pattern.  The image shows two 4x5 matrices. The left matrix contains numerical data from 1 to 20, arranged sequentially, with a 0 in the first row, second column and the last row, last column.  The top row of this matrix is highlighted in a light peach color.  A thick black arrow points from the left matrix to the right matrix, with the text 'use the first row to mark columns that have zeros' written above it. The right matrix is identical to the left, except that the columns corresponding to the zeros in the first row of the left matrix (column 1 and column 4) now also have zeros in all their rows.  Specifically, zeros are added to the second row, first column; and the last row, last column of the right matrix.  The added zeros in the right matrix are visually indicated by upward-pointing arrows originating from the corresponding zeros in the first row.  Both matrices are labeled with row and column indices (0-3 and 0-4 respectively).

To set markers for the first row and first column, we can begin searching the rest of the matrix, excluding the first row and first column, for any zero-valued cells. Let’s refer to this part of the matrix as the ‘submatrix.’ When we find a zero, we set the corresponding cell in the first row and column to zero. Scanning every cell in the submatrix for zeros allows us to set markers in the first row and first column:

Image represents a 4x5 matrix, visually divided into cells, each containing a numerical value.  The rows and columns are labeled 0 through 3 and 0 through 4 respectively.  Several cells are highlighted in a light peach color, forming a roughly L-shaped region encompassing cells (0,0), (1,0), (2,0), (3,0), and (0,1).  Arrows indicate data flow:  a horizontal arrow points from cell (1,0) to (0,0), another from (3,0) to (0,0), and two vertical arrows point from (1,0) to (0,1) and from (3,0) to (0,4).  The values within the highlighted cells are all '0', while the remaining cells contain numbers ranging from 1 to 19. The arrows suggest a data aggregation or processing pattern where values from specific cells (1,0) and (3,0) are directed towards cells (0,0), (0,1), and (0,4), possibly representing a summation or other operation.

Now, we should start converting cells in the submatrix to zeros based on their corresponding markers. We can assess any cell in the submatrix by checking:

  • Whether its corresponding marker in the first column is zero.
  • Whether its corresponding marker in the first row is zero.

If either of these conditions are met, we should set that cell’s value to zero, as shown below:

Image represents a visual explanation of a coding pattern, likely related to matrix or array manipulation.  The image shows two 4x5 matrices before and after an operation.  Each matrix is labeled with row and column indices (0-3 and 0-4 respectively).  The cells contain numerical values, with some cells highlighted in light peach.  The initial matrix displays a dashed-line-outlined light-blue cell containing the value '8' at row 1, column 2. A dashed light-blue line connects this cell to the cell at row 1, column 0, which contains a '0'.  A central explanatory box states `cell(1, 2): The corresponding marker at the first column is 0 → set to 0`.  A thick black arrow points from the initial matrix to the modified matrix. The modified matrix is identical to the initial one, except that the cell at row 1, column 0 now contains a '0', visually demonstrating the effect of setting the marker in the first column to 0 based on the value in cell (1,2).

To update this submatrix, we can iterate from the second row and column and update cell values based on the logic we just mentioned:

Image represents a transformation of a 4x5 matrix.  The left matrix displays numerical values arranged in a grid with rows labeled 0-3 and columns labeled 0-4.  A peach-colored region highlights a subset of cells containing the values 0, 8, 9, 10, 12, 13, 14, 15, 17, 18, and 19.  A thick orange line traces a path through these highlighted cells, starting at cell (1,1) with value '0' and ending at cell (3,4) with value '0', forming a roughly 'L' shape.  A large black arrow points from the left matrix to the right. The right matrix is identical in structure to the left, but the values within the peach-colored region are all replaced with zeros, while the values outside this region remain unchanged.  The transformation visually demonstrates the removal or zeroing-out of specific elements within a defined subset of the matrix, leaving the rest of the matrix untouched.

Handling zeros in the first row and column After completing the previous step, there’s just one issue to address. What if the first row or column originally had a zero, like in the example below?

Image represents a 4x5 matrix, or two-dimensional array, of integers.  The matrix is indexed using row and column numbers, displayed along the top and left side respectively, ranging from 0 to 3 for rows and 0 to 4 for columns.  Each cell within the matrix contains a single integer value.  The integers are sequentially increasing, with the exception of several zeros strategically placed within the matrix. Specifically, a zero is located at row 0, column 3; and another at row 3, column 4.  The matrix is visually delineated by bold black lines forming a grid structure, clearly separating each individual cell containing its respective integer value.  No URLs or parameters are present; the image solely displays the numerical data within the matrix structure. The highlighted cell (row 0, column 3) containing '0' is shaded in a light peach or beige color.

Here, we can’t distinguish which zero in the first row was originally present, or resulted from being used as a marker. This means we won’t know if the first row should be zeroed:

Image represents a 4x5 grid of numbers, labeled with row and column indices from 0 to 3 and 0 to 4 respectively.  The grid contains integers from 0 to 19, arranged sequentially. Two zeros in column 1 and 4 of row 0 are highlighted with a light peach background. Two orange arrows point upwards from the zeros in column 1 (row 1) and column 4 (row 3), indicating movement or a transformation.  To the right of the grid, text states: 'can't tell which zero is a marker or was originally there,' suggesting ambiguity regarding the origin of the zeros, implying that the zeros might be placeholders or part of the original data. The arrows visually represent a potential shift or operation involving these zeros, but the exact nature of this operation remains unclear due to the accompanying text.

The remedy for this is to flag whether a zero exists in the first row or first column before using them as markers.

Image represents a 4x5 matrix, indexed from 0 to 3 along both rows and columns, containing integer values.  The matrix's cell at row 0, column 3 contains a highlighted '0' in a peach-colored background.  A horizontal arrow extends from this cell to a rectangular box with a dashed border. This box contains two lines of text representing boolean variables:  `first_row_has_zero = True` and `first_col_has_zero = False`. The arrow indicates that the presence of a '0' in the first row (row 0) results in the `first_row_has_zero` variable being assigned the value `True`, while the absence of a '0' in the first column (column 0) results in `first_col_has_zero` being assigned `False`.  The matrix visually demonstrates the data used to determine the boolean values.

Once we've filled the first row and column with markers, as shown in matrix X below, and set the appropriate cell values in the submatrix to zero, as shown in matrix Y, we then evaluate the first row and column separately. The first row was initially marked as containing a zero, so we convert all cells in the first row to zero (matrix Z). The first column was not flagged for having a zero initially, so it remains unaltered at this step:

Image represents a step-by-step transformation of a 4x4 matrix.  The initial matrix, labeled 'X', contains numerical values, with some cells highlighted in peach.  Within the peach-colored cells, the values are represented by bold zeros ('**0**').  Orange arrows indicate data movement; two arrows point upwards from the bottom row's peach cells to the top row's peach cells, and two arrows point horizontally from the left column's peach cells to the right column's peach cells.  The matrix 'Y' shows the result of this transformation: the peach-colored cells from 'X' are now grouped together in a 2x2 submatrix within the larger 4x4 matrix, with the remaining cells containing zeros ('0') in gray text.  A rightward arrow connects 'X' and 'Y'.  Finally, matrix 'Z' shows a further transformation where the 2x2 submatrix from 'Y' is compressed into a single row at the top of the matrix, again with the remaining cells filled with zeros ('0') in gray text.  Another rightward arrow connects 'Y' and 'Z'.  Each matrix is labeled with its corresponding letter (X, Y, Z) below it, and the rows and columns are numbered from 0 to 3.

In-place zero-marking strategy Let's summarize the above approach into the following steps:

  1. Use a flag to indicate if the first row initially contains any zero.
  2. Use a flag to indicate if the first column initially contains any zero.
  3. Traverse the submatrix, setting zeros in the first row and column to serve as markers for rows and columns that contain zeros.
  4. Apply zeros based on markers: iterate through the submatrix that starts from the second row and second column. For each cell, check if its corresponding marker in the first row or column is marked with a zero. If so, set that element to zero.
  5. If the first row was initially marked as containing a zero, set all elements in the first row to zero.
  6. If the first column was initially marked as having a zero, set all elements in the first column to zero.

Implementation - In-place Zero Tracking

python
from typing import List
   
def zero_striping(matrix: List[List[int]]) -> None:
   if not matrix or not matrix[0]:
       return
   m, n = len(matrix), len(matrix[0])
   # Check if the first row initially contains a zero.
   first_row_has_zero = False
   for c in range(n):
       if matrix[0][c] == 0:
           first_row_has_zero = True
           break
   # Check if the first column initially contains a zero.
   first_col_has_zero = False
   for r in range(m):
       if matrix[r][0] == 0:
           first_col_has_zero = True
           break
   # Use the first row and column as markers. If an element in the submatrix is zero,
   # mark its corresponding row and column in the first row and column as 0.
   for r in range(1, m):
       for c in range(1, n):
           if matrix[r][c] == 0:
               matrix[0][c] = 0
               matrix[r][0] = 0
   # Update the submatrix using the markers in the first row and column.
   for r in range(1, m):
       for c in range(1, n):
           if matrix[0][c] == 0 or matrix[r][0] == 0:
               matrix[r][c] = 0
   # If the first row had a zero initially, set all elements in the first row to
   # zero.
   if first_row_has_zero:
       for c in range(n):
           matrix[0][c] = 0
   # If the first column had a zero initially, set all elements in the first column
   # to zero.
   if first_col_has_zero:
       for r in range(m):
           matrix[r][0] = 0

Complexity Analysis

Time complexity: The time complexity of zero_striping is O(m⋅n)O(m\cdot n)O(m⋅n). Here’s why:

  • Checking the first row for zeros takes O(m)O(m)O(m) time, and checking the first column takes O(n)O(n)O(n) time.
  • Then, we perform two passes of the entire matrix, one to mark 0s and another to update the matrix based on those markers. Each pass takes O(m⋅n)O(m\cdot n)O(m⋅n) time.
  • Finally, we iterate through the first row and first column up to once each, which takes O(m)O(m)O(m) and O(n)O(n)O(n) time, respectively.

Therefore, the overall time complexity is O(m)+O(n)+O(m⋅n)=O(m⋅n)O(m)+O(n)+O(m\cdot n)=O(m\cdot n)O(m)+O(n)+O(m⋅n)=O(m⋅n).

Space complexity: The space complexity is O(1)O(1)O(1) because we use the first row and column as markers to track which rows and columns contain zeros, instead of using auxiliary data structures.

Longest Chain of Consecutive Numbers

Longest Chain of Consecutive Numbers

Find the longest chain of consecutive numbers in an array. Two numbers are consecutive if they have a difference of 1.

Example:

python
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 iterate through the array to identify the longest sequence of consecutive numbers.

Image represents a visual depiction of a sorting algorithm's effect on an array of numbers.  The diagram begins with an unsorted array `[1 6 2 5 8 7 10 3]` enclosed in square brackets.  A right-pointing arrow labeled 'sort' indicates a transformation.  The result of the sorting operation is shown as two sub-arrays. The first, a peach-colored sub-array `[1 2 3]`, contains the smallest three numbers from the original array. The second, a light-green sub-array `[5 6 7 8 10]`, contains the remaining numbers, which are larger than those in the first sub-array. The label 'longest' is placed above the second sub-array, indicating that this sub-array represents the longest increasing subsequence within the original unsorted array.  The entire output is presented as a sequence of numbers within square brackets.

This approach requires sorting, which takes O(nlog⁡(n))O(n\log (n))O(nlog(n)) time, where nnn 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:

python
from typing import List
     
def longest_chain_of_consecutive_numbers_brute_force(nums: List[int]) -> int:
    if not nums:
        return 0
    longest_chain = 0
    # Look for chains of consecutive numbers that start from each number.
    for num in nums:
        current_num = num
        current_chain = 1
        # Continue to find the next consecutive numbers in the chain.
        while (current_num + 1) in nums:
            current_num += 1
            current_chain += 1
        longest_chain = max(longest_chain, current_chain)
    return longest_chain

This brute force approach takes O(n3)O(n^3)O(n3) time because of the nested operations involved:

  • The outer for-loop iterates through each element, which takes O(n)O(n)O(n) time.
  • For each element, the inner while-loop can potentially run up to n iterations if there is a long consecutive sequence starting from the current number.
  • For each, while-loop iteration, an O(n)O(n)O(n) 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.

Optimization - hash set To find the next number in a sequence, we perform a linear search through the array. However, by storing all the numbers in a hash set, we can instead query this hash set in constant time to check if a number exists.

This reduces the time complexity from O(n3)O(n^3)O(n3) to O(n2)O(n^2)O(n2).

Optimization - identifying the start of each chain In the brute force approach, we treat each number as the start of a chain. This becomes quite expensive because we perform a linear search for every number to find the rest of its chain:

Image represents a diagram illustrating a search for a consecutive chain of numbers.  The top of the diagram contains the text 'find the rest of a consecutive chain starting at:'.  From this text, eight orange arrows point downwards to a set of single-digit numbers enclosed in square brackets: [1, 6, 2, 5, 8, 7, 10, 3].  The arrows visually represent the exploration of potential consecutive chains starting from an unspecified initial number (implied by the phrase 'starting at:').  The numbers at the bottom are potential candidates or elements within the search space for completing the consecutive chain.  The diagram suggests a branching or fan-out search strategy, where multiple possibilities are explored simultaneously to find the rest of the consecutive chain.

The key observation here is that we don’t need to perform this search for every number in a chain. Instead, we only need to perform it for the smallest number in each chain since this number identifies the start of its chain:

Image represents a diagram illustrating the concept of finding a consecutive chain within a sequence of numbers.  The top of the diagram displays the text 'find the rest of a consecutive chain starting at:'.  Three orange arrows emanate downwards from this text, pointing to three distinct subsequences of numbers: '[1 6 2]', '[5 8 7]', and '[10 3]'. Below each subsequence, grey arrows indicate potential consecutive chains.  Specifically, under '[1 6 2]', a grey arrow points to '1 2 3', suggesting a possible consecutive chain starting with 1. Similarly, under '[5 8 7]', a grey arrow points to '5 6 7 8', indicating a potential consecutive chain starting with 5. Finally, under '[10 3]', a grey arrow points to '10', showing a single-element consecutive chain starting at 10. The diagram visually represents the challenge of identifying consecutive number sequences from a larger, unordered set.

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.

Image represents a step-by-step illustration of an algorithm, likely searching within an array.  The top section shows an array `[1, 6, 2, 5, 8, 7, 10, 3]`.  The number 5 is highlighted in a grey circle. Above it, text states 'not the smallest in its chain: 6 - 1 = 5 exists,' indicating a check for whether 5 is the smallest element in a sub-array derived by subtracting 1 from a larger element (in this case, 6). A downward-pointing arrow connects this text to the array, visually indicating the element being processed. The bottom section shows the same array, but now the number 5 is no longer highlighted. Above it, the text reads 'smallest in its chain: 5 - 1 = 4 doesn't exist,' implying that a search for 4 (5-1) within the array failed.  A downward-pointing arrow again connects the text to the array, showing that the algorithm continues its search after finding that 5 is not the smallest in its chain. The overall image demonstrates a process of iterating through an array and checking for the existence of specific values derived from calculations on other array elements.

This reduces the time complexity from O(n2)O(n^2)O(n2) to O(n)O(n)O(n), as now every chain is searched through only once. This is explained in more detail in the complexity analysis.

Implementation

python
from typing import List
        
def longest_chain_of_consecutive_numbers(nums: List[int]) -> int:
    if not nums:
        return 0
    num_set = set(nums)
    longest_chain = 0
    for num in num_set:
        # If the current number is the smallest number in its chain, search for
        # the length of its chain.
        if num - 1 not in num_set:
            current_num = num
            current_chain = 1
            # Continue to find the next consecutive numbers in the chain.
            while current_num + 1 in num_set:
                current_num += 1
                current_chain += 1
            longest_chain = max(longest_chain, current_chain)
    return longest_chain

Complexity Analysis

Time complexity: The time complexity of longest_chain_of_consecutive_numbers is O(n)O(n)O(n) because, although there are two loops, the inner loop is only executed when the current number is the start of a chain. This ensures that each chain is iterated through only once in the inner while-loop. Thus, the total number of iterations for both loops combined is O(n)O(n)O(n): the outer for-loop runs nnn times, and the inner while-loop runs a total of nnn times across all iterations, resulting in a combined time complexity of O(n+n)=O(n)O(n+n)=O(n)O(n+n)=O(n).

Space complexity: The space complexity is O(n)O(n)O(n) since the hash set stores each unique number from the array.

Geometric Sequence Triplets

Geometric Sequence Triplets

A geometric sequence triplet is a sequence of three numbers where each successive number is obtained by multiplying the preceding number by a constant called the common ratio.

Let's examine three triplets to understand how this works:

  • (1, 2, 4): This is a geometric sequence with a ratio of 2‾\underline{\text{2}}2​ (i.e., [1, 1⋅2‾\underline{\text{2}}2​ = 2, 2⋅2‾\underline{\text{2}}2​ = 4]).
  • (5, 15, 45): This is a geometric sequence with a ratio of 3‾\underline{\text{3}}3​ (i.e., [5, 5⋅3‾\underline{\text{3}}3​ = 15, 15⋅3‾\underline{\text{3}}3​ = 45]).
  • (2, 3, 4): Not a geometric sequence.

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:

Image represents five rows demonstrating a geometric sequence. Each row begins with a bracketed sequence of numbers: [2, 1, 2, 4, 8], with some rows having additional numbers (8) at the end.  The numbers 2, 4, and 8 within the brackets are highlighted in peach-colored circles, and small numbers beneath them indicate their position within the sequence (0, 2, 3, 4, 5).  Following each bracketed sequence is a colon and then an equation demonstrating the pattern: (2, 2*2 = 4, 4*2 = 8).  The first number in the equation (2) corresponds to the first highlighted number in the bracket, and subsequent numbers are generated by multiplying the preceding number by 2. The last row shows a variation where the sequence starts with 1 instead of 2, resulting in a modified equation: (1, 1*2 = 2, 2*2 = 4).  The overall image illustrates the concept of a geometric progression where each term is obtained by multiplying the previous term by a constant value (in this case, 2).
python
Input: nums = [2, 1, 2, 4, 8, 8], `r` = 2
Output: 5

Explanation:

  • Triplet [2, 4, 8] occurs at indexes (0, 3, 4), (0, 3, 5), (2, 3, 4), (2, 3, 5).
  • Triplet [1, 2, 4] occurs at indexes (1, 2, 3).

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.

How can we represent a geometric sequence so that it follows rule 1? Let’s say the first number is x. The second number is the first number multiplied by r (i.e., x⋅r), and the third is the second number multiplied by r (i.e., x⋅r⋅r = x⋅r^2). So, a triplet in a geometric sequence can be represented as (x, x⋅r, x⋅r^2).

A brute force approach is to iterate over every possible triplet in the array and check if any of these triplets follow a geometric progression. However, it would take three nested for-loops to search through all the triplets, resulting in a time complexity of O(n3)O(n^3)O(n3), where nnn 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⋅r^2 to form a geometric triplet (x, x⋅r, x⋅r^2). However, we could run into issues when using this triplet representation. While it’s clear the values x⋅r and x⋅r^2 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⋅r^2, x⋅r), which is invalid:

Image represents a diagram illustrating an ordering problem in a data structure.  A single 'X' at the top points downwards via a grey arrow to a horizontal array-like structure enclosed in square brackets `[ ]`. This array contains several elements represented by dots (`.`) as placeholders, with a single 'X' and then 'x*r²' and 'x*r' appearing in the array. Below the array, a grey box highlights the text '(x, x*r², x*r)' labeled as 'triplet valves,' indicating that these three elements are expected to form a triplet. The text 'appear in the wrong order' explains that the order of these elements within the array does not match the expected order defined in the triplet valves.  The diagram visually shows the incorrect arrangement of the triplet within a larger data structure, highlighting the problem of out-of-order elements.

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:

Image represents a comparison of two search operations within a data structure, likely an array.  The left side shows a light-blue rectangular box representing an array containing several elements (represented by black dots). A gray line extends below the box, indicating a range, with a downward-pointing triangle marking a midpoint. Below the line, the text 'find x/r' suggests a search operation aiming to find an element 'x' at or near the middle ('r' possibly representing a range or index). The right side mirrors this structure but uses a peach-colored rectangle.  The text 'find x*r' suggests a different search operation, possibly indicating a search for 'x' within a range defined by 'r', but with a different search algorithm or criteria implied by the asterisk (*). The letter 'X' separates the two diagrams, visually distinguishing the two search methods.  Both diagrams use brackets to visually represent the beginning and end of the array.

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 O(n2)O(n^2)O(n2) 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.

What we would need are two hash maps:

  • A hash map that contains numbers to the left of each x (left_map).
  • A hash map that contains numbers to the right of each x (right_map).
Image represents a diagram illustrating a data structure concept, likely within the context of a two-pointer approach or similar algorithm.  The diagram shows two rectangular boxes, one light blue labeled implicitly as a 'left_map' and the other peach-colored labeled implicitly as a 'right_map'. Each box contains several dots (...), representing data elements within each respective map.  A horizontal line extends below each box, ending in a downward-pointing triangle, visually indicating a storage location. Below each line, the text 'store in' is written, followed by 'left_map' under the light blue box and 'right_map' under the peach box.  The two boxes are separated by the letter 'X', suggesting a separation or comparison point between the two data structures. The overall structure suggests that data is stored and potentially processed separately in 'left_map' and 'right_map', with the 'X' possibly representing a pivot or comparison point in an algorithm.

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.

Finding all (x/r, x, x⋅r) triplets Our goal is to find all triplets that follow a geometric sequence, representing each number in the array as the middle (x) number of a triplet.

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:

Image represents a diagram illustrating a coding pattern, possibly related to data structures or algorithms.  The diagram shows two parallel structures, each representing a set of numbers. The left structure, labeled 'x/r = 2' in light blue, depicts a set [2, 1, 2], where two instances of the number 2 are connected by a line labeled 'two 2's' to a central node.  The right structure, labeled 'x/r = 8' in orange, shows a set [8, 8], with two instances of 8 connected by a line labeled 'two 8's' to the same central node. A downward-pointing arrow labeled 'x' connects the two structures, indicating a transformation or operation. The central node, connected to both structures, is labeled '2 * 2 = 4', suggesting a calculation based on the number of instances in each structure. Finally, an arrow pointing to the right indicates the result: '4 instances of [2, 4, 8]', implying that the combined result of the operation on the two input structures produces four instances of the combined set [2, 4, 8].

This overall methodology can be summarized in the following steps:

Image represents a flowchart illustrating a coding pattern for counting triplets.  The top shows two arrays, a light-blue array labeled `[`...`]` on the left representing values of `x/r`, and a peach-colored array labeled `[`...`]` on the right representing values of `x*r`, separated by the variable 'x'.  Step ①, labeled 'check that x%r == 0', indicates a condition that must be met.  Step ②, labeled 'get frequency of x/r from left_map', shows an arrow pointing from the left array to step ④.  Similarly, step ③, labeled 'get frequency of x*r from right_map', shows an arrow pointing from the right array to step ④.  Step ④, labeled 'multiply the frequencies to get the number of triplets with this x in the middle', indicates that the frequencies obtained in steps ② and ③ are multiplied to determine the count of triplets where 'x' is the central element, satisfying the condition in step ①.  The arrows visually represent the data flow, showing how the frequencies from the left and right arrays are combined to calculate the final result.

Note that if either x/r or x⋅r are not found in their hash maps, their frequency is 0 by default.

Let’s implement this strategy using the example below:

Image represents a one-dimensional array or list enclosed in square brackets `[` and `]`, containing the integer sequence `[2, 1, 2, 4, 8, 8]`.  This array is separated by a comma from the assignment `r = 2`, indicating a variable `r` is assigned the integer value 2.  The arrangement shows the array as a data structure, and the assignment suggests a potential relationship between the array and the variable `r`, possibly representing a parameter or index related to the array's processing or manipulation within a larger algorithm or code.  There are no URLs or other parameters present beyond the numerical values and variable assignment.

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:

Image represents a visual depiction of a data transformation process, likely related to frequency counting or mapping.  The input is a list of numbers `[2, 1, 2, 4, 8, 8]` and a value `r = 2`.  This input is processed to generate two maps: `left_map` and `right_map`.  `right_map` is a table with two columns: `x*r` and `freq`. It shows the results of multiplying each element in the input list by `r` (2) in the `x*r` column, and the frequency of each resulting value in the `freq` column. For example, 2*2=4 appears once, 1*2=2 appears twice, 4*2=8 appears twice. `left_map` is an empty table with columns `x/r` and `freq`, suggesting it would contain the results of dividing each input element by `r` and their corresponding frequencies, but these values are not shown in the image.  The overall diagram illustrates a process where an input list is transformed based on a given value `r`, resulting in frequency counts displayed in two separate tables representing different operations (multiplication and division by `r`).

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:

Image represents a diagram illustrating a coding pattern, possibly related to frequency counting or mapping.  The diagram shows an input array `x = [2, 1, 2, 4, 8, 8]` and a value `r = 2`.  This input is processed to create two maps: `left_map` and `right_map`.  `left_map` is empty, suggesting it's a placeholder or the result of a computation not yet shown. `right_map` is a table with two columns: `x*r` (representing elements of the input array multiplied by `r`) and `freq` (representing the frequency of each element in `x*r`).  The `right_map` contains the following entries: `x*r` values of 2, 1, 4, 8 and their corresponding frequencies in `freq` as 2, 1, 1, 2 respectively. An arrow points from the `freq` column value of 2 (corresponding to `x*r` = 2) to the number 1, indicating a potential further processing step or mapping not fully depicted.  The overall structure suggests a process where an input array is manipulated based on a given value `r`, resulting in frequency counts displayed in the `right_map`, with a possible additional transformation indicated by the arrow.

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:

The image represents a code snippet illustrating a calculation process. On the left, an array `[2 1 2 4 8 8]` is shown, with a variable `r` assigned the value 2.  A downward-pointing arrow suggests this array is input to the process. On the right, a light-grey box contains a conditional statement and a series of calculations. The condition `x % r == 0` checks if a variable `x` (not explicitly defined in the image but implied to be an element from the input array) is divisible by `r`. If true, the `count` variable is updated. The update involves multiplying values from two maps, `left_map` and `right_map`, using `x/r` and `x*r` as indices respectively.  The example shows the calculation for `x=2`, resulting in `count += left_map[2/2] * right_map[2*2]`, which simplifies to `count += left_map[1] * right_map[4]`.  Subsequent lines show further additions to `count`, ultimately resulting in `count += 0`, suggesting that the initial multiplication resulted in zero, and subsequent additions also contributed zero.  The overall structure depicts a conditional update of a counter based on array elements and lookups in two maps (`left_map` and `right_map`).

Before moving on to the next value, let’s add the current number to the left_map because it now becomes a potential x/r value for future triplets in the array:

Image represents a comparison of two maps, labeled 'left_map' and 'right_map,' each depicted as a table with two columns.  The left column in both maps is labeled 'x/r' in the left map and 'x*r' in the right map, representing some form of input data (possibly x divided by r and x multiplied by r respectively). The right column in both maps is labeled 'freq,' indicating frequency.  'left_map' contains two rows: one with '2' in the 'x/r' column and '1' in the 'freq' column, and another with an empty 'x/r' cell and an empty 'freq' cell. 'right_map' contains four rows showing different pairings of 'x*r' and 'freq': (2,1), (1,1), (4,1), and (8,2).  The maps are presented side-by-side for visual comparison, highlighting the difference in data representation and frequency distribution between the 'x/r' and 'x*r' transformations.

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.

Image represents a diagram illustrating a coding pattern, possibly related to data processing or algorithm design.  At the top, an input array `[2 1 2 4 8 8]` and a parameter `r = 2` are shown.  A downward-pointing arrow indicates the processing of the input array. Below, two tables are presented side-by-side, labeled 'left_map' and 'right_map'.  'left_map' contains a column labeled 'x/r' with values (presumably resulting from dividing elements of the input array by `r`) and a column labeled 'freq' representing their frequencies. Similarly, 'right_map' has columns 'x*r' (likely representing multiplication of input array elements by `r`) and 'freq' showing their frequencies.  A light gray rectangular box displays the condition `x % r != 0 → can't form a triplet → continue`, indicating that if the remainder of an element divided by `r` is not zero, a triplet cannot be formed, and the process continues to the next element.  The overall diagram suggests a frequency analysis or counting process involving division and multiplication by a given parameter `r`, with a conditional check for triplet formation.
Image represents a diagram illustrating a coding pattern, possibly related to frequency counting or data manipulation.  At the top, an input array `[2 1 2 4 8 8]` and a value `r = 2` are given. Below, two tables labeled 'left_map' and 'right_map' are shown.  'left_map' contains two columns: 'x/r' representing values after integer division by `r`, and 'freq' representing their frequencies.  Similarly, 'right_map' has columns 'x*r' (values after multiplication by `r`) and 'freq'.  The values in these tables seem pre-calculated based on the input array. A dashed-line box to the right shows a calculation process.  It starts with a condition `x % r == 0`, implying a check for divisibility by `r`. If true, a `count` variable is updated by adding the product of a value from 'left_map' (indexed by `x/r`) and a value from 'right_map' (indexed by `x*r`). An example calculation is shown, where `x` is implicitly 2, resulting in `left_map[2/2] * right_map[2*2]`, which simplifies to `1 * 1`, adding 1 to the `count`.  The entire process suggests an algorithm that uses pre-computed frequency maps ('left_map' and 'right_map') to efficiently process the input array based on divisibility by `r`.
Image represents a diagram illustrating a coding pattern, possibly related to frequency counting or data manipulation.  At the top, an input array `[2 1 2 4 8 8]` and a value `r = 2` are given.  Below, two tables labeled 'left_map' and 'right_map' are shown.  'left_map' has two columns: 'x/r' (representing the element of the input array divided by r) and 'freq' (frequency). It contains the values 2 (x/r) and 2 (freq) in the first row, and 1 (x/r) and 1 (freq) in the second row. 'right_map' similarly has columns 'x*r' (element multiplied by r) and 'freq', with values 2 and 0, 1 and 0, 4 and 0, and 8 and 2. To the right, a dashed box shows a calculation:  `x % r == 0 → count += left_map[x/r] * right_map[x*r]`. This formula is then broken down step-by-step, substituting values from the input array and the maps: `+= left_map[4/2] * right_map[4*2]`, `+= 2 * 2`, and finally `+= 4`, demonstrating how the count is updated based on the values in the 'left_map' and 'right_map' tables.  The overall diagram visualizes a process where an input array is processed using two pre-computed frequency maps ('left_map' and 'right_map') to calculate a final count.
Image represents a coding pattern illustration focusing on a calculation involving two maps, `left_map` and `right_map`, and an input array `[2, 1, 2, 4, 8, 8]` with `r` set to 2.  An arrow points downwards from 'x' suggesting an input value. `left_map` and `right_map` are presented as tables with two columns: the first labeled 'x/r' or 'x*r' respectively, representing the result of integer division by `r` or multiplication by `r`, and the second labeled 'freq', likely representing frequency counts.  The `left_map` contains entries (2,2), (1,1), (4,1), (8,1), while `right_map` contains (2,0), (1,0), (4,0), (8,1). A dashed box to the right shows a calculation process:  `x % r == 0` (checking if `x` is a multiple of `r`) leads to `count += left_map[x/r] * right_map[x*r]`.  The calculation is further broken down with example values, showing `count` being updated by adding the product of `left_map[8/2]` (which is 1) and `right_map[8*2]` (which is 1), resulting in a final `count` of 0.  The diagram illustrates how the maps are used to perform a calculation based on the input array and the value of `r`.
Image represents a diagram illustrating a coding pattern, possibly for counting occurrences based on modulo operation.  At the top, an input array `[2 1 2 4 8 8]` and a value `r = 2` are given. Below, two tables labeled 'left_map' and 'right_map' are shown side-by-side.  Each table has two columns: the first column in 'left_map' lists the elements of the input array divided by `r` (integer division), while the second column represents their frequencies. Similarly, 'right_map' has its first column listing elements of the input array multiplied by `r`, and the second column showing their frequencies.  A dashed-line box to the right contains a calculation sequence. It starts with a condition `x % r == 0`, indicating that if an element `x` modulo `r` equals 0, a counter (`count`) is updated. The update involves multiplying the frequency from 'left_map' (using the integer division of `x` by `r` as the index) with the frequency from 'right_map' (using `x` multiplied by `r` as the index). The example calculation shows how this is done for the element 8: `left_map[8/2]` (which is 1) is multiplied by `right_map[8*2]` (which is 0), resulting in 0 being added to the counter.  The final result of the calculation is 0 in this specific example.

Implementation

python
from typing import List
from collections import defaultdict
    
def geometric_sequence_triplets(nums: List[int], r: int) -> int:
    # Use 'defaultdict' to ensure the default value of 0 is returned when
    # accessing a key that doesn’t exist in the hash map. This effectively sets
    # the default frequency of all elements to 0.
    left_map = defaultdict(int)
    right_map = defaultdict(int)
    count = 0
    # Populate 'right_map' with the frequency of each element in the array.
    for x in nums:
        right_map[x] += 1
    # Search for geometric triplets that have x as the center.
    for x in nums:
        # Decrement the frequency of x in 'right_map' since x is now being
        # processed and is no longer to the right.
        right_map[x] -= 1
        if x % r == 0:
            count += left_map[x // r] * right_map[x * r]
        # Increment the frequency of x in 'left_map' since it'll be a part of the
        # left side of the array once we iterate to the next value of `x`.
        left_map[x] += 1
    return count

Complexity Analysis

Time complexity: The time complexity of geometric_sequence_triplets is O(n)O(n)O(n) because we iterate through the nums array and perform constant-time hash map operations at each iteration.

Space complexity: The space complexity is O(n)O(n)O(n) because the hash maps can grow up to nnn in size.

Chapter 3

Linked Lists

~31 min read

Introduction to Linked Lists

Introduction to Linked Lists

Intuition

A linked list is a data structure consisting of a sequence of nodes, where each node is linked to the next. A node in a linked list has two main components: the data it stores (val) and a reference to the next node (next) in the sequence:

The image represents a single node in a linked list data structure.  A circular node, outlined in black, contains the label 'val' indicating it holds a value. A light-blue arrow extends from this node, labeled 'next,' pointing to the right. This arrow signifies a pointer or reference to the next node in the sequence.  The 'next' label indicates the direction of traversal through the linked list, implying that the node's data ('val') is followed by another node accessible via the 'next' pointer. The absence of a node at the arrow's end suggests this is either the last node in a list or a partially depicted structure.

We define a node using the ListNode class, as below:

python
class ListNode:
   def __init__(self, val: int, next: ListNode):
       self.val = val
       self.next = next

Singly linked list

The simplest form of a linked list is a singly linked list, where each node points to the next node in the linked list, and the last node points to nothing (null), indicating the end of the linked list. The start of the linked list is called the ‘head,’ which is generally the only node we initially have immediate access to:

Image represents a singly linked list data structure.  A rectangular box labeled 'head' points downwards to a circle containing the number '1'. This circle represents the first node in the list. Each subsequent node (circles numbered 2, 3, 4, and 5) is connected to the previous node by a light-blue arrow labeled 'next'. This 'next' label indicates a pointer that stores the memory address of the following node in the sequence. The arrows illustrate the unidirectional flow of information, showing how each node points to the next one in the list. The final node (5) has no outgoing arrow, signifying the end of the list.  The entire structure visually demonstrates the sequential arrangement of nodes and the use of pointers to navigate through the linked list.

To access the other nodes in a linked list, we would need to traverse it starting at the head.

Image represents a singly linked list data structure.  Five circular nodes, numbered 1 through 5, are depicted, each containing a single integer value.  Solid, light-blue arrows labeled 'next' connect each node sequentially, indicating the directional flow of the list.  These arrows represent pointers in the linked list, showing the connection from one node to the next.  Dashed, orange arrows also arc above the solid arrows, visually representing the traversal of the list.  A separate orange rectangle labeled 'ptr' points down to node 5, indicating a pointer variable named 'ptr' currently referencing the last node in the list.  The overall diagram illustrates the structure and traversal of a singly linked list, with the 'ptr' highlighting a specific point of access within the list.

Singly linked lists can be used to store a collection of data. One of their main benefits lies in their dynamic sizing capability, since they can grow or shrink in size flexibly, unlike arrays which are fixed in size. Additionally, singly linked lists excel in scenarios requiring frequent insertions and deletions, as these operations can be performed more efficiently than in arrays, which need to shift elements to perform insertion or deletion.

Image represents a visual explanation of inserting a node into a linked list.  The top section shows an initial linked list with five nodes (numbered 1 through 5), connected sequentially by light-blue arrows labeled 'next'.  A separate node '3' is shown above this list. A thick black downward-pointing arrow indicates a transformation. The bottom section depicts the linked list after the insertion of node 3 between nodes 2 and 4.  Node 3 is now connected to node 2 and node 4 via light-blue 'next' arrows. The original connection between nodes 2 and 4 is shown crossed out with a red 'X', illustrating the modification of the list's structure. The title 'example - insert node 3 between nodes 2 and 4:' clarifies the operation being demonstrated.

The efficiency of these operations comes at the cost of the inability to perform random access, as nodes can’t be accessed by indexes like in an array. This trade-off may be acceptable in many use cases where the benefits of dynamic sizing and the efficiency of insertion/deletion outweigh the main performance benefits of random access.

Doubly linked list A doubly linked list is an extended version of the linked list where each node contains two references: one to the next node (next), and one to the previous node (prev). In most implementations, doubly linked lists have immediate access to both the head node and the tail node.

Image represents a doubly linked list data structure.  Five nodes, numbered 1 through 5, are depicted as circles, each containing its respective numerical value.  A rectangular box labeled 'head' points to node 1, indicating the beginning of the list, and another rectangular box labeled 'tail' points to node 5, indicating the end.  Between each pair of adjacent nodes are two curved arrows: a light-blue arrow labeled 'next' pointing from the lower-numbered node to the higher-numbered node, representing the forward link in the list; and a purple arrow labeled 'prev' pointing in the opposite direction, representing the backward link. This bidirectional linking allows traversal in both directions within the list.

A big advantage of doubly linked list is that it allows for bidirectional traversal. Additionally, deleting nodes in a doubly linked list is generally more straightforward because we have references to both the next and previous nodes:

Image represents a doubly linked list before and after the deletion of a node.  The top half shows the initial state: a doubly linked list with five nodes (numbered 1 through 5). Each node is represented by a circle containing its numerical value.  Light-blue curved arrows labeled 'next' indicate the forward links between nodes, while purple curved arrows labeled 'prev' show the backward links.  Rectangular boxes labeled 'head' and 'tail' point to the first and last nodes respectively, indicating the list's boundaries. The bottom half depicts the list after node 3 has been removed. Node 3 is replaced by a large red 'X' signifying its deletion. The 'next' and 'prev' pointers of nodes 2 and 4 are now connected directly, bypassing the deleted node, maintaining the doubly linked structure.  A thick black arrow points downwards from node 3 in the top half to the 'X' in the bottom half, clearly showing the node removal operation.

Pointer Manipulation

Many linked list interview problems require traversing or restructuring a linked list. Understanding and being proficient at pointer manipulation is essential to solving these problems. A useful tip is to visualize pointers as arrows that point from one node to another, and observe how these arrows should be moved to reflect the structural change. For example, this is how we would visualize a node insertion:

Image represents a visual depiction of inserting a new node into a linked list.  The top shows a labeled orange rectangle 'new_node' pointing down to a circled '3', representing the new node to be inserted. Below, a linked list is shown with nodes numbered 1, 2, 4, and 5, connected by light-blue arrows labeled 'next'. A thick black arrow points downwards indicating a transformation. The bottom section shows the result of the insertion. Node 3 is now inserted between nodes 2 and 4.  Dashed gray arrows and text indicate the changes: a dashed gray arrow labeled 'add arrow' points from node 2 to node 3, another from node 3 to node 4, and a dashed gray arrow labeled 'remove this arrow' shows the original connection between nodes 2 and 4 being replaced, with a red 'X' marking the removed connection.  All new connections use light-blue arrows labeled 'next', maintaining the linked list structure.

Real-world Example

Music Playlist: Music player applications often use linked lists to implement playlists, particularly doubly linked lists, where each song node links to the next and previous songs. This structure enables efficient addition, removal, and reordering of songs because only the pointers between nodes need to be updated, rather than moving the song data in memory.

Chapter Outline

In this chapter, we explore problems involving both singly and doubly linked lists. We also explore the unique challenge of restructuring a multi-level linked list.

Image represents a hierarchical diagram illustrating different coding patterns related to linked lists.  A rounded rectangle at the top, labeled 'Linked Lists,' serves as the root node, branching down with dashed lines to three subordinate categories.  The leftmost branch leads to a dashed-line rectangle labeled 'Traversal,' which further lists two sub-patterns: 'Linked List Intersection' and 'Palindrome Linked List.' The central branch connects to another dashed-line rectangle labeled 'Restructuring,' containing three sub-patterns: 'Linked List Reversal,' 'Remove the Kth Last Node From a Linked List,' and 'Flatten a Multi-Level Linked List.' Finally, the rightmost branch points to a dashed-line rectangle labeled 'Doubly Linked List,' which includes a single sub-pattern: 'LRU Cache.'  The diagram visually organizes various linked list coding problems based on their approach (traversal or restructuring) and the type of linked list (singly or doubly linked).

Linked List Reversal

Linked List Reversal

Reverse a singly linked list.

Example:

Image represents two directed acyclic graphs (DAGs) arranged vertically.  The top DAG consists of five nodes labeled 1, 2, 4, 7, and 3, connected sequentially by directed edges (arrows) in that order.  The bottom DAG also has five nodes, labeled 3, 7, 4, 2, and 1, connected sequentially by directed edges in that order. A thick, downward-pointing arrow connects the node labeled '4' in the top DAG to the node labeled '4' in the bottom DAG, indicating a transition or transformation between the two graphs.  The overall structure suggests a before-and-after representation, possibly illustrating a process or algorithm that rearranges the sequence of nodes.

Intuition - Iterative

A naive strategy is to store the values of the linked list in an array and reconstruct the linked list by traversing the array in reverse order. However, this solution does not reverse the original linked list; it just creates a new one. Could we try performing the reversal in place?

Let’s think about the problem in terms of pointer manipulation. The key observation here is that if we "flip" the direction of the pointers, we're effectively reversing the linked list:

Image represents a visual illustration of a sequence reversal. The top row, labeled 'original:', shows a directed acyclic graph (DAG) with nodes numbered 1, 2, 4, 7, and 3, connected by black arrows indicating a unidirectional flow from 1 to 2, 2 to 4, 4 to 7, and 7 to 3.  The second row, labeled 'reversed:', displays the same nodes but with orange arrows reversing the direction of flow, pointing from 3 to 7, 7 to 4, 4 to 2, and 2 to 1. The third row shows an equals sign followed by a sequence of nodes (3, 7, 4, 2, 1) connected by orange arrows, representing the reversed sequence in a forward direction, effectively demonstrating the reversed order of the original sequence.

Now, we just need to figure out how to perform this pointer manipulation. Consider the example below:

Image represents a simple directed acyclic graph (DAG) illustrating a sequential process or workflow.  The graph consists of three circular nodes, labeled '1,' '2,' and '3,' respectively, arranged linearly from left to right.  Each node likely represents a stage or step in a process.  Thick, unidirectional arrows connect the nodes, indicating the flow of information or execution.  Specifically, an arrow points from node '1' to node '2,' signifying that stage '2' follows stage '1,' and another arrow points from node '2' to node '3,' indicating that stage '3' follows stage '2.'  The overall structure depicts a linear progression where the output of one stage becomes the input for the subsequent stage.

To reverse the direction of all pointers, we iterate through the nodes one by one. In this process, we need access to the current node (curr_node) and the previous node (prev_node) to adjust the current node's next pointer to the previous node. Note that prev_node will initially point at null since the first node has no previous node:

Image represents a diagram illustrating a coding pattern, possibly related to linked lists or similar data structures.  Two rectangular boxes labeled 'prev_node' (in orange) and 'curr_node' (also in orange) are positioned above a sequence of three circles representing nodes numbered 1, 2, and 3.  Arrows descend from 'prev_node' and 'curr_node' pointing to 'null' and node 1 respectively.  Nodes 1, 2, and 3 are connected by unidirectional arrows indicating the next node in the sequence.  To the right, a dashed-line rectangle contains the code snippet 'curr_node.next = prev_node', suggesting an operation to modify the linked list structure by changing the 'next' pointer of the current node to point to the previous node.  The overall diagram depicts a step in an algorithm that manipulates a linked list, possibly involving reversing a portion of the list or inserting a node.
Image represents a linked list data structure illustrating a node deletion operation.  Two rectangular boxes labeled 'prev_node' and 'curr_node' are positioned above a sequence of circular nodes representing the list's elements. A downward arrow from 'prev_node' points to 'null1', indicating that the previous node is currently pointing to null.  A downward arrow from 'curr_node' points to a circular node containing the number '1'. This node '1' is connected to a node containing '2' by a light gray arrow crossed out with a large red 'X', signifying the deletion of the link between nodes 1 and 2. A solid black arrow connects node '2' to node '3', showing the remaining connection in the list. A light blue arrow points from node '1' to 'null1', indicating that the node '1' is now being removed from the list and its previous pointer is being updated to point to null.  The overall diagram visually depicts the steps involved in removing a node from a linked list, specifically updating the pointers to bypass the deleted node.

To reverse the next pointer, we'll need a way to shift the curr_node and prev_node pointers one node over. To shift prev_node, we can set it to the position of curr_node. However, we can't move curr_node to node 2 because we lost our reference to node 2:

Image represents a linked list data structure undergoing a deletion operation.  At the top, two orange rectangular boxes labeled `prev_node` and `curr_node` represent pointers.  A dashed orange line connects them, indicating a relationship between these pointers. A red 'X' is positioned to the right of `curr_node`, signifying the node to be deleted. Below, a linked list is depicted with three circular nodes containing the numbers 1, 2, and 3, respectively.  A solid black arrow points from node 1 to `null`, indicating the beginning of the list.  Another solid black arrow connects node 2 to node 3.  A red curved arrow points from node 1 to node 2, labeled 'no reference to node 2,' illustrating that after the deletion, node 1 no longer points to node 2, effectively removing node 2 from the list. The orange arrow from `prev_node` points to `null`, and the orange arrow from `curr_node` points to node 1, showing the pointers' positions before the deletion of node 2.

This suggests we should have preserved a reference to node 2 before reversing the curr_node. This can be done by creating a variable next_node and setting it to curr_node.next. Let’s assume we did this. Now, we can advance prev_node and curr_node forward by one:

Image represents a linked list data structure illustration.  Three orange rectangular boxes labeled 'prev_node', 'curr_node', and 'next_node' point downwards with arrows to three circular nodes numbered 1, 2, and 3 respectively.  Node 1 is connected to a 'null1' label on its left, indicating the beginning of the list.  Nodes 1, 2, and 3 are sequentially connected with arrows, showing the flow of the list.  A dashed-line rectangle to the right displays assignment statements: 'prev_node = curr_node' and 'curr_node = next_node', representing the iterative process of traversing the linked list, where the previous node becomes the current node, and the current node becomes the next node in each step.  The overall diagram visually depicts the concept of iterating through a linked list using pointers (prev_node, curr_node, next_node) and updating them during traversal.
Image represents a linked list data structure illustrating a traversal process.  Three circular nodes, labeled '1', '2', and '3', represent data elements within the list.  A solid arrow points from node '1' to node '2', and another from node '2' to node '3', indicating the sequential linking of elements.  The label 'null' precedes node '1', signifying the beginning of the list. Two rectangular boxes, labeled 'prev_node' and 'curr_node' in orange, are positioned above the nodes.  Dashed orange arrows connect 'prev_node' to node '1' and 'curr_node' to node '2', showing that 'prev_node' currently points to node '1' and 'curr_node' points to node '2', representing the current state during list traversal.  The dashed arrows illustrate the movement of these pointers during iteration through the list.

Note, we don't need to shift next_node, as it can be set by curr_node.next in the next iteration.

We can summarize this logic in three steps. At each node in the linked list:

  1. Save a reference to the next node (next_node = curr_node.next).
  2. Change the current node’s next pointer to link to the previous node (curr_node.next = prev_node).
  3. Move both prev_node and curr_node forward by one (prev_node = curr_node,curr_node = next_node).

Let's repeat these steps for the rest of the linked list:

Image represents a diagram illustrating a linked list reversal algorithm.  The left side shows a linked list with three nodes numbered 1, 2, and 3, connected by arrows indicating the direction of traversal.  Node 1 is pointed to by a label 'null', signifying the beginning of the list.  Above the nodes are two rectangular boxes labeled 'prev_node' and 'curr_node', with arrows pointing downwards to nodes 1 and 2 respectively. This indicates that these variables are currently referencing nodes 1 and 2 during the reversal process. Node 2 points to node 3. The right side contains a dashed-line box outlining two lines of pseudocode: '1) next_node = curr_node.next' and '2) curr_node.next = prev_node'. These lines describe the steps involved in reversing the links: the first line assigns the next node in the sequence to a temporary variable, and the second line reverses the link from the current node to point to the previous node, effectively reversing the list's direction.
Image represents a singly linked list data structure, illustrating the concept of traversing it in reverse. Three circular nodes, numbered 1, 2, and 3, represent data elements within the list.  A black arrow points from node 1 to `null`, indicating the beginning of the list. A light-blue arrow points from node 2 to node 1, showing the reverse traversal direction.  Black arrows descend from rectangular boxes labeled 'prev_node' (grey), 'curr_node' (grey), and 'next_node' (orange) to nodes 1, 2, and 3 respectively. These boxes represent pointers, indicating the current position and the previous and next nodes during the reverse traversal. The arrangement visually demonstrates how the pointers are updated to move backward through the list.
Image represents a linked list data structure illustrating a traversal algorithm.  Three circular nodes labeled '1', '2', and '3' are connected sequentially by arrows, representing the links between nodes in the list.  A left-pointing arrow connects the first node ('1') to 'null', indicating the beginning of the list. Above the nodes are three rectangular boxes labeled 'prev_node', 'curr_node', and 'next_node', respectively.  Arrows point downwards from these boxes to nodes '1', '2', and '3', respectively, showing how these variables might point to different nodes during traversal.  To the right, a dashed-line box contains pseudocode: '3) prev_node = curr_node; curr_node = next_node;', representing a step in an iterative algorithm that updates the 'prev_node' and 'curr_node' pointers to move through the list.  The numbered '3)' suggests this is the third step of a multi-step process.
Image represents a singly linked list data structure with three nodes labeled 1, 2, and 3.  The nodes are connected by solid arrows indicating the direction of the links, with node 1 pointing to node 2, and node 2 pointing to node 3.  A solid arrow points from node 1 to the left, labeled 'null', signifying the beginning of the list.  Above the nodes, two orange rectangular boxes are labeled 'prev_node' and 'curr_node'.  Dashed orange arrows connect these boxes to nodes 2 and 3 respectively, illustrating the pointers 'prev_node' and 'curr_node' that might be used in an algorithm iterating through the list.  The dashed arrow from 'prev_node' to node 2 shows that 'prev_node' currently points to node 2, and the dashed arrow from 'curr_node' to node 3 indicates that 'curr_node' points to node 3.  This visualization likely depicts a step in an algorithm, such as reversing a linked list or performing an insertion/deletion operation, where 'prev_node' and 'curr_node' track the current and previous nodes being processed.
Image represents a diagram illustrating a linked list reversal algorithm.  Three circular nodes labeled '1,' '2,' and '3' are depicted, connected sequentially by arrows representing links.  The first node ('1') is connected to the left by an arrow pointing to 'null,' indicating the beginning of the list.  Arrows from rectangular boxes labeled 'prev_node' and 'curr_node' point downwards to nodes '2' and '3' respectively, highlighting these nodes' roles in the algorithm. A dashed-line box to the right contains two numbered steps describing the algorithm: '1) next_node = curr_node.next' and '2) curr_node.next = prev_node,' which represent the core logic for reversing the links in the list.  The diagram visually shows the state of the list during a step in the reversal process, with 'prev_node' and 'curr_node' pointing to the nodes being manipulated to reverse the links.
The image represents a singly linked list data structure with three nodes, labeled 1, 2, and 3, visually depicted as circles containing their respective values.  Node 1 is connected to node 2 by a solid black arrow pointing from 1 to 2, indicating the direction of traversal. Node 2 is connected to node 3 by a light blue arrow pointing from 3 to 2, suggesting a reverse traversal or manipulation. Node 3 is connected to 'null' by a downward-pointing orange arrow, signifying the end of the list. Above the nodes, three rectangular boxes represent pointers: 'prev_node' (gray) points to node 2, 'curr_node' (gray) points to node 3, and 'next_node' (orange) points to 'null'.  The 'null' label appears at both the beginning and end of the list, indicating the absence of a node before the first and after the last. The arrangement shows the structure and potential manipulation of a linked list, highlighting the pointers used to navigate and modify the list.
Image represents a singly linked list with three nodes labeled 1, 2, and 3, respectively.  The list is depicted visually with circles representing nodes and arrows indicating the direction of links.  The first node (1) is pointed to by 'null', signifying the beginning of the list.  Each node points to the next node in the sequence; node 1 points to node 2, and node 2 points to node 3.  Node 3 points to 'null', indicating the end of the list. Above the list, three rectangular boxes labeled 'prev_node', 'curr_node', and 'next_node' are shown.  Arrows descend from these boxes to nodes 2, 3, and the 'null' after node 3, respectively, suggesting these labels represent pointers to nodes within the list during an iterative process. To the right, a dashed-line box contains pseudocode:  '3) prev_node = curr_node; curr_node = next_node', illustrating an algorithm step likely involved in traversing or manipulating the linked list, where the pointers are updated to move through the list.
Image represents a singly linked list with three nodes containing the values 1, 2, and 3, respectively.  The list is depicted visually with circles representing nodes and arrows indicating the direction of links.  The leftmost node (1) points to `null`, signifying the beginning of the list.  Each subsequent node points to the next node in the sequence, with node 2 pointing to node 3, and node 3 pointing to `null`, indicating the end of the list. Above the list, two orange rectangular boxes labeled 'prev_node' and 'curr_node' are shown.  Dashed orange arrows point from 'prev_node' to node 2 and from 'curr_node' to node 3, suggesting these variables are pointing to specific nodes within the list during some operation, likely a traversal or reversal.  The solid arrows represent the actual links within the linked list structure itself, while the dashed arrows represent pointers to nodes within the list from variables outside the list structure.

We can stop the reversal when curr_node becomes null, indicating there are no more nodes to reverse.

The final step is to return the head of the reversed linked list, which is pointed to by prev_node once curr_node becomes null.

Implementation - Iterative

python
from ds import ListNode
   
def linked_list_reversal(head: ListNode) -> ListNode:
    curr_node, prev_node = head, None
    # Reverse the direction of each node's pointer until 'curr_node' is null.
    while curr_node:
        next_node = curr_node.next
        curr_node.next = prev_node
        prev_node = curr_node
curr_node = next_node # 'prev_node' will be pointing at the head of the reversed linked list.
return prev_node

Complexity Analysis

Time complexity: The time complexity of linked_list_reversal is O(n)O(n)O(n), where nnn denotes the length of the linked list. This is because we perform constant-time pointer manipulation at each node of the linked list.

Space complexity: The space complexity is O(1)O(1)O(1).

Intuition - Recursive

Sometimes, the interviewer may want the problem solved using recursion. Let’s see what a recursive solution to this problem would look like.

In a recursive solution, the problem is solved by solving smaller instances of the same problem. To solve these smaller subproblems, we would need to use the linked list reversal function (linked_list_reversal) in its own implementation. The process of solving smaller problems using recursive calls continues until the smallest version of the problem is solved.

The smallest version of this problem involves reversing a linked list of size 0 or 1. These are linked lists which are inherently the same as their reverse. So, these can be our base cases.

With this in mind, let's try crafting the logic of the recursive function using the example below:

Image represents a linked list data structure.  A rectangular box labeled 'head' points downwards to a circle containing the number '1,' representing the first node in the list.  This node is connected by a unidirectional arrow to a circle containing '2,' which is similarly connected to a circle containing '3,' and finally '3' is connected to a circle containing '4,' the last node.  The arrows indicate the direction of traversal through the list, starting from the head node and progressing sequentially through each node to the end.  Each circle represents a node in the list, and the numbers within the circles could represent data stored within each node.  The structure visually demonstrates the linear, sequential nature of a singly linked list.

Think about which subproblem we should solve. To reverse the entire linked list, we can use our linked_list_reversal function to reverse the sublist after the current node. This way, we only need to focus on reversing the pointer of the current node. Let’s see how this works.

As mentioned, let’s first recursively call linked_list_reversal on the sublist starting at head.next. Let’s assume this recursive call reverses this sublist and returns its head as intended.

When designing a recursive function, assume any recursive call to that function will behave as intended, even if the function hasn’t been fully implemented.

Image represents a singly linked list data structure undergoing reversal.  A rectangular box encloses nodes numbered 2, 3, and 4, representing the linked list's initial state.  Arrows indicate the direction of links between these nodes, starting from node 2 and proceeding sequentially to node 4.  A separate node, labeled '1', is positioned to the left of the box, connected to node 2 via an arrow, indicating it's the head of the list.  Above node 1, a rectangular box labeled 'head' points downwards with an arrow towards node 1, showing the initial head pointer. Below the rectangular box containing nodes 2, 3, and 4, a dashed-line box contains the code snippet `new_head = linked_list_reversal(head.next)`. This line suggests that a function called `linked_list_reversal` is being called with the `next` pointer of the initial `head` (node 1) as an argument, and the result is assigned to `new_head`, implying the reversal operation is performed on the sublist starting from node 2.
Image represents a linked list data structure before and after an insertion operation.  A gray rectangular box labeled 'head' points to a circular node containing the number '1'. This node is connected via a black arrow to a node containing '2', which in turn is connected to a node containing '3' via a black arrow. Node '3' is connected to node '4' via a light-blue arrow, indicating the direction of the connection.  An orange rectangular box labeled 'new_head' points to node '4', showing that node '4' has become the new head of the linked list after an insertion operation. The light-blue arrow from node '4' to node '3' represents the newly created link, effectively inserting node '4' at the beginning of the list. The original list's head pointer has been updated to point to the newly inserted node '4'.

Next, we need the tail of the reversed sublist (node 2) to point to node 1. We can reference node 2 using head.next. So, all we need to do is set head.next.next to head as illustrated below:

Image represents a linked list data structure before and after inserting a new node.  Initially, a linked list is shown with nodes numbered 1, 2, and 3, where a rectangular box labeled 'head' points to node 1, indicating the beginning of the list.  Each node is a circle containing its numerical value, and arrows represent the `next` pointers connecting them sequentially. A new node, labeled '4', is introduced with a rectangular box labeled 'new_head' pointing to it.  An arrow connects 'new_head' to node 4.  Node 4 is then linked to node 3, reversing the previous order. A dashed rectangular box displays the code snippet 'head.next.next = head', illustrating the operation performed to insert node 4 at the head of the list by updating the `next` pointer of the second node (originally pointing to node 3) to now point to the original head (node 1).  This effectively inserts node 4 as the new head of the linked list.
Image represents a linked list data structure illustrating an insertion operation.  Two rectangular boxes labeled 'head' and 'new_head' represent pointers to the beginning of the list.  A downward arrow from 'head' points to a circular node labeled '1,' indicating it's the current head of the list.  Nodes '1,' '2,' and '3' are sequentially connected by unidirectional arrows, showing the order of elements. A black arrow from node '1' points to node '2', indicating the next element in the sequence. A light-blue curved arrow points from node '2' back to node '1', suggesting a potential back-link or additional connection. Node '3' is connected to node '4' by a unidirectional arrow. A downward arrow from 'new_head' points to node '4,' indicating that node '4' is about to be inserted. The overall structure depicts the state of the linked list before the insertion of node '4' at the end, with the light-blue arrow possibly representing a modification or pointer update during the insertion process.

The linked list is almost fully reversed now, but node 1 is still pointing to node 2. To remove this link, just set head.next to null. Then, we can return new_head, which is the head of the reversed linked list:

Image represents a linked list data structure before and after inserting a new node at the head.  Initially, a linked list is shown with nodes numbered 1, 2, and 3, connected sequentially. A rectangular box labeled 'head' points to node 1, indicating it's the head of the list.  A new node, labeled '4', is introduced, and a rectangular box labeled 'new_head' points to it.  An arrow from 'new_head' points to node 4, showing its insertion point. Node 4 is then connected to node 3 via a unidirectional arrow.  A dashed rectangular box displays the code snippet 'head.next = null', illustrating that the next pointer of the original head (node 1) is set to null after the insertion, implying that node 1 is no longer the head of the list.  Node 1 now has a bidirectional connection with node 2, suggesting a possible circular linked list structure within the original list.  The overall diagram depicts the process of prepending a new node to an existing linked list, modifying the head pointer to reflect the change.
Image represents a linked list data structure before and after insertion of a new node.  On the left, a linked list is shown with a `head` pointer pointing to a node containing the value `1`. This node is connected to a node with value `2`, which is connected to a node with value `3`.  A light-blue arrow indicates that the beginning of the list is `null`. On the right, a `new_head` pointer points to a new node containing the value `4`, which is inserted at the beginning of the list.  This new node is now connected to the previous head node (containing `1`). The previous head node (`1`) is now the second node in the list. A dashed-line box labeled 'return new_head' indicates that the function or operation returns the new head of the modified linked list, which is the node with value `4`.  The arrows represent the pointers connecting the nodes, showing the direction of the links within the list.

Implementation - Recursive

python
from ds import ListNode
   
def linked_list_reversal_recursive(head: ListNode) -> ListNode:
    # Base cases.
    if (not head) or (not head.next):
        return head
    # Recursively reverse the sublist starting at the next node.
    new_head = linked_list_reversal_recursive(head.next)
    # Connect the reversed sublist to the head node to fully reverse the entire linked list.
    head.next.next = head
    head.next = None
    return new_head

Complexity Analysis

Time complexity: The time complexity of linked_list_reversal_recursive is O(n)O(n)O(n) because it involves a single recursive traversal through the linked list, visiting each node exactly once.

Space complexity: The space complexity is O(n)O(n)O(n) due to the stack space taken up by the recursive call stack, which grows up to nnn levels deep because nnn recursive calls are made.

Interview Tip

Tip: Visualize pointer manipulations. Often, it can be tricky to figure out exactly what to do when dealing with linked list manipulation. Drawing pointers as arrows between nodes can be quite helpful. By observing how these arrows should be reoriented to represent changes in the linked list's structure, we can deduce the necessary pointer manipulation logic. This approach also helps identify which nodes we need references to when making these changes.

Remove the Kth Last Node From a Linked List

Remove the Kth Last Node From a Linked List

Return the head of a singly linked list after removing the kth node from the end of it.

Example:

The image represents a linked list data structure illustrating the concept of finding the k<sup>th</sup> to last element.  The top row shows a linked list with nodes containing the values 1, 2, 4, 7, and 3, connected by unidirectional arrows indicating the next element in the sequence.  A value 'k = 2' is displayed above, indicating that we are searching for the second to last element. The node containing '7' is highlighted in red and labeled 'k<sup>th</sup> last,' signifying its position as the second to last element in the list. A downward arrow points from the '4' node in the top row to the '4' node in the bottom row. The bottom row shows the same linked list after the removal of the last element (3).  The structure of the bottom row is identical to the top row except for the absence of the last node, demonstrating the process of iterating through the list to find the k<sup>th</sup> to last element.

Constraints:

  • The linked list contains at least one node.

Intuition

We can divide this problem into two objectives:

  1. Find the position of the kth last node.
  2. Remove this node.

Let’s first understand how node removal works. Consider the example below, where we need to remove node b. To do this, we need access to the node preceding it (node a), so we can redirect the pointer of node a to skip over node b. This ensures node b is no longer reachable through linked list traversal:

Image represents a linked list data structure illustrating the removal of a node.  A rectangular box labeled 'prev' points downwards with an arrow to a circular node labeled 'a'. Node 'a' connects via a rightward arrow to a highlighted, red-bordered circular node labeled 'b', which is further connected via a rightward arrow to a circular node labeled 'c'. A red curved arrow points from the text 'node to remove' (in red) to node 'b', indicating the node to be deleted.  To the right, a dashed-line rectangle contains the code snippet 'prev.next = prev.next.next', representing the algorithm to remove node 'b'. This code updates the 'next' pointer of the preceding node ('prev') to skip the node being removed ('b') and point directly to the following node ('c'), effectively removing 'b' from the list.
Image represents a state diagram illustrating a coding pattern, possibly related to state transitions or workflow.  A rectangular box labeled 'prev' points downwards with an arrow to a circle labeled 'a,' representing an initial state. A curved arrow connects circle 'a' to circle 'c,' indicating a direct transition from state 'a' to state 'c.'  A large red 'X' is overlaid on a light-red circle positioned between 'a' and 'c,' visually signifying that a direct transition between 'a' and 'c' is invalid or prohibited. A light-grey arrow points from the red 'X' to circle 'c,' suggesting an alternative, possibly indirect, path to reach state 'c' from 'a' that is not explicitly shown in the diagram but is implied to exist due to the grey arrow.

This indicates we need to find the node directly before the kth last node in order to remove it.

A naive solution to this problem is to first obtain the length of the linked list (n) by traversing it. Then, use this length to determine the number of steps required to arrive at the node before the kth last node, which is just n - k - 1 steps. This solution involves two for-loops, but is there a cleaner way to approach this problem?

The challenge with navigating a singly linked list in a single for-loop is that as we traverse, it’s hard to tell how far we are from the final node. The only way we’d know this is when we reach the final node itself, since its next node is null. How can we make use of this information?

Consider using two pointers instead of one. Could we create a scenario where, by the time one pointer reaches the end of the linked list, another pointer is positioned before the kth last node? Let’s explore this logic using the following example:

Image represents a directed graph illustrating a sequence of nodes connected by unidirectional arrows.  The nodes are represented by circles containing numerical values: 1, 2, 4, 7, and 3.  The node labeled '1' is designated as 'head'.  Arrows indicate the flow of information from one node to the next, starting from node 1 and proceeding sequentially to node 2, then node 4, then node 7, and finally node 3.  The graph is accompanied by the text ', k = 2', suggesting a parameter 'k' with a value of 2, likely relevant to the algorithm or process depicted by the graph's structure.  The overall structure suggests a linear progression or a simple linked list data structure, possibly demonstrating a specific coding pattern or algorithm step.

We denote the first pointer as leader and the pointer that follows it as trailer. When the leader pointer reaches the last node of the linked list, we want the trailer pointer to end up at node 4 (the node right before the kth last node) to prepare for deletion. In other words, the leader should be k nodes in front of the trailer when the leader reaches the last node.

Image represents a linked list data structure illustrating a sliding window pattern with a window size (k) of 2.  The list consists of nodes numbered 1 through 7, with node 1 labeled 'head,' indicating the beginning of the list.  Nodes are connected by unidirectional arrows showing the sequence. A light gray shaded node 7 is present.  A blue box labeled 'trailer' points down to node 4, and an orange box labeled 'leader' points down to node 3. A gray line segment underlines nodes 4 and 7, with 'k = 2' written below it, indicating the window size. The arrows and node arrangement visually depict the movement of a window of size 2 across the linked list, where the 'trailer' marks the window's start and the 'leader' marks its end.  The window's position is dynamically updated as the leader advances through the list.

To achieve this, we can start by advancing the leader pointer through the linked list for k steps. When the leader pointer is k nodes ahead of the trailer, we can advance both pointers together until the leader reaches the last node. This process will be explained in more detail soon.

However, there’s an important edge case to consider first: what if the head itself is the node we need to remove? In this case, there’s no node before the head, so we cannot perform the removal, as mentioned earlier. To circumvent this, we can create a dummy node, place it before the head node, and start our traversal from there.

Image represents a linked list data structure illustrating a coding pattern.  Two rectangular boxes labeled 'trailer' and 'leader' point downwards with arrows to a light gray circle labeled 'D,' representing a detached node.  A dark gray arrow extends from 'D' to a dark circle labeled '1' and marked 'head,' indicating the beginning of the main list.  This 'head' node (1) is connected via dark arrows to subsequent nodes labeled '2,' '4,' '7,' and finally '3.'  The arrows show the unidirectional flow of information or traversal through the list, starting from the head (1) and progressing sequentially to the tail (3). The overall structure depicts a singly linked list with a detached node, highlighting how nodes can be added or removed from the list.

Let’s now try incorporating our strategy into the example.

First, advance the leader pointer k (2) times so it’s k nodes ahead of the trailer pointer:

Image represents a linked list data structure illustrating a coding pattern.  A gray, circular node labeled 'D' represents the trailer, connected by a solid arrow to a black, circular node labeled '1', which is labeled 'head' below it.  Node '1' is connected to node '2' by a solid arrow.  Node '2' is connected to node '4' by a solid arrow. Node '4' is connected to node '7' by a solid arrow. Finally, node '7' is connected to node '3' by a solid arrow.  A rectangular box labeled 'leader' in orange is positioned above node '2', with a solid arrow pointing down to it, indicating a pointer to this node.  Dashed orange arrows connect the 'trailer' node to node '1' and node '1' to node '2', showing additional pointers or connections. A gray line segment below the nodes 'D' and '2' is labeled 'k = 2', indicating a distance or window size of 2 between the trailer and the leader.  The overall structure depicts a linked list with a trailer, head, and a leader pointer positioned k nodes ahead of the head.

With the leader k nodes ahead, we can move both the trailer and leader pointers until the leader reaches the last node:

Image represents a linked list data structure illustrating the 'Leader-Follower' coding pattern.  The list is composed of circular nodes numbered 1 through 7, and a detached node 'D'.  Solid arrows indicate the primary forward links within the list, progressing from node 1 to 2, 2 to 4, 4 to 7, and finally 7 to 3. Node 1 is labeled 'head', signifying the starting point of the list's traversal. A light gray node 'D' is connected to node 1 via a solid gray arrow, representing a data element added to the list.  A rectangular box labeled 'trailer' in light blue is connected to node 1 via a dashed light blue arrow, indicating a pointer to the tail of the list.  Separately, a rectangular box labeled 'leader' in orange is connected to node 4 via a dashed orange arrow, highlighting a separate pointer that points to a specific node within the list, demonstrating the 'leader' aspect of the pattern.  The arrangement shows how both a head and a leader pointer can be used to efficiently manage and access elements within the linked list.
Image represents a linked list data structure illustrating the Leader-Follower pattern.  A gray circle labeled 'D' represents the data source, connected via a solid gray arrow to a black circle labeled '1' and marked 'head'.  Black circles numbered '1', '2', '4', '7', and '3' sequentially connect via solid black arrows, representing the nodes of the linked list. A light-blue rectangle labeled 'trailer' is positioned above node '2', connected to it by a dashed light-blue arrow, indicating a pointer to the trailer node.  Similarly, an orange rectangle labeled 'leader' is positioned above node '7', connected to it by a dashed orange arrow, showing a pointer to the leader node. The dashed arrows represent pointers that are not part of the main linked list structure but point to specific nodes for tracking purposes within the Leader-Follower pattern.  The overall structure shows the flow of data from 'D' through the linked list, with the 'trailer' and 'leader' pointers tracking specific nodes within the list.
Image represents a linked list data structure illustrating the concept of pointers and multiple linked lists.  A primary linked list is shown with nodes numbered 1 through 7, connected sequentially by solid black arrows. Node 1 is labeled 'head,' indicating the beginning of the list. A grey circle labeled 'D' precedes node 1, suggesting a detached or disconnected element. A light-blue rectangle labeled 'trailer' points to node 4 via a solid light-blue arrow, indicating a secondary pointer to a specific node within the main list.  Separately, an orange rectangle labeled 'leader' points to node 3 via a solid orange arrow, representing another independent pointer to a different node in the main list.  Dashed light-blue arrows connect node 2 to node 4, and dashed orange arrows connect node 7 to node 3, illustrating additional, non-sequential connections or pointers within the list, possibly representing alternative traversal paths or additional data structures linked to the main list.

With the trailer pointer at the ideal position, we can remove node 7:

Image represents a linked list data structure undergoing an insertion operation.  A singly linked list is depicted with nodes numbered 1 through 4, initially connected sequentially. Node 1 is labeled 'head,' indicating the beginning of the list. A grey node labeled 'D' precedes node 1, representing a dummy node. A new node containing the value 7 is highlighted in red, indicating its insertion point.  A light-blue box labeled 'trailer' points to node 4, representing a pointer to the node before the insertion point. A grey box labeled 'leader' points to node 3, representing a pointer to the node after the insertion point.  Arrows indicate the direction of the links between nodes. A dashed grey box contains the code snippet 'trailer.next = trailer.next.next,' illustrating the algorithm for inserting node 7: the 'next' pointer of the node before the insertion point (trailer) is updated to point to the node after the insertion point (trailer.next.next), effectively inserting the new node 7 into the list.
Image represents a diagram illustrating a coding pattern, possibly related to linked lists or data structures.  The diagram shows a sequence of numbered circles (1, 2, 4, 3) representing nodes, connected by arrows indicating a directional flow. A grey circle labeled 'D' precedes node 1, with a grey arrow pointing to it, suggesting an initial data source. Node 1 is labeled 'head', indicating the beginning of the sequence.  A light blue rectangle labeled 'trailer' points to node 4, suggesting a pointer to the end of the sequence. A black arrow connects node 4 to node 3, bypassing a crossed-out circle, indicating a skipped or removed element. A separate black rectangle labeled 'leader' points to node 3, suggesting an alternative or overriding pointer to a specific node. The overall structure depicts a linked list with a head, a trailer, and a potential modification or re-ordering of elements, possibly through a 'leader' pointer bypassing a deleted node.

After this removal, we just return dummy.next, which points at the head of the modified linked list.

Implementation

python
from ds import ListNode
    
def remove_kth_last_node(head: ListNode, k: int) -> ListNode:
    # A dummy node to ensure there's a node before 'head' in case we need to remove
    # the head node.
    dummy = ListNode(-1)
    dummy.next = head
    trailer = leader = dummy
    # Advance 'leader' k steps ahead.
    for _ in range(k):
        leader = leader.next
        # If k is larger than the length of the linked list, no node needs to be
        # removed.
        if not leader:
            return head
    # Move 'leader' to the end of the linked list, keeping 'trailer' k nodes behind.
    while leader.next:
        leader = leader.next
        trailer = trailer.next
    # Remove the kth node from the end.
    trailer.next = trailer.next.next
    return dummy.next

Complexity Analysis

Time complexity: The time complexity of remove_kth_last_node is O(n)O(n)O(n). This is because the algorithm first traverses at most nnn nodes of the linked list, and then two pointers traverse the linked list at most once each.

Space complexity: The space complexity is O(1)O(1)O(1).

Linked List Intersection

Linked List Intersection

Return the node where two singly linked lists intersect. If the linked lists don't intersect, return null.

Example:

The image represents a directed acyclic graph (DAG) illustrating two distinct paths, labeled 'A:' and 'B:'.  Path 'A:' starts with a node labeled '1', which connects to a node labeled '3', followed by a node labeled '4'. Node '4' then branches, connecting to a node labeled '8'. Path 'B:' begins with a node labeled '6', connecting to another node labeled '4'. This second '4' node also connects to node '8'. Node '8' subsequently connects to a node labeled '7', which finally connects to a node labeled '2'.  The arrows indicate the direction of flow between the nodes, showing a sequential progression along each path.  The paths 'A' and 'B' represent different sequences or processes, converging at node '8' before diverging again.  The numbers within the nodes likely represent steps or stages in a process, and the overall structure suggests a workflow or dependency diagram.
python
Output: Node 8

Intuition

Let’s first understand what an intersection between two linked lists is.

An intersection occurs when two linked lists converge at a shared node and, from that point onwards, share all subsequent nodes.

Note, this intersection has nothing to do with the values of the nodes.

A naive approach is to use a hash set. We can traverse the first linked list once and store each node in a hash set. Next, we traverse the second linked list until we find the first node that exists in the hash set, signifying the intersection point since it’s the first node shared between the two linked lists. This approach solves the problem linearly, but can we find a solution that uses constant space?

Consider the following example:

Image represents a diagram illustrating a merge operation in a linked list data structure.  Two linked lists, labeled 'head_A' and 'head_B,' are depicted.  List 'head_A' consists of three light-blue nodes connected sequentially by unidirectional arrows, indicating the flow of data. Similarly, list 'head_B' comprises two light-purple nodes linked in the same manner.  The final nodes of both lists 'head_A' and 'head_B' converge at a common node, the first of a new linked list represented by three orange nodes. These orange nodes are also connected sequentially with unidirectional arrows. The arrows visually represent the pointers connecting the nodes in each list, showing the direction of traversal. The overall structure demonstrates how two separate linked lists are merged into a single, unified list.

Treating these as two separate linked lists can get confusing with the above visualization. Instead, let’s visualize the input as two linked lists to help us think about the problem more clearly. Note that the tail nodes are still shared between the two linked lists, we’re just visualizing them separately:

The image represents two linked lists merging into a single structure.  Two lists, labeled `head_A` and `head_B`, are depicted using light-blue and light-purple circles respectively, representing nodes, connected by black arrows indicating the direction of traversal.  Each list consists of three nodes.  These lists converge at a point where they share a common set of nodes, represented by three peach-colored circles enclosed within an orange rectangle.  These shared nodes are labeled as 'same tail nodes' via a curved arrow pointing to the rectangle. The arrangement visually demonstrates how two separate linked lists can share a common tail section, a concept relevant to data structure optimization and merging algorithms.

Notice that this problem is easier to solve if the two linked lists are of equal length. This is because the intersection node can be found at the same position from the heads of both linked lists. In other words, when we iterate through two linked lists of the same length, we’re guaranteed to reach the intersection node at the same time:

Image represents two linked lists, labeled 'A' and 'B', visually separated by a peach-colored rectangle containing the symbol '||' signifying a parallel structure.  List 'A' uses light-blue nodes, while list 'B' uses light-purple nodes. Each list starts with a labeled head node ('head_A' and 'head_B'), pointed to by a rectangular box labeled 'ptr_A' and 'ptr_B' respectively, indicating pointers to the beginning of each list.  Solid arrows show the sequential connections within each list. Dashed gray arrows indicate additional pointers, possibly for iterative traversal or other operations, connecting nodes within each list.  Both lists converge at a point represented by orange nodes, forming a common section after the initial distinct parts.  The orange nodes continue in a linked list structure, suggesting a merging or concatenation of the two initial lists.

Could we somehow replicate this behavior when dealing with linked lists of varying lengths? The key observation is that, while two linked lists ‘list A’ and ‘list B’ may have different lengths, ‘list A → list B’ has the same length as ‘list B → list A’ (where ‘→’ represents the connection of two lists). Conveniently, these combined linked lists also share the same tail nodes:

Image represents two linked lists, A and B, initially separate, depicted as horizontal sequences of circles connected by arrows indicating direction.  List A starts with a light-blue circle labeled 'head_A' and continues with several light-blue and orange circles. List B begins with a light-purple circle labeled 'head_B' and proceeds with light-purple and orange circles.  The lists merge; the tail of list A connects to a light-blue circle, which is then followed by several more light-blue circles, and the tail of list B connects to a light-purple circle, followed by several more light-purple circles.  After the merge, a rectangular box encloses a section of the combined lists, containing four orange circles arranged in two rows of two, representing a sub-section of the merged list.  The labels 'A' and 'B' are placed above and below the initial sections of lists A and B respectively, indicating their initial separation, and are reversed below the merged section, indicating the merging point.  Arrows consistently point rightward, showing the direction of traversal through each list.

We’ve now set up a scenario where we have two combined linked lists of the same length, which share the same tail nodes. By traversing these combined linked lists, we’ll eventually reach the intersection node simultaneously on both linked lists (if one exists).

To do this, we can traverse both combined linked lists with two pointers, and stop once the nodes at both pointers are the same. This node would be the intersection node:

Image represents two linked lists, A and B, visualized horizontally.  List A's nodes are light blue, while list B's nodes are light purple.  Both lists also contain orange nodes interspersed.  A rectangular box labeled 'ptr_A' points down to the head of list A, labeled 'head_A,' which is a light blue circle.  Similarly, a box labeled 'ptr_B' points to the head of list B, 'head_B,' a light purple circle.  Solid arrows indicate the forward links within each list. Dashed gray arrows show how the algorithm iterates through both lists simultaneously, comparing nodes.  The lists merge at a point where list A's orange node connects to list B's purple node, and vice-versa.  After the merging point, the lists continue with their respective node colors. A peach-colored rectangle separates the merged portion from the rest of the lists. A solid arrow emerges from this rectangle, pointing to a dashed-line box labeled 'return ptr_A,' indicating that the algorithm returns a pointer to a node within list A after the merging process.

If no intersection exists, both pointers will end up stopping at null nodes:

Image represents two linked lists, A and B, visualized horizontally.  List A's head is labeled 'head_A' and is a light-blue circle; list B's head is labeled 'head_B' and is a purple circle.  Each list consists of several nodes represented by similarly colored circles connected by solid arrows indicating the next node in the sequence.  Above each list's head is a rectangular box labeled 'ptr_A' and 'ptr_B' respectively, representing pointers to the respective list heads.  Dashed arrows from these pointer boxes point to the respective list heads, showing the pointer's initial position.  Both lists terminate with a 'null' node represented by a light-grey rectangle.  After the 'null' nodes, a solid arrow points to a dashed-line rectangle labeled 'return ptr_A', indicating that the function returns a pointer to the head of list A.  The dashed arrows between the nodes in each list suggest the traversal of the list during the execution of the algorithm.  The arrangement shows the lists' structure and the pointer's role in accessing and manipulating the lists.

Traversing combined linked lists An important observation is that to traverse through 'list A → list B', we don't actually need to connect these two linked lists together. Instead, we can traverse 'list A' and, upon reaching its end, continue by traversing 'list B':

Image represents a diagram illustrating the traversal of two linked lists, A and B.  The top section, labeled '1. traverse list A,' shows a linked list A depicted as a sequence of four light-blue circles connected by solid black arrows, representing the nodes and their connections.  The first node is labeled 'head_A'.  Dashed grey arrows curve above the solid arrows, indicating the traversal process. The bottom section, labeled '2. traverse list B,' similarly shows a linked list B with three light-purple circles followed by three peach-colored circles, connected by solid black arrows. The first node is labeled 'head_B,' and dashed grey arrows above the solid arrows again show the traversal.  The title 'traverse list A → list B:' indicates that the diagram demonstrates a process involving traversing both lists, possibly for comparison or data transfer between them.  The color change in list B suggests a potential modification or interaction during the traversal.

This technique allows us to traverse the entire sequence of both linked lists as if they were connected.

Implementation

python
from ds import ListNode
   
def linked_list_intersection(head_A: ListNode, head_B: ListNode) -> ListNode:
    ptr_A, ptr_B = head_A, head_B
    # Traverse through list A with 'ptr_A' and list B with 'ptr_B' until they meet.
    while ptr_A != ptr_B:
        # Traverse list A -> list B by first traversing 'ptr_A' and then, upon
        # reaching the end of list A, continue the traversal from the head of list B.
        ptr_A = ptr_A.next if ptr_A else head_B
        # Simultaneously, traverse list B -> list A.
        ptr_B = ptr_B.next if ptr_B else head_A
    # At this point, 'ptr_A' and 'ptr_B' either point to the intersection node or both
    # are null if the lists do not intersect. Return either pointer.
    return ptr_A

Complexity Analysis

Time complexity: The time complexity of linked_list_intersection is O(n+m)O(n+m)O(n+m), where nnn and mmm denote the lengths of list A and B, respectively. This is because pointers linearly traverse both linked lists sequentially.

Space complexity: The space complexity is O(1)O(1)O(1).

LRU Cache

LRU Cache

Design and implement a data structure for the Least Recently Used (LRU) cache that supports the following operations:

  • LRUCache(capacity: int): Initialize an LRU cache with the specified capacity.
  • get(key: int) -> int: Return the value associated with a key. Return -1 if the key doesn't exist.
  • put(key: int, value: int) -> None: Add a key and its value to the cache. If adding the key would result in the cache exceeding its size capacity, evict the least recently used element. If the key already exists in the cache, update its value.

Example:

python
Input: [
  put(1, 100),
  put(2, 250),
  get(2),
  put(4, 300),
  put(3, 200),
  get(4),
  get(1),
],
  capacity = 3
Output: [250, 300, -1]

Explanation:

python
put(1, 100)  # cache is[1: 100]
put(2, 250)  # cache is[1: 100, 2: 250]
get(2)       # return 250
put(4, 300)  # cache is[1: 100, 2: 250, 4: 300]t
put(3, 200)  # cache is[2: 250, 4: 300, 3: 200]
get(4)       # return 300
get(1)       # key 1 was evicted when adding key 3 due to the capacity
             # limit: return -1

Constraints:

  • All keys and values are positive integers.
  • The cache capacity is positive.

Intuition

When presented with a design problem, the first steps usually involve understanding the problem and deciding which data structures to use. Let's start by understanding how an LRU cache works at a high level.

Consider the LRU cache described below. It currently holds 3 elements and has reached full capacity. Assume that in this representation, the key-value pairs are ordered from the least recently used (left) to the most recently used (right):

Image represents a visual depiction of a Least Recently Used (LRU) cache with a capacity of 3.  The main component is a rectangular box representing the cache, divided into three smaller rectangular cells. Each cell contains a pair of numbers; the first represents a key (1, 2, or 4), and the second represents an associated value (100, 250, or 300).  The cells are arranged horizontally within the larger cache box, with the cell containing '1 | 100' on the left, representing the least recently used item, and the cell containing '4 | 300' on the right, representing the most recently used item.  An arrow extends from the left edge of the cache box to the right, labeled 'least recently used' on the left and 'most recently used' on the right, explicitly indicating the order of usage within the cache.  The text 'capacity = 3' above the cache box specifies the maximum number of key-value pairs the cache can hold.

Let's try putting a new key-value pair into the cache:

The image represents a data structure, likely a fixed-size array or buffer, with a capacity of 3.  On the left, an input element, depicted as an orange rectangle labeled 'put (3 | 200)', shows a key-value pair where '3' is the key and '200' is the value. A rightward arrow indicates the insertion of this key-value pair into the data structure. The data structure itself is represented by a light gray rectangle labeled 'capacity = 3' at the top. Inside this rectangle are three smaller, similarly styled rectangles, each representing an element within the structure. These elements contain key-value pairs: the first is '1 | 100', the second is '2 | 250', and the third is '4 | 300'.  A horizontal arrow beneath the data structure suggests the possibility of further operations or data flow from the structure. The overall diagram illustrates the process of adding a new key-value pair to a pre-existing data structure with a limited capacity.

This new pair would effectively be the most recent in the cache, so we know it should be added at the most-recently-used end of the cache. Since the cache is currently at maximum capacity, we need to make room for the new pair by first evicting the least recently used pair:

Image represents a visual depiction of a cache eviction and insertion process.  A light gray rectangular box, representing a cache, contains three key-value pairs: '2 | 250', '4 | 300', and '3 | 200', each enclosed in a smaller rectangle.  The pair '3 | 200' is highlighted with an orange border, indicating it's a newly added element. To the left, a gray rectangle containing '1 | 100' is marked with a large red 'X', signifying its removal from the cache. A curved gray arrow connects the text '1. evict the least recently used pair' to the '1 | 100' pair, illustrating the eviction process.  A similar curved gray arrow connects the text '2. add new pair' to the '3 | 200' pair, showing the addition of a new element. A horizontal arrow beneath the cache box indicates the potential for further operations or a continuous cycle of eviction and insertion.

From this high-level overview, we can summarize operations we need to implement the put function:

  1. Remove a key-value pair from the least recently used end of the cache.
  2. Add a key-value pair to the most recently used end of the cache.

Now, let's try retrieving a value from this example cache. If we perform get(2), we expect it to return 250. Accessing this pair would effectively make it the most recently used pair. So, we should move it to the most recently used end of the cache:

Image represents a data structure, likely a Least Recently Used (LRU) cache, illustrating a `get(2)` operation.  A light gray rectangular box encloses three smaller boxes, each representing a cache entry.  These entries contain key-value pairs; the first box, highlighted in orange, shows '2 | 250,' indicating key '2' and value '250'. The other boxes show '4 | 300' and '3 | 200'. An arrow labeled 'get(2)' points to the cache.  A downward arrow from the right side of the container indicates retrieval.  A line connects the top of the orange box to a label above reading 'move to most recently used end,' illustrating that after retrieving the entry with key '2', it's moved to the end of the cache, maintaining the LRU order. A horizontal arrow at the bottom of the container shows the data structure after the operation, with the '2 | 250' entry now at the right end.
Image represents a data structure, possibly an array or list, containing three key-value pairs.  Each pair is depicted as a rectangular box with a number (key) separated by a vertical line from a larger number (value). The boxes are arranged horizontally within a larger, light-grey container, indicated by a dashed border.  The first box shows '4 | 300', the second '3 | 200', and the third, highlighted with an orange border, shows '2 | 250'. A solid arrow extends from the right side of the container to a dashed-bordered box labeled 'return 250', indicating that the value associated with the key '2' (250) is being returned as the output of some operation or function.  The visual suggests a process of selecting or accessing a specific element (the one with key '2') from the data structure.

From this example, we identified two key operations for the get function:

  1. Move a key-value pair to the most recent end of the cache.
  2. Access a value using its key.

We’ve now narrowed the design down to the four main operations listed above. These will help us identify which data structures we can employ to design the LRU cache.

Choosing data structures Operations 1 and 2‾\underline{\text{Operations 1 and 2}}Operations 1 and 2​

The first two operations involve adding and removing key-value pairs. Specifically, we need the ability to remove a key-value pair from one end of a data structure (representing the least recently used end) and add a key-value pair to the other.

Which data structure allows us to efficiently add or remove an element from it? A suitable data structure for these operations is a linked list, particularly because we can add and remove a node in constant time if we have a reference to that node. But should we use a singly or doubly linked list?

Singly vs. doubly linked list‾\underline{\text{Singly vs. doubly linked list}}Singly vs. doubly linked list​

Adding or removing a node from the head of a linked list takes O(1)O(1)O(1) time, whether it’s a singly or doubly linked list. However, removing the tail node from a singly linked list takes O(n)O(n)O(n) time, even with a reference to the tail, because we need to traverse the list to access the node before the tail. In contrast, a doubly linked list allows O(1)O(1)O(1) removal of the tail because each node has a reference to its previous node, enabling direct access without traversal. So, let’s choose the doubly linked list.

An important feature we need is the ability to access both ends of the doubly linked list when adding or removing nodes. With this in mind, let's establish some definitions:

  • The tail of the linked list signifies the most frequently used node.
  • The head of the linked list signifies the least recently used node.

To reference the ends of the linked list, we can establish head and tail nodes, where head points to the least recently used node, and tail points to the most recently used node:

The image represents a circular buffer data structure with a capacity of 3.  The structure is depicted as a sequence of three rectangular boxes connected by curved arrows, representing the buffer's elements. Each box contains two numbers separated by a vertical bar; the first number represents the index of the element within the buffer (starting from 1), and the second number represents the data value stored at that index.  Specifically, the boxes show elements at indices 1 (containing data value 100), 2 (containing data value 250), and 4 (containing data value 300).  To the leftmost box is a rectangular box labeled 'head', indicating the beginning of the buffer, and to the rightmost box is a rectangular box labeled 'tail', indicating the end of the buffer.  Gray curved arrows connect the 'head' to the first element, each element to the next, and the last element to the 'tail', illustrating the circular nature of the buffer; data would wrap around from the 'tail' back to the 'head' if the buffer were full.  Above the structure, the text 'capacity = 3' explicitly states the buffer's maximum size.

Operations 3 and 4‾\underline{\text{Operations 3 and 4}}Operations 3 and 4​

Operation 3 indicates that we’ll need to be able to move a node to the most recently-used end of the cache, and that this node doesn’t necessarily need to be at the head or tail of the linked list. If this node was somewhere in the middle, we’d need to traverse the linked list to find it. Is there a way we could access this node in O(1)O(1)O(1) time? Since this node is associated with a key, we could use a hash map to store key-node pairs. This allows us to access a node by its key in constant time. The diagram below illustrates how the hash map's values are references to nodes in the linked list:

Image represents a doubly linked list visualized alongside a hashmap.  The linked list consists of rectangular nodes connected by bidirectional arrows.  The nodes are labeled sequentially: 'head', then three data nodes containing key-value pairs (1|100, 2|250, 4|300), and finally 'tail'.  Each data node shows a key and a value separated by a vertical bar.  The 'head' and 'tail' nodes represent the beginning and end of the list, respectively.  Below the linked list, a hashmap is depicted, showing three key-value pairs: (key: 4, value: ●), (key: 1, value: ○), and (key: 2, value: ●).  The values are represented by colored dots (magenta, cyan, and magenta respectively). Dotted lines connect each node in the linked list to its corresponding key-value pair in the hashmap, illustrating a potential mapping between the list's data and the hashmap's entries.  The lines are colored to match the values in the hashmap.  The magenta dashed lines connect the nodes with magenta dots, and the cyan dotted line connects the node with the cyan dot.

Using a hash map also addresses operation 4 regarding efficient access to values from their keys.

Now we’ve decided on using a doubly linked list and a hash map to represent the LRU cache, let’s examine how the put and get functions would be implemented.

put(key: int, val: int) -> None: Below is the flow for adding a new key-value pair to the cache. This involves correctly updating the linked list and the hash map, while ensuring the cache does not exceed its capacity:

Image represents a flowchart depicting the logic for adding a key-value pair to a cache implemented using a hash map and a linked list (likely an LRU cache).  The process begins by checking if the key already exists in the cache via a hash map query. If 'yes,' the existing node is removed from the linked list and then re-added as the most recently used node at the head of the linked list.  If 'no,' the flowchart checks if adding a new node would exceed the cache's capacity. If 'yes,' the least recently used node is removed from both the linked list and the hash map. If 'no,' the new node is added as the most recently used node to the linked list and also added to the hash map.  The flowchart uses orange arrows to indicate the flow of control based on the 'yes' or 'no' answers to the conditional checks.  Each box contains a description of the action to be performed at that step.

To better understand how to add a new node to a doubly linked list that’s at maximum capacity, check out the following example:

Image represents a visualization of a cache replacement algorithm, specifically illustrating a Least Recently Used (LRU) strategy.  The top section shows an initial state where a doubly linked list represents the cache.  Nodes labeled `1 | 00`, `2 | 250`, and `4 | 300` are linked together, with `head` pointing to the beginning and `tail` pointing to the end.  The input `put (3 | 200)` is given, and since the cache is full, the `remove_node(head.next)` function is called, removing node `1 | 00` (indicated by a red cross).  A dotted line shows the connection between `remove_node` and the removed node. The middle section shows the cache after the removal, with only `2 | 250` and `4 | 300` remaining. The bottom section depicts the `add_to_tail(3 | 200)` function adding the new node `3 | 200` to the end of the list, updating the `tail` pointer.  The entire process demonstrates how the LRU algorithm maintains a fixed-size cache by removing the least recently used element when a new element needs to be added.  The numbers within the nodes likely represent keys and values, respectively.

As you can see, we’ll need a function to remove a node (remove_node), as well as a function to add a node to the tail of the linked list (add_to_tail). We discuss these functions in more detail in the implementation section.

get(key: int) -> int: Below is the process for retrieving a key's value from the cache:

Image represents a flowchart depicting a cache lookup algorithm.  The process begins with a decision box asking 'Is the key in the cache? Check by querying the hash map.'  An orange arrow branches from this box, leading to two rectangular boxes representing the possible outcomes: 'yes' and 'no'. If the key is found ('yes'), the algorithm proceeds to a larger rectangular box detailing the steps to update the cache:  1. Remove the node associated with the key; 2. Add the node back as the most recently used node in a linked list.  From this box, another orange arrow points to a final rectangular box that states 'Return the value associated with this node.'  If the key is not found ('no'), an orange arrow leads to a smaller rectangular box that simply states 'Return -1', indicating a cache miss.  The overall structure illustrates a Least Recently Used (LRU) cache implementation, using a hash map for efficient key lookups and a linked list to maintain the order of recently used items.

Now let’s take a look at an example of how the doubly linked list is updated during a get function call:

Image represents a visual depiction of a `get(2)` operation on a doubly linked list, implemented using a hashmap for efficient node access.  The top section shows the initial state: a doubly linked list with nodes labeled '2 | 250', '4 | 300', and '3 | 200', connected by bidirectional arrows indicating `next` and `prev` pointers.  'head' and 'tail' labels point to the beginning and end of the list, respectively.  A dashed arrow points from `remove_node(hashmap[2])` to node '2 | 250', which is marked with a red 'X', signifying its removal. The middle section illustrates the removal of node '2 | 250'. The bottom section shows the node '2 | 250' re-added to the tail of the list via `add_to_tail(2 | 250)`, indicated by a dashed arrow. Finally, a thick black arrow at the bottom points to 'return 250', indicating the value returned by the `get(2)` operation after the node has been temporarily removed and then re-added to the tail.  Each node displays a key-value pair (e.g., '2 | 250'), representing the key used in the hashmap and the associated value. The overall diagram demonstrates the process of retrieving a node's value, removing it, and then re-inserting it at the tail to maintain the list's structure.

Now that we understand how the doubly linked list and hash map are used to design the LRU cache, let's dive into its implementation details, including the details of the helper methods remove_node and add_to_tail.

Implementation

We can use the custom class below to represent a node in a doubly linked list:

python
class DoublyLinkedListNode:
   def __init__(self, key: int, val: int):
       self.key = key
       self.val = val
       self.next = self.prev = None

The example below illustrates how to add a node to the tail of the linked list. Let's refer to the node before the tail as prev_node.

Image represents a doubly linked list data structure undergoing an addition operation.  The list visually shows three nodes, each represented by a light gray rectangle containing a number pair. The first node, labeled 'head,' contains no number pair. The second node shows '2 | 250,' indicating a value of 250 associated with the node 2. The third node, labeled 'prev_node,' displays '4 | 300,' representing a value of 300 associated with node 4.  These nodes are interconnected by bidirectional curved arrows, signifying the links between nodes in a doubly linked list. A fourth node, represented by an orange rectangle with rounded corners, shows '3 | 200' and is labeled `add_to_tail(3 | 200)`. A dashed arrow points from this node to the current 'tail' node, indicating that this node (3 | 200) is about to be appended to the end of the list. The 'tail' node is a light gray rectangle labeled 'tail,' representing the current end of the list.  The overall diagram illustrates the process of adding a new node to the tail of a doubly linked list.

The new node should appear after prev_node and before the tail node. Let’s set the new node’s prev and next pointers to reflect this:

Image represents a doubly linked list data structure.  The list is composed of rectangular nodes, each containing two values separated by a vertical bar '|'.  The first value represents a node's identifier (an integer), and the second represents its data (also an integer).  The nodes are labeled sequentially: 'head' (a light gray node with no identifier or data), '2 | 250', '4 | 300', and 'tail' (a light gray node with no identifier or data).  Node '3 | 200' is highlighted with an orange border, suggesting a special status or selection.  Curved bidirectional arrows connect adjacent nodes, indicating that each node points to both its predecessor and successor.  The arrow below node '4 | 300' is labeled 'prev_node', explicitly showing the backward pointer.  The overall structure demonstrates the interconnectedness of nodes in a doubly linked list, allowing traversal in both directions.

Now connect prev_node and tail to the new node:

Image represents a doubly linked list data structure.  The list is composed of rectangular nodes, each containing two values separated by a vertical bar '|'.  The first value represents a node's identifier (e.g., '2', '3', '4'), and the second represents data associated with that node (e.g., '250', '200', '300').  The nodes are connected by bidirectional arrows indicating the `prev_node` and `next_node` relationships.  The leftmost node is labeled 'head', representing the beginning of the list, and the rightmost node is labeled 'tail', representing the end.  A node with identifier '3' and data '200' is highlighted with an orange border, suggesting it might be a currently focused or selected node.  The arrows show that node '2' points to node '3' and node '4' points to node '3', while node '3' points to node '4' and node '2'.  The 'head' node points to node '2', and the 'tail' node points to node '3', completing the doubly linked structure.

Below is the implementation of this function:

python
def add_to_tail(self, node: DoublyLinkedListNode) -> None:
    prev_node = self.tail.prev
    node.prev = prev_node
    node.next = self.tail
    prev_node.next = node
    self.tail.prev = node

The example below illustrates how to remove a node from the doubly linked list:

The image represents a doubly linked list data structure before and after a node removal operation.  The list is visualized horizontally, with rectangular nodes representing data elements. Each node contains two values separated by a vertical bar: a key (integer) and a value (integer).  The nodes are connected by bidirectional arrows indicating the links between them.  The leftmost node is labeled 'head,' and the rightmost node is labeled 'tail,' representing the beginning and end of the list.  Initially, the list contains nodes with values (2 | 250), (4 | 300), and (3 | 200). A separate, unattached node (4 | 300) is shown above the list, labeled 'remove_node('. A dashed arrow points from this node to the node (4 | 300) within the list, indicating the node to be removed. The diagram illustrates the `remove_node` operation, implying that after the operation, the node (4 | 300) will be deleted from the linked list, leaving only the nodes (2 | 250) and (3 | 200) connected.

To remove a node, we make its two adjacent nodes point at each other, effectively excluding the node to be removed from the linked list:

Image represents a doubly linked list data structure with three nodes.  The leftmost node is labeled 'head,' indicating the beginning of the list.  Next is a node containing '2 | 250,' where '2' likely represents a key or identifier and '250' represents data associated with that key. This node is connected to the 'head' node via a bidirectional grey arrow, signifying that traversal is possible in both directions.  A central node, '4 | 00,' is crossed out in red, indicating it's been removed or is invalid.  This node is connected to the previous node with a light grey arrow pointing to the left and to the next node with a light grey arrow pointing to the right. The rightmost node is labeled 'tail,' marking the end of the list and is connected to the previous node via a bidirectional grey arrow.  The 'head' and 'tail' nodes are represented as rounded rectangles, while the data nodes are represented as rectangular boxes.  The central node's removal is highlighted by a large red 'X' over it, suggesting a deletion operation within the linked list.  The overall structure illustrates the connections and flow of information within a doubly linked list, emphasizing the removal of a node.

Below is the implementation of this function:

python
def remove_node(self, node: DoublyLinkedListNode) -> None:
    node.prev.next = node.next
    node.next.prev = node.prev

With the help of the above two functions, we can complete the full implementation of the LRU cache.

LRU Cache

python
class DoublyLinkedListNode:
    def __init__(self, key: int, val: int):
        self.key = key
        self.val = val
        self.next = self.prev = None
    
class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        # A hash map that maps keys to nodes.
        self.hashmap = {}
        # Initialize the head and tail dummy nodes and connect them to 
        # each other to establish a basic two-node doubly linked list.
        self.head = DoublyLinkedListNode(-1, -1)
        self.tail = DoublyLinkedListNode(-1, -1)
        self.head.next = self.tail
        self.tail.prev = self.head
    
    def get(self, key: int) -> int:
        if key not in self.hashmap:
            return -1
        # To make this key the most recently used, remove its node and
        # re-add it to the tail of the linked list.
        self.remove_node(self.hashmap[key])
        self.add_to_tail(self.hashmap[key])
        return self.hashmap[key].val
    
    def put(self, key: int, value: int) -> None:
        # If a node with this key already exists, remove it from the
        # linked list.
        if key in self.hashmap:
            self.remove_node(self.hashmap[key])
        node = DoublyLinkedListNode(key, value)
        self.hashmap[key] = node
        # Remove the least recently used node from the cache if adding
        # this new node will result in an overflow.
        if len(self.hashmap) > self.capacity:
            del self.hashmap[self.head.next.key]
            self.remove_node(self.head.next)
        self.add_to_tail(node)
    
    def add_to_tail(self, node: DoublyLinkedListNode) -> None:
        prev_node = self.tail.prev
        node.prev = prev_node
        node.next = self.tail
        prev_node.next = node
        self.tail.prev = node
    
    def remove_node(self, node: DoublyLinkedListNode) -> None:
        node.prev.next = node.next
        node.next.prev = node.prev

Complexity Analysis

Time complexity: The time complexity for the helper functions remove_node and add_tail_node is O(1)O(1)O(1) because they perform constant-time operations on a doubly linked list. The put and get functions utilize these helper functions, while also performing constant-time hash map operations. Consequently, they also have an O(1)O(1)O(1) time complexity.

Space complexity: The overall space complexity of this solution is O(n)O(n)O(n), where nnn is the capacity of the cache. This is because both the doubly linked list and hash map can each occupy O(n)O(n)O(n) space.

Interview Tip

Tip: Explore how combining data structures can help achieve certain functionality. It’s possible to encounter situations where no single data structure provides the functionality required for your solution. In such cases, try to work out if this functionality can be achieved using a combination of data structures. For instance, in this problem we combined a doubly-linked list and a hash map to achieve the functionality required for the LRU cache.

Palindromic Linked List

Palindromic Linked List

Given the head of a singly linked list, determine if it's a palindrome.

Example 1:

Image represents a directed graph illustrating a sequence of states or steps.  Five circular nodes, labeled sequentially from 1 to 5, are arranged horizontally. Each node contains a single digit representing a state.  Directed edges, represented by arrows, connect the nodes, indicating the flow of information or progression through the sequence. The arrows point from node 1 to node 2, from node 2 to node 3, from node 3 to node 4 (which is also labeled 2), and finally from node 4 to node 5 (which is labeled 1).  This suggests a cyclical pattern where the sequence starts with state 1, progresses through states 2 and 3, then returns to state 2 before finally returning to the initial state 1.  The repetition of states 2 and 1 highlights a potential loop or recurring pattern within the overall process.
python
Output: True

Example 2:

Image represents a directed graph illustrating a sequence of operations or states.  The graph consists of four circular nodes, each containing a single digit: '1', '2', '1', and '2'.  These nodes are connected by directed edges, represented by arrows.  The first node, containing '1', is connected to the second node ('2') via a directed edge pointing from '1' to '2'.  Similarly, the second node ('2') connects to the third node ('1'), and the third node ('1') connects to the fourth node ('2'), each connection represented by a directed edge showing the flow from left to right.  The arrangement suggests a pattern of alternating '1' and '2' states or operations, with the information flow unidirectional from left to right.
python
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 takes 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.

Image represents two linked lists displayed horizontally.  The top list, labeled 'linked list:', shows a sequence of nodes containing the values 1, 2, 3, 2, and 1, connected by rightward-pointing arrows indicating the direction of traversal. The bottom list, labeled 'reversed linked list:', displays the same numerical values (1, 2, 3, 2, 1) but in reverse order, connected by leftward-pointing arrows.  A horizontal orange arrow connects the ends of both lists, labeled 'same sequence of values,' highlighting that both lists contain the identical numerical sequence, but in opposite directions, demonstrating the concept of reversing a linked list.

This means we could create a copy of the linked list, reverse it, and compare its values with the original linked list. However, this would still take up linear space. Can we adjust this idea to avoid creating a new linked list?

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:

Image represents a diagram illustrating a palindrome checking algorithm.  Five circular nodes, numbered sequentially 1, 2, 3, 2, 1, are arranged linearly and connected by unidirectional arrows.  The arrows indicate the flow of data.  Nodes 1 and 2 are connected by an arrow pointing from 1 to 2, representing the processing of the first half of the input. Node 2 is connected to node 3, also with an arrow pointing from 2 to 3, continuing the processing of the first half. A grey line segment above nodes 1, 2, and 3 is labeled 'compare first half'.  Node 3 is connected to node 2 by an arrow pointing from 2 to 3, and node 2 is connected to node 1 by an arrow pointing from 1 to 2, representing the reversed processing of the second half. A grey line segment below nodes 3, 2, and 1 is labeled 'with reverse of second half'. The overall structure shows a comparison between the first half of a sequence and the reversed second half to determine if it's a palindrome.

Before we can perform this comparison, we need to:

  1. Find the middle of the linked list to get the head of the second half.
  2. Reverse the second half of the linked list from this middle node.

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.

Image represents a linked list data structure with five nodes.  Each node is depicted as a circle containing a numerical value (1, 2, 3, 2, 1).  Arrows indicate the directional links between nodes, showing the sequence of the list.  A gray rectangular box labeled 'head' points to the first node (containing the value 1), representing the starting point of the list.  Separately, an orange rectangular box labeled 'mid' points to the third node (containing the value 3), highlighting a specific node within the list.  The arrangement visually demonstrates the linear progression of elements in a linked list, with the 'head' and 'mid' labels indicating pointers to specific locations within that sequence.

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.

Image represents a diagram illustrating a linked list data structure with two separate heads.  The first head, labeled 'head' in a gray rectangle, points to a node containing the number '1'. This node is connected via a black arrow to a node containing '2', which is further connected to a node containing '3' by another black arrow. A second head, labeled 'mid' in a gray rectangle, points to the node containing '3'.  Separately, a third head, labeled 'second_head' in an orange rectangle, points to a node containing '1'. This node is connected to a node containing '2' via a light-blue arrow pointing left, indicating a connection in the opposite direction compared to the first sequence.  The overall structure shows two distinct linked lists, one progressing from '1' to '3' and the other starting from '1' and connecting to '2' in reverse order, sharing some nodes but with different starting points and directions of traversal.

To learn how to reverse a linked list in O(n)O(n)O(n) time, read the explanation in the Reverse Linked List problem in this chapter.

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:

Image represents a linked list data structure with two pointers, `ptr1` and `ptr2`, manipulating its nodes.  The list consists of five nodes, numbered 1, 2, 3, 2, and 1, sequentially connected by solid arrows indicating the primary list order. A rectangular box labeled 'head' points to the first node (1), establishing the beginning of the list.  Another rectangular box labeled 'second_head' points to the last node (1), suggesting a second entry point.  Dashed orange arrows show additional connections: `ptr1` points to node 3, and `ptr2` points to node 3.  Furthermore, dashed orange arrows illustrate a secondary, non-sequential connection from node 1 to node 2, and from node 2 to node 3, and from node 2 to node 1, indicating a potential circular or rearranged structure within the list, possibly created or modified by the pointers.

Implementation

python
from ds import ListNode
    
def palindromic_linked_list(head: ListNode) -> bool: 
    # Find the middle of the linked list and then reverse the second half of the # linked list starting at this midpoint.
    mid = find_middle(head)
    second_head = reverse_list(mid)
    # Compare the first half and the reversed second half of the list
    ptr1, ptr2 = head, second_head
    res = True
    while ptr2:
        if ptr1.val != ptr2.val:
            res = False
        ptr1, ptr2 = ptr1.next, ptr2.next
    return res
    
# From the 'Reverse Linked List' problem.
def reverse_list(head: ListNode) -> ListNode:
    prevNode, currNode = None, head
    while currNode:
        nextNode = currNode.next
        currNode.next = prevNode
        prevNode = currNode
        currNode = nextNode
    return prevNode
    
# From the 'Linked List Midpoint' problem.
def find_middle(head: ListNode) -> ListNode:
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow

Complexity Analysis

Time complexity: The time complexity of palindromic_linked_list is O(n)O(n)O(n), where nnn 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.

Space complexity: The space complexity is O(1)O(1)O(1).

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 a Multi-Level Linked List

Flatten a Multi-Level Linked List

In a multi-level linked list, each node has a next pointer and child pointer. The next pointer connects to the subsequent node in the same linked list, while the child pointer points to the head of a new linked list under it. This creates multiple levels of linked lists. If a node does not have a child list, its child attribute is set to null.

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:

Image represents a tree-like data structure visualized through numbered nodes and directed edges, illustrating a 'flattening' operation.  The structure is organized into three levels labeled 'Level 1,' 'Level 2,' and 'Level 3.' Level 1 contains nodes 1 through 5, connected sequentially by 'next' labeled arrows.  Nodes 2 and 4 have downward-pointing arrows labeled 'child,' connecting them to Level 2 nodes 6 and 8, respectively. Level 2 consists of nodes 6 through 9, with nodes 6 and 8 connected sequentially by 'next' arrows.  Nodes 7 and 9 have downward-pointing 'child' arrows connecting to Level 3 nodes 10 and 11, respectively.  A thick downward arrow labeled 'FLATTEN' indicates a transformation. Below this arrow is a flattened, single-level linked list comprising all nodes (1 through 11) connected sequentially by 'next' arrows, demonstrating the result of the flattening operation where the hierarchical structure is removed, resulting in a linear sequence.  The nodes' colors vary across levels, with Level 1 nodes in light orange, Level 2 in darker reddish-orange, and Level 3 in a yellowish-orange.

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’:

Image represents a diagram illustrating a tree-like data structure, specifically showing the relationship between nodes at two levels, labeled 'Level L' and 'Level L + 1'.  At Level L, four black circles representing nodes are arranged horizontally, connected by arrows labeled 'next', indicating a sequential relationship.  Each of the second and fourth nodes in Level L has a downward-pointing arrow labeled 'child' connecting it to a light-blue circle in Level L + 1. These light-blue circles, also representing nodes, are arranged horizontally and connected by arrows labeled 'next', mirroring the structure of Level L. A gray horizontal line with tick marks underneath Level L + 1 visually groups the nodes at that level, and the text 'Level L + 1 nodes are accessible from level L' clarifies that the lower-level nodes are children of, and therefore accessible from, the upper-level nodes.  The overall structure demonstrates a hierarchical relationship where nodes at Level L can access nodes at Level L + 1 through the 'child' connections.

How can we connect the nodes on level ‘L + 1’ to the end of level ‘L’? Since we have access to the nodes at the next level from the current level’s child pointers, we can append each child linked list to the end of the current level, which effectively merges these two levels into one.

Image represents a diagram illustrating a linked list data structure with nested lists.  The diagram shows a sequence of five solid-line circles connected by solid-line arrows labeled 'next,' representing nodes in a primary linked list. The third and fourth nodes have downward-pointing arrows labeled 'child,' connecting them to separate light-blue rectangular boxes. Each box contains a smaller linked list of two nodes (solid-line circles) connected by a 'next' arrow.  The primary list then continues with two additional nodes represented by dashed-line circles within a light-blue rectangle, also connected by 'next' arrows.  Finally, a light-blue upward-pointing arrow connects the bottom of each of the child linked lists to the dashed-line nodes in the primary list, indicating a hierarchical relationship where the child lists are appended to the main list.  The overall structure depicts a tree-like organization where the main list acts as the parent, and the child lists are sub-lists branching from specific nodes.

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:

Image represents a directed acyclic graph illustrating a coding pattern, possibly a tree-like data structure.  The graph is composed of ten nodes, numbered 1 through 10, represented as circles with varying shades of orange and brown.  Nodes 1, 2, 3, and 4 are lighter orange, while nodes 5, 6, 7, and 8 are darker brown. Nodes 9 and 10 are a lighter orange than nodes 1-4.  Arrows connect the nodes, indicating direction.  Nodes 1, 2, 3, and 4 are linked horizontally by arrows labeled 'next,' forming a sequence. Node 1 has a downward arrow labeled 'child' connecting it to node 5, and node 4 has a downward arrow labeled 'child' connecting it to node 7. Similarly, nodes 5 and 6 are linked horizontally by an arrow labeled 'next,' with node 6 having a downward arrow labeled 'child' connecting it to node 9.  Nodes 7 and 8 are linked horizontally by an arrow labeled 'next,' with node 7 having a downward arrow labeled 'child' connecting it to node 10. The label 'head: 1' indicates node 1 as the starting point or root of the structure.  The overall structure suggests a combination of linked lists (horizontal 'next' connections) and a tree-like hierarchy (vertical 'child' connections).

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, we'll create a tail pointer and advance it through level 1's linked list until it reaches the last node, which happens when tail.next is equal to null:

Image represents a singly linked list data structure.  The list is labeled 'head:' and consists of four nodes, each represented by an orange circle containing a numerical value (1, 2, 3, and 4). Each node, except the last, has a solid black arrow labeled 'next' pointing to the subsequent node, indicating the directional flow of data within the list.  Dashed light-blue curved arrows connect each node to the `tail` which is represented by a light-blue rectangular box containing the text 'tail'. These dashed arrows visually represent the connection to the tail, but don't imply a direct data flow in the same way as the 'next' arrows. The overall structure shows a linear sequence of nodes, where each node points to the next, and all nodes are indirectly connected to the tail.

Now, let’s add the child linked lists (5 → 6 and 7 → 8) to the tail node. We must keep the tail pointer fixed at the end of the linked list, so let’s introduce a separate pointer, curr, to traverse the linked list. Whenever curr encounters a node with a child node that isn’t null, we know we’ve found a child linked list. In the example, the first node (node 1) has a child linked list, which we want to add to the tail node:

Image represents a linked list data structure illustrating a coding pattern.  The main list is composed of orange-filled nodes (1, 2, 3, 4) connected by arrows labeled 'next,' indicating the directional flow of data.  A gray box labeled 'curr' points to node 1, signifying a current pointer. A light-blue box labeled 'tail' points to node 4, indicating the tail of the main list. Node 1 has a downward-pointing arrow labeled 'child' connecting it to a sub-list enclosed in a green box. This sub-list contains brown-filled nodes (5, 6) also linked by 'next' arrows.  Node 4 similarly has a downward-pointing arrow labeled 'child' connecting it to another sub-list with brown-filled nodes (7, 8) linked by 'next' arrows. The text 'add to tail' is written below the first sub-list, suggesting a potential operation.  The overall structure shows a main list with child lists branching from specific nodes, demonstrating a hierarchical or tree-like structure within a linked list context.

To add this child linked list to the end of the tail node, set tail.next to the head of the child list:

Image represents a linked list data structure illustrating a coding pattern.  The main list, labeled 'head:', consists of orange nodes numbered 1 through 4, connected sequentially by 'next' pointers.  A gray box labeled 'curr' points to node 1, indicating a current pointer. Node 1 has a downward-pointing 'child' pointer to a sub-list enclosed in a green box, containing maroon nodes 5 and 6 linked by a 'next' pointer.  Similarly, node 4 has a downward-pointing 'child' pointer to a maroon node 7, which is linked to node 8 via a 'next' pointer. A light-blue box labeled 'tail' points to node 4, indicating the tail of the main list.  A dashed-line box displays the code snippet 'tail.next = curr.child', representing an operation to append the child list to the main list, connecting the tail of the main list to the head of the child list.
Image represents a linked list data structure with additional child pointers illustrating a tree-like structure.  The main list is composed of nodes numbered 1 through 6, each node (except the last) connected to the next by a 'next' pointer represented by an arrow.  Node 1 is labeled 'head,' indicating the start of the list.  A light blue box labeled 'tail' points to node 4.  Nodes 5 and 6 are enclosed in a green box, suggesting a sub-section or a different level in the structure.  Each of nodes 1, 4, and 6 has a downward-pointing 'child' pointer connecting it to another node: node 1 points to node 5, node 4 points to node 7, and node 6 points to node 9.  Nodes 5 and 6 are also linked together via a 'next' pointer.  Nodes 7, 8, and 9 form a secondary linked list, with node 7 connected to node 8 via a 'next' pointer.  Nodes 5 and 6 are lighter gray than the main list, suggesting a different level or type of data.  The overall structure shows a combination of a singly linked list and a tree structure, where the main list has nodes with child pointers creating branches.

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 iterating the tail pointer until its next node is null:

Image represents a linked list data structure with nested lists.  The main list, labeled 'head:', consists of nodes numbered 1 through 6, each represented by an orange circle containing its value.  Solid black arrows labeled 'next' connect these nodes sequentially, indicating the order of elements. Node 1 has a downward-pointing arrow labeled 'child' connecting it to a smaller, gray node labeled 5, which is linked to another gray node labeled 6 via a 'next' arrow. Similarly, node 4 has a 'child' arrow pointing to a darker-red node labeled 7, which connects to node 8 via a 'next' arrow. Finally, node 6 has a 'child' arrow pointing to an orange node labeled 9.  A light-blue dashed arrow points from node 4 to node 5, and another from node 5 to node 6, suggesting a secondary connection or traversal path.  Rectangular boxes labeled 'curr' and 'tail' indicate pointers to the beginning and end of the main list, respectively, with arrows showing their positions.  The overall structure depicts a linked list where some nodes have child lists branching off, demonstrating a tree-like structure within the main linear list.

With the tail pointer now repositioned, we can continue this process of:

  • Finding the next node with a child linked list using the curr pointer.
  • Adding the child linked list to the tail node.
  • Advancing the tail pointer to the last node of the flattened linked list.
Image represents a linked list data structure illustrating the process of adding a node to the tail.  The main list is composed of orange-filled nodes (1-6) labeled sequentially, each connected to the next by a directed arrow labeled 'next'.  A gray-filled sub-list (5, 6) branches down from node 1, indicating a 'child' relationship.  A dark-red filled sub-list (7, 8) branches down from node 4, also labeled 'child' and enclosed in a green box with the text 'add to tail' below.  A light-orange node (9) hangs off node 6, also labeled 'child'.  A gray box labeled 'curr' points to node 4, and a light-blue box labeled 'tail' points to node 6, indicating the current and tail pointers respectively.  Finally, a green box contains two dashed-line circles representing empty nodes, connected by a 'next' arrow, showing the space where new nodes would be added to the tail of the main list.  The arrows indicate the direction of the 'next' pointers in the list.
Image represents a linked list data structure illustrating an operation.  The main list is composed of nodes numbered 1 through 8, each node (except the last) containing an integer value and a 'next' pointer connecting it to the subsequent node.  Nodes 1 through 4 are colored light orange, while nodes 5 through 8 are dark red.  The 'head' label points to node 1, indicating the beginning of the list.  A light gray linked list, labeled 'child', branches off from nodes 1 and 4, containing nodes 5-6 and 7-8 respectively.  A blue box labeled 'tail' points to node 8, indicating the current end of the main list. A gray dashed-line box represents a new node to be added. A green box labeled '9' is shown below node 6, representing a new node being added to the list.  A dark orange node labeled '10' is shown below node 8, representing the new tail of the list after the addition.  A black box labeled 'curr' points to node 6, indicating the current node being processed. The text 'add to tail' in green indicates the operation being performed.  The arrows represent the 'next' pointers, showing the directional flow of data within the linked list.
Image represents a linked list data structure illustrating the process of adding a node to the tail.  The list begins with a 'head' node labeled '1', colored orange, connected via 'next' pointers to subsequent nodes (2, 3, 4, 5, 6, 7, 8, and 9) with colors transitioning from orange to dark-red and then orange again.  Nodes 2, 3, 4, 6, and 7 are also colored orange, while 5 is dark-red.  Each node, except the last, points to the next node in the sequence.  Nodes 1, 2, 3, 4, 6, and 7 have downward-pointing arrows labeled 'child' connecting them to secondary linked lists (5-6, 7-8, and 9 respectively), represented by lighter gray nodes.  A 'curr' pointer points to node 8, and a 'tail' pointer points to node 9.  A new node, '10', colored orange and enclosed in a green box, is being added to the tail, indicated by the text 'add to tail' and an arrow from node 8 to node 10.  The final node in the main list is represented by a dashed-line box, indicating that it is the current end of the list before the addition of node 10.
Image represents a linked list data structure with nodes numbered 1 through 10.  The nodes are connected by directed edges labeled 'next,' indicating the sequential order. Nodes 1 through 4 are colored light orange, nodes 5 through 7 are dark red, and nodes 8 through 10 are light orange.  The list starts with a 'head' pointer pointing to node 1.  Each node (except the last) points to the next node in the sequence via its 'next' pointer.  Additionally, nodes 4, 6, and 8 have downward-pointing edges labeled 'child,' each connecting to a separate, smaller linked list containing nodes 5-6, 7-8, and 9-10 respectively. These child lists also use 'next' pointers to link their nodes. A 'curr' pointer points to node 8, and a 'tail' pointer points to node 10, indicating current position and the end of the main list.

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:

python
class MultiLevelListNode:
    def __init__(self, val, next, child):
        self.val = val
        self.next = next
        self.child = child
python
from ds import MultiLevelListNode
   
def flatten_multi_level_list(head: MultiLevelListNode) -> MultiLevelListNode:
    if not head:
        return None
    tail = head
    # Find the tail of the linked list at the first level.
    while tail.next:
        tail = tail.next
    curr = head
    # Process each node at the current level. If a node has a child linked list,
    # append it to the tail and then update the tail to the end of the extended linked
    # list. Continue until all nodes at the current level are processed.
    while curr:
        if curr.child:
            tail.next = curr.child
            curr.child = None
            while tail.next:
                tail = tail.next
        curr = curr.next
    return head

Complexity Analysis

Time complexity: The time complexity of flatten_multi_level_list is O(n)O(n)O(n), where nnn 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 O(1)O(1)O(1).

Chapter 4

Fast and Slow Pointers

~14 min read

Introduction to Fast and Slow Pointers

Introduction to Fast and Slow Pointers

Intuition

The fast and slow pointer technique is a specialized variant of the two-pointer pattern, characterized by the differing speeds at which two pointers traverse a data structure. In this technique, we designate a fast pointer and a slow pointer:

  • Usually, the slow pointer moves one step in each iteration.
  • Usually, the fast pointer moves two steps in each iteration.

This creates a dynamic in which the fast pointer moves at twice the speed of the slow pointer:

Image represents a visual depiction of the 'slow and fast pointer' algorithm often used in linked list traversal.  The diagram shows two parallel linked lists, each composed of several circular nodes connected by solid arrows representing the `next` pointer in each node.  The top list illustrates the initial state, with a 'slow' pointer (indicated by a light blue box labeled 'slow') and a 'fast' pointer (indicated by an orange box labeled 'fast') both initially pointing to the first node.  Arrows from the boxes point to their respective nodes.  The bottom list shows the pointers after one iteration. The 'slow' pointer has moved one node to the right, and the 'fast' pointer has moved two nodes to the right.  Both lists continue with ellipses (...) indicating that they extend beyond the visible portion.  Dashed arrows on the bottom list show the movement of the pointers.  To the right, a gray box with dashed borders contains pseudocode: `slow = slow.next` and `fast = fast.next.next`, illustrating how the pointers are updated in each iteration.  This code snippet explains the algorithm's core logic: the slow pointer advances one node at a time, while the fast pointer advances two nodes at a time.

Keep in mind these pointers aren't limited to just one and two steps. As long as the fast pointer advances more steps than the slow pointer does, the logic of the fast and slow pointer technique still applies.

Real-world Example

Detecting cycles in symlinks: Symlinks are shortcuts that point to files or directories in a file system. A real-world example of using the fast and slow pointer technique is detecting cycles in symlinks.

In this process, the slow pointer follows each symlink one step at a time, while the fast pointer moves two steps at a time. If the fast pointer catches up to the slow pointer, it indicates a loop in the symlinks, which can cause infinite loops or errors when accessing files. To understand how fast and slow pointers detect cycles in this way, study the Linked List Loop problem.

Chapter Outline

Fast and slow pointers are particularly beneficial in data structures like linked lists, where index-based access isn't available. They allow us to gather crucial information about the data structure's elements through the relative positions of the two pointers, rather than with indexing. This will become clearer as we explore some common use cases in this chapter.

Image represents a hierarchical diagram illustrating the applications of 'Fast and Slow Pointers' coding pattern.  The top-level node, a rounded rectangle, is labeled 'Fast and Slow Pointers'.  From this node, three dashed lines descend to three lower-level nodes, each representing a specific application.  The leftmost node is a rounded rectangle labeled 'Cycle Detection - `Linked List Loop`', indicating its use in detecting cycles within linked lists. The rightmost node is a similarly styled rounded rectangle labeled 'Sequence Analysis - `Happy Number`', showing its application in analyzing number sequences, specifically the 'Happy Number' problem.  Finally, a central lower-level node, also a rounded rectangle, is labeled 'Fractional Point Identification - `Linked List Midpoint`', demonstrating its use in finding the midpoint of a linked list.  The connections between the top node and the three lower nodes represent the different ways the 'Fast and Slow Pointers' pattern can be utilized, with each lower node detailing a specific problem solved using this pattern.

Linked List Loop

Linked List Loop

Given a singly linked list, determine if it contains a cycle. A cycle occurs if a node's next pointer references an earlier node in the linked list, causing a loop.

Example:

Image represents a state diagram illustrating a coding pattern.  The diagram shows a linear sequence followed by a cyclical sequence. The linear sequence consists of three nodes labeled '0', '1', and '2', connected by unidirectional arrows indicating a flow from '0' to '1' and then from '1' to '2'.  The cyclical sequence, labeled 'cycle', is composed of four nodes numbered '3', '4', '5', and '2' (note that '2' is shared between the linear and cyclical sequences). These nodes form a closed loop with unidirectional arrows indicating a flow from '3' to '4', '4' to '5', '5' to '2', and '2' to '3'. The cyclical sequence is enclosed by a dashed rectangle to visually distinguish it from the linear sequence.  The overall diagram depicts a system where a linear process leads into a repeating cyclical process, potentially representing a common pattern in program execution or data flow.
python
Output: True

Intuition

A straightforward approach is to iterate through the linked list while keeping track of the nodes that were already visited in a hash set. Encountering a previously-visited node during the traversal indicates the presence of a cycle. Below is the code snippet for this approach:

python
from ds import ListNode
    
def linked_list_loop_naive(head: ListNode) -> bool:
    visited = set()
    curr = head
    while curr:
        # Cycle detected if the current node has already been visited.
        if curr in visited:
            return True
        visited.add(curr)
        curr = curr.next
    return False

This solution takes O(n)O(n)O(n) time, where nnn is the number of nodes in the linked list, since each node is visited once. However, this comes at the cost of O(n)O(n)O(n) extra space due to the hash set. Is there a way to achieve a linear time complexity while using constant space?

Imagine a race track represented as a circular linked list (i.e., a linked list with a perfect cycle) where two runners start at the same node.

Image represents a circular flowchart depicting a four-step process, numbered 1 through 4, with each step represented by a numbered circle.  Arrows indicate the sequential flow, starting from step 1, proceeding to step 2, then 3, then 4, and finally looping back to step 1, creating a continuous cycle.  A central circle labeled 'θ' (theta) is positioned outside the main cycle and connected to it with arrows indicating interaction or data flow between the central element and the cyclical process.  To the left of the central circle, two stick figures are shown; one orange labeled 'FF' and one light blue labeled 'S', suggesting two distinct entities or inputs interacting with the central element 'θ' and influencing the cyclical process. The arrangement suggests a feedback loop where the four steps continuously repeat, influenced by the interaction between 'FF', 'S', and the central component 'θ'.

If both runners move at the same speed, they will always be together at each node. However, consider what happens when one runner (the slow runner) moves one step at a time, while the other runner (the fast runner) moves two steps at a time. In this scenario, the fast runner will overtake the slow runner at some point since the track is cyclic. But how can we use this information to detect a cycle?

In a linked list, detecting whether the fast runner has overtaken the slow runner is difficult due to the lack of positional indicators (like indexes in an array). A better way to find a cycle is to see if the fast runner reunites with the slow runner by both landing on the same node at some point. This would be a clear sign the linked list has a cycle.

The question now is, will the fast runner reunite with the slow runner, or is there a chance for the fast runner to consistently bypass the slow runner without ever converging on the same node? To answer this, let’s start by looking at some examples.

Perfect cycle First, let’s check whether the two runners, represented as a slow pointer and a fast pointer, will reunite in a linked list that forms a perfect cycle. As we can see from the figure below, the pointers will eventually meet.

Image represents a visual depiction of a coding pattern, likely illustrating a cycle detection algorithm.  The diagram shows three stages. The first stage depicts a circular linked list with nodes numbered 0 through 4, where node 0 has incoming arrows labeled 'fast' (orange) and 'slow' (light blue), indicating two pointers moving at different speeds.  The second stage shows the same circular list, but now the 'fast' pointer has advanced further than the 'slow' pointer, with dashed orange and light blue arrows indicating their movement. The third stage shows the pointers converging at a point, with the 'fast' pointer now moving faster than the 'slow' pointer, indicated by dashed orange arrows from node 2 to node 3 and from node 3 to node 4. A solid black arrow points downwards from the third stage, suggesting the algorithm's completion or a subsequent step.  The overall structure demonstrates the concept of two pointers traversing a circular data structure at different speeds to detect cycles.
Image represents a state transition diagram illustrating a coding pattern, likely related to cycle detection or pointer manipulation.  The diagram shows three stages. The first stage depicts a circular linked list with nodes numbered 0 through 4. Node 0 is distinct, enclosed in a green box with incoming arrows labeled 'fast' (orange) and 'slow' (light blue), representing two pointers moving at different speeds.  Nodes 1 through 4 form a cycle with solid black arrows indicating the direction of traversal. The second stage shows the same structure but with dashed arrows (orange and light blue) indicating the movement of the 'fast' and 'slow' pointers, respectively, starting from node 0 and progressing through the cycle.  The third stage shows the final state where the 'fast' pointer has caught up to the 'slow' pointer, indicated by the convergence of the dashed orange and light blue arrows at node 1.  The 'fast' pointer is labeled with an orange rectangle, and the 'slow' pointer is labeled with a light blue rectangle in the final stage.  The arrows between stages indicate a progression of time or steps in the algorithm.

Delayed cycle What about when the cycle doesn’t start immediately in the linked list? Consider simulating the fast and slow pointer technique over the following example:

Image represents a step-by-step visualization of a coding pattern, possibly related to linked lists or circular buffers, demonstrating the movement of data elements labeled with 'slow' and 'fast' pointers.  The diagram begins with a linear sequence of nodes (0, 1, 2) connected by solid arrows, followed by a circular linked list (3, 4, 5).  A 'fast' pointer (orange dashed arrows) moves twice as quickly as a 'slow' pointer (light blue dashed arrows). The subsequent stages show the pointers traversing the circular list.  The 'fast' pointer initially overtakes the 'slow' pointer, and then the process repeats in a smaller circular list.  Finally, the 'fast' and 'slow' pointers converge at node 4, which is highlighted in a green box, indicating a potential detection of a cycle or loop within the data structure.  Each node is numbered (0-5), and the speed of each pointer ('slow' or 'fast') is indicated by a colored box next to the pointer's arrow.  The arrows illustrate the direction and speed of pointer movement.

Again, the pointers eventually met in the cycle despite the fact that fast and slow entered the cycle at different times.

Will fast always catch up with slow? In both cases, it might seem like the fast pointer could keep overtaking the slow pointer without ever meeting it, but this isn’t true. Here’s an easier way to understand why they will meet.

The fast pointer moves 2 steps at a time, and the slow pointer moves 1 step at a time, so the fast pointer will gain a distance of 1 node over the slow pointer at each iteration. This can be observed below, where the distance between the fast and slow pointers reduces by one in each iteration until they inevitably meet.

Image represents a step-by-step visualization of a cyclical process, possibly illustrating a coding pattern like a pointer chase algorithm.  The core element is a circular arrangement of five nodes labeled 0 through 4.  Initially (leftmost diagram), node 0 is unfilled, while nodes 1 and 3 are shaded grey, indicating a state change.  A dashed line connects node 1 to node 3, labeled 'distance = 2'.  Solid arrows show the directional flow around the circle.  A rectangular box labeled 'fast' points to node 1, and a box labeled 'slow' points to node 3. The middle diagram shows the process evolving; node 4 is now shaded grey, the distance between the pointers (represented by the dashed line) is reduced to 'distance = 1', and the 'slow' pointer has moved to node 4.  The rightmost diagram depicts the final state where the distance between the pointers is 'distance = 0', both pointers are at node 0, and node 0 is now shaded grey.  The 'fast' and 'slow' pointers are now both directed towards node 0.  The arrows between the diagrams indicate the progression of the process.

Therefore, the maximal number of steps required for the fast pointer to catch up with the slower pointer is k steps (once both are in the cycle), where k is the length of the cycle. In the worst case, the cycle will contain all the linked list’s nodes, and the pointers will eventually meet in n steps.

No cycle The final case is when there is no cycle. In this case, the fast pointer will eventually reach the end of the list and exit the while-loop:

Image represents a state diagram or flowchart illustrating a process with four states, numbered 0, 1, 2, and 3, represented as circles.  State 0 is the starting point, receiving input from two sources labeled 'slow' (in light blue) and 'fast' (in orange), indicated by arrows pointing to state 0.  These labels likely represent different pathways or speeds of entry into the process.  The states are sequentially connected by unidirectional arrows, indicating a linear progression from state 0 to 1, then 1 to 2, and finally 2 to 3.  The arrows represent transitions between states, suggesting a step-by-step process where each state completes before moving to the next.  There are no loops or branches shown, implying a simple, linear workflow.
The image represents a data flow diagram illustrating two parallel processing paths.  Four numbered circles (0, 1, 2, and 3) represent processing stages.  A solid arrow from circle 0 to circle 1 indicates a sequential flow of data.  Another solid arrow connects circle 1 to circle 2, and a final solid arrow connects circle 2 to circle 3, showing a linear progression through these stages.  Above the diagram, two rectangular boxes are labeled 'slow' (in light blue) and 'fast' (in orange).  A downward-pointing blue arrow connects the 'slow' box to circle 1, indicating that data enters stage 1 via a slower path.  Similarly, an orange downward-pointing arrow connects the 'fast' box to circle 2, showing data entering stage 2 through a faster route.  Dashed orange arrows connect circle 0 to circle 1 and circle 1 to circle 2, representing a feedback loop or alternative data path within the 'fast' processing stream.  A dashed blue arrow connects circle 0 to circle 1, suggesting a secondary, slower feedback loop.  The overall diagram depicts a system where data can follow either a fast or slow path, with potential feedback mechanisms within each path.
Image represents a linked list with nodes numbered 0, 1, 2, and 3, sequentially connected by solid arrows.  A light-blue rectangular box labeled 'slow' points downwards to node 2, indicating a pointer.  Similarly, an orange rectangular box labeled 'fast' points downwards to node 3.  A dashed light-blue arrow connects node 1 to node 2, representing a slow pointer's movement. A dashed orange arrow connects node 3 back to node 2, representing a fast pointer's movement.  The sequence continues with a solid arrow from node 3 to 'null', signifying the end of the list.  To the right, a grey, dashed-line box displays the condition 'fast == null → no cycle', indicating that if the fast pointer reaches the end (null), there is no cycle detected in the linked list.

Implementation

This algorithm is formally known as ‘Floyd's Cycle Detection’ algorithm [1].

python
from ds import ListNode
    
def linked_list_loop(head: ListNode) -> bool:
    slow = fast = head
    # Check both 'fast' and 'fast.next' to avoid null pointer exceptions when we
    # perform 'fast.next' and 'fast.next.next'.
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if fast == slow:
            return True
    return False

Complexity Analysis

Time complexity: The time complexity of linked_list_loop is O(n)O(n)O(n) because the fast pointer will meet the slow pointer in a linear number of steps, as described.

Space complexity: The space complexity is O(1)O(1)O(1).

Linked List Midpoint

Linked List Midpoint

Given a singly linked list, find and return its middle node. If there are two middle nodes, return the second one.

Example 1:

Image represents a directed graph illustrating a sequence of steps or a process flow.  The graph consists of five nodes, represented as circles, labeled sequentially with the numbers 1, 2, 4, 7, and 3.  Arrows indicate the direction of flow, showing a progression from node 1 to node 2, then to node 4, then to node 7, and finally to node 3. Node 4 is visually distinct, highlighted with a green fill and labeled 'mid' in green text above it, suggesting it represents a midpoint or a crucial step in the overall process. The arrangement of the nodes and arrows clearly depicts a linear sequence with a highlighted central element.
python
Output: Node 4

Example 2:

Image represents a directed acyclic graph (DAG) illustrating a sequence of data flow.  The graph consists of four circular nodes, each containing a numerical value: 1, 2, 4, and 7.  These nodes are connected by directed edges (arrows) indicating the direction of data flow.  The data flows sequentially from node 1 to node 2, then from node 2 to node 4, and finally from node 4 to node 7. Node 4 is highlighted with a light green fill and the word 'mid' is written above it in green, suggesting that node 4 represents a midpoint or intermediate stage in the process.  The absence of cycles and the unidirectional nature of the edges confirm the acyclic nature of the graph.
python
Output: Node 4

Constraints:

  • The linked list contains at least one node.
  • The linked list contains unique values.

Intuition

The most intuitive approach to solve this problem is to traverse the linked list to find its length (n), and then traverse the linked list a second time to find the middle node (n / 2):

Image represents two parallel sequences of nodes (circles) connected by directed edges (arrows).  The top sequence, labeled 'n', shows nodes containing the numbers 1, 2, 4, 7, and 3, sequentially connected with solid black arrows indicating the flow of information.  Additionally, grey dashed curved arrows connect the first node to the third, the second to the fourth, and the fourth to the fifth, suggesting a non-sequential, potentially parallel or skip-ahead processing step. The bottom sequence, labeled 'n/2', mirrors the node values and sequential connections of the top sequence using solid black arrows. However, it features orange dashed curved arrows connecting the first node to the third, indicating a different, potentially more efficient or reduced processing path compared to the top sequence.  The difference in the arrows' styles and colors highlights the distinction between the two processing methods, suggesting a comparison of computational complexity or efficiency.  The 'n' and 'n/2' labels likely represent the input size or number of operations involved in each processing path.

This approach solves the problem in O(n)O(n)O(n) time, but requires two iterations to find the midpoint. However, there’s a cleaner approach that uses only a single iteration.

When iterating through the linked list, our exact position is unclear. We only know where we are when we reach the final node. Is it possible to create a scenario where, as soon as one pointer reaches the end of the linked list, the other pointer is positioned at the middle node?

If we had one pointer move at half the speed of the other, by the time the faster pointer reaches the end of the list, the slower one will be at the middle of the linked list.

We can achieve this by using the fast and slow pointer technique:

  • Move a slow pointer one node at a time.
  • Move a fast pointer two nodes at a time.
Image represents a directed acyclic graph illustrating a workflow or process.  The graph consists of five circular nodes labeled sequentially with the numbers 1, 2, 4, 7, and 3.  Arrows indicate the direction of flow between these nodes, starting from node 1 and proceeding through nodes 2, 4, 7, and finally to node 3.  Above the first node (1), two rectangular boxes are positioned; one labeled 'slow' in light blue and the other labeled 'fast' in orange.  Arrows point from these boxes to node 1, indicating that the 'slow' path leads to node 1, and the 'fast' path also leads to node 1. This suggests two different input paths or processes that converge at node 1 before continuing through the rest of the sequence. The overall structure depicts a linear sequence of operations or steps, with two distinct initial inputs merging into a single workflow.
Image represents a directed graph illustrating a workflow or process.  The graph consists of five numbered nodes (1, 2, 4, 7, 3) connected by solid arrows indicating the sequential flow. Node 1 connects to node 2, node 2 connects to node 4, node 4 connects to node 7, and node 7 connects to node 3.  Additionally, there are two dashed arrows: a light-blue dashed arrow loops from node 1 back to node 2, labeled 'slow' in a light-blue box above it, indicating a feedback or iterative process; and an orange dashed arrow loops from node 1 to node 2 and then to node 4, labeled 'fast' in an orange box above the arrow pointing to node 4, suggesting a faster alternative path or shortcut.  The overall diagram depicts two distinct paths through the process: a slower, iterative path (indicated by the light-blue arrow) and a faster, more direct path (indicated by the orange arrow).
Image represents a diagram illustrating a linked list traversal with two pointers, 'slow' and 'fast.'  The linked list is depicted as a sequence of numbered circles (nodes) 1, 2, 4, 7, and 3, connected by solid arrows indicating the direction of traversal.  A light gray node (4) is visually distinguished. A light blue rectangle labeled 'slow' points down to node 4, and an orange rectangle labeled 'fast' points down to node 3.  Dashed blue arrows show a secondary connection from node 2 to node 4, representing the 'slow' pointer's movement. Dashed orange arrows show a secondary connection from node 7 to node 3, representing the 'fast' pointer's movement. A gray rounded rectangle to the right displays the condition 'fast.next == null → break,' indicating that the traversal stops when the 'fast' pointer reaches the end of the list (null).  The overall diagram visualizes a process where two pointers move through a linked list at different speeds, with the condition checking for the end of the list to terminate the process.

One thing we must be careful about is when we should stop advancing the fast pointer. We need to stop when the slow pointer reaches the middle node. When the list length is odd, this happens when fast.next equals null, as we can see above.

What about when the length of the linked list is even? Consider the below example:

Image represents a directed acyclic graph illustrating a coding pattern, possibly related to task scheduling or pipeline processing.  The graph consists of four circular nodes labeled sequentially as 1, 2, 4, and 7, connected by unidirectional arrows indicating the flow of information or execution order.  Node 1 acts as the starting point, receiving input from two rectangular boxes positioned above it. The top box is light blue and labeled 'slow,' while the other is orange and labeled 'fast,' suggesting two different input streams or processing speeds.  Arrows from these boxes point to node 1, indicating that both 'slow' and 'fast' inputs feed into the initial processing step (node 1).  Subsequently, node 1's output flows to node 2, then to node 4, and finally to node 7, representing a linear progression of tasks or stages. The overall structure suggests a sequential process where the initial step (node 1) combines inputs from different sources before proceeding through subsequent stages.
Image represents a directed acyclic graph illustrating a workflow with two distinct paths.  The graph consists of five nodes (circles) numbered 1, 2, 4, and 7, representing sequential steps in a process.  Nodes 1, 2, 4, and 7 are connected by solid arrows indicating the primary flow. Node 1 connects to node 2, node 2 connects to node 4, and node 4 connects to node 7.  Above the graph, two rectangular boxes labeled 'slow' (in light blue) and 'fast' (in orange) indicate alternative paths. A light blue dashed arrow connects node 1 to node 2, representing a 'slow' path, while an orange dashed arrow connects node 1 to node 2 and another connects node 4 to node 2, representing a 'fast' path. The 'slow' path implies a direct progression from node 1 to node 2, while the 'fast' path suggests a faster route potentially skipping some steps or utilizing parallel processing, indicated by the feedback loop from node 4 to node 2.  The overall diagram visualizes a process where data or tasks flow through different stages, with the possibility of choosing between a slower, linear path and a faster, potentially more complex path.
Image represents a diagram illustrating a coding pattern, likely related to linked lists or similar data structures.  The diagram shows a sequence of numbered nodes (1, 2, 4, 7) connected by solid arrows representing a forward traversal. Node 4 is shaded gray, suggesting a different state or significance. A light-blue box labeled 'slow' points to node 4 with a downward-pointing arrow, indicating a pointer or iterator moving slowly through the sequence. An orange box labeled 'fast' points to the word 'null' with a downward-pointing arrow, representing a faster pointer. Dashed arrows, blue and orange respectively, show the movement of the slow and fast pointers. The orange dashed arrow loops from node 7 back to node 4, and the blue dashed arrow loops from node 2 back to node 4. A dashed-line box contains the code snippet 'fast == null → break', indicating a conditional statement where the algorithm terminates when the fast pointer reaches a null value.  The overall structure depicts a process where two pointers traverse a data structure at different speeds, with the termination condition based on the fast pointer's position.

As you can see, to have slow point at the second middle node, we'd need to stop fast when it reaches a null node.

Implementation

python
from ds import ListNode
    
def linked_list_midpoint(head: ListNode) -> ListNode:
    slow = fast = head
    # When the fast pointer reaches the end of the list, the slow pointer will be at
    # the midpoint of the linked list.
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow

Complexity Analysis

Time complexity: The time complexity of linked_list_midpoint is O(n)O(n)O(n) because we traverse the linked list linearly using two pointers.

Space complexity: The space complexity is O(1)O(1)O(1).

Stop and Think

What if you were asked to modify your algorithm to return the first middle node when the linked list is of even length?

Answer: Following the same method, we should stop advancing the fast pointer when fast.next.next is null. This way, slow will end up pointing to the first middle node:

Image represents a diagram illustrating a linked list traversal using two pointers, 'slow' and 'fast'.  Four nodes, numbered 1, 2, 4, and 7, are depicted as circles, connected by solid arrows indicating the direction of traversal. Node 2 is shaded gray. A light-blue rectangular box labeled 'slow' points to node 2 with a light-blue arrow, and an orange rectangular box labeled 'fast' points to node 4 with an orange arrow.  Dashed orange and light-blue curved arrows connect node 1 to node 2, suggesting the movement of the 'slow' and 'fast' pointers.  A separate, light-gray, dashed-bordered rectangle contains the code snippet 'fast.next.next == null → break', indicating a conditional check within the algorithm: if the node two steps ahead of the 'fast' pointer is null (end of the list), the loop breaks.

Interview Tip

Tip: Be prepared to address potential gaps in the information provided. During an interview, it’s possible the interviewer won't specify which middle node should be returned for linked lists of even length, leaving it up to you to recognize and address this special scenario. You might be expected to identify ambiguities like this and actively engage with the interviewer to discuss a suitable resolution.

Happy Number

Happy Number

In number theory, a happy number is defined as a number that, when repeatedly subjected to the process of squaring its digits and summing those squares, eventually leads to 1. An unhappy number will never reach 1 during this process, and will get stuck in an infinite loop [1].

Given an integer, determine if it's a happy number.

Example:

python
Input: n = 23
Output: True

Explanation: 222^222 + 323^232 = 13 ⇒ 121^212 + 323^232 = 10 ⇒ 121^212 + 020^202 = 1

Intuition

We can simulate the process of identifying a happy number by repeatedly summing the squares of each digit of a number, and then applying the same process to the resulting sum.

According to the problem statement, this process could conclude in one of two ways:

  • Case 1: the process continues until the final number is 1.
  • Case 2: the process gets stuck in an infinite loop.

If we diagram both scenarios, we observe something interesting:

Image represents two distinct cases illustrating data flow, possibly representing different coding patterns or algorithms.  Case 1 shows a linear sequence of four circular nodes labeled 23, 13, 10, and 1, respectively.  Each node is connected to the next by a unidirectional arrow, indicating a sequential flow of information from 23 to 1. Case 2 depicts a circular flow of data. Eight circular nodes, labeled 116, 38, 73, 58, 37, 16, 4, and 20, are arranged in a circle.  Unidirectional arrows connect each node to the next in a clockwise direction, forming a closed loop.  Additionally, a separate circular sequence of three nodes (89, 145, and 42) is connected to the main circular sequence via arrows from node 58 to 89 and from node 42 to node 20.  The numbers within each node likely represent data values or states, and the arrows signify the direction of data processing or transformation.

This looks quite similar to a linked list problem. In particular, the problem of determining if a linked list has a cycle (case 2) or doesn’t (case 1).

We can reduce this problem to the same cycle detection challenge as the Linked List Loop problem. By applying the fast and slow pointer technique (i.e., Floyd's Cycle Detection algorithm), we can efficiently determine if a cycle exists.

However, in this problem, we don't have an actual linked list to perform the fast and slow pointer algorithm on. Therefore, we need to find a way to traverse the sequence of numbers generated in the happy number process.

Conveniently, we already know what the “next” number in the sequence is for any number x. As described in the problem statement, the next number can be calculated by summing the square of each digit of x. So, if each number were a node in a linked list, we could get the “next” node by calculating the next number in the sequence.

Getting the next number in the sequence To calculate the next number of x, we need a way to access each digit of x. This can be done in two steps:

  1. The modulo operation (x % 10) is used to extract the last digit of a number x.
  2. Divide x by 10 (x = x / 10) to truncate the last digit, positioning the next digit as the new last digit.

We can see this unfold in full below for x = 123:

Image represents a flowchart illustrating the `get_next_num(x)` function, which takes an integer `x` (initially 123) as input and calculates a new number (`next_num`). The process involves iteratively extracting digits from `x` using the modulo operator (`%`) and integer division (`/`).  First, the rightmost digit (3) is obtained via `x % 10`, then `x` is updated to `x / 10` (12). This process repeats, extracting the next digit (2) and then (1). Each extracted digit is circled in orange.  These digits are then used to calculate `next_num` by summing their squares (3² + 2² + 1² = 14). The iteration stops when `x` becomes 0.  Arrows indicate the flow of data, showing how the extracted digits (`digit`) are derived and subsequently used in the final calculation of `next_num`. The function's output, `next_num`, is explicitly calculated as 14 at the bottom.

Now that we have a way to traverse the sequence, we can implement Floyd’s Cycle Detection algorithm. To start, set the fast and slow pointers at the start of this sequence. Then move the pointers as follows:

  • Advance the slow pointer one number at a time (slow = get_next_num(slow)).
  • Advance the fast pointer two numbers at a time (fast = get_next_num(get_next_num(fast))).

If the fast and slow pointers meet during the process, it indicates the presence of a cycle, meaning the number is not a happy number. Otherwise, the algorithm will end when we reach 1, in which case the number is a happy number.

Implementation

python
def happy_number(n: int) -> bool:
    slow = fast = n
    while True:
        slow = get_next_num(slow)
        fast = get_next_num(get_next_num(fast))
        if fast == 1:
            return True
        # If the fast and slow pointers meet, a cycle is detected. Hence, 'n' is not
        # a happy number.
        elif fast == slow:
            return False
    
def get_next_num(x: int) -> int:
    next_num = 0
    while x > 0:
        # Extract the last digit of 'x'.
        digit = x % 10
        # Truncate (remove) the last digit from 'x' using floor division.
        x //= 10
        # Add the square of the extracted digit to the sum.
        next_num += digit ** 2
    return next_num

Complexity Analysis

Time complexity: The time complexity of happy_number is O(log⁡(n))O(\log(n))O(log(n)). The full analysis of this time complexity is quite complicated and beyond the scope of interviews. For interested readers, please see the detailed analysis below.

Space complexity: The space complexity is O(1)O(1)O(1).

Interview Tip

Tip: Visualize the problem. At first glance, this problem seems like it requires mathematical reasoning to solve. However, when we visualized the problem, we were able to formulate a solution using an algorithm we already know (Floyd’s Cycle Detection). Visualizing a problem can help uncover hidden patterns or data structures that can lead to the solution.

Happy Number Time Complexity Analysis

The following time complexity analysis establishes an upper bound on the steps required to determine a happy number. In this analysis, we define the "next number" of a number nnn as the result obtained by summing the squares of the digits of nnn.

1) Upper limit for the next number For any number nnn with a fixed number of digits, the maximum value for its successor is achieved when all its digits are 9. For instance, the maximum next number from a 3-digit number happens when this 3-digit number is 999.

2) Size of the next number relative to the number of digits

  • If a number nnn has 1 or 2 digits, it’s possible for the next number to be larger than nnn (e.g., the next number of 99 is 162, which is one digit longer).
  • If a number nnn has 3 or more digits, the next number is always smaller than the original value of nnn. The table below highlights how the largest number with 3 or more digits has a smaller next number.
DigitsLargest NumberNext Number
1981
299162
3999243
49999324
599999405
6999999486
.........

3) Implications for cycles Since the next number is always smaller for numbers with three or more digits, it means a cycle can only commence in the happy number process once the number falls below 243 (the next number of 999). This is because we’ve observed that any number nnn larger than 243 will have the next number smaller than nnn. However, once we fall below 243, the next number can potentially be larger, potentially cycling back to a previous number.

4) Time complexity for numbers less than 243 Once a number falls below 243, the algorithm will take less than 243 steps to either converge to 1 or to cycle back to a previous number in the sequence. Therefore, since the length of the cycle or the number of steps to reach 1 is bounded by 243, the time complexity of Floyd’s cycle detection algorithm for numbers less than 243 is O(1)O(1)O(1).

5) Time complexity for numbers greater than 243 The number of digits in a number is approximately equal to log⁡(n)\log(n)log(n) (base 10). So, the calculation of the next number of nnn will take approximately log⁡(n)\log(n)log(n) steps (i.e., get_next_num will take log⁡(n)\log(n)log(n) steps to execute).

Let's call nnn’s next number n2n^2n2. The next number after n2n^2n2 (n3n^3n3) will take approximately log⁡(n2)\log(n^2)log(n2) steps to calculate. The next number after n3n^3n3 (n4n^4n4) will take approximately log⁡(n3)\log(n^3)log(n3) steps to calculate, and so on. From this, we can summarize the time complexity of this process as O(log⁡(n)+log⁡(n2)+log⁡(n3)+…)O(\log(n)+\log(n^2)+\log(n^3)+…)O(log(n)+log(n2)+log(n3)+…). Since we've established that n>n2>…>nkn>n^2>…>n^kn>n2>…>nk where nkn^knk is the last number greater than 243, the dominant component of this time complexity is O(log⁡(n))O(\log(n))O(log(n)). So, the time complexity for numbers greater than 243 is O(log⁡(n))O(\log(n))O(log(n)).

Conclusion When nnn is less than 243, the time complexity is O(1), and when nnn is greater than 243, the time complexity is O(log⁡(n))O(\log(n))O(log(n)). Therefore, the overall time complexity of the algorithm is O(log⁡(n))O(\log(n))O(log(n)).

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. It might also be helpful to mention what parts of the problem or the solution make it difficult to analyze the time complexity. In this problem, it’s initially unclear how many steps the happy number process would take before reaching 1 or revealing a cycle.

Chapter 5

Sliding Windows

~18 min read

Introduction to Sliding Windows

Introduction to Sliding Windows

Intuition

The sliding window technique is a subset of the two-pointer pattern, as it uses two pointers (generally left and right) to define the bounds of a "window" in iterable data structures like arrays. The window defines a subcomponent of the data structure (e.g., a subarray or substring), and it slides across the data structure unidirectionally, typically searching for a subcomponent that meets a certain requirement.

The image represents a sliding window concept often used in algorithms.  It shows a sequence of elements represented by dots enclosed in square brackets `[ . . . . . . ]`. A light-blue rectangular area highlights a subset of these elements, labeled 'window'.  Two rectangular boxes, labeled 'left' and 'right,' are positioned above the sequence.  A downward-pointing arrow connects 'left' to the leftmost dot within the window, and a similar arrow connects 'right' to the rightmost dot within the window.  A light-blue upward-pointing arrow labeled 'window' indicates that this window can move along the sequence.  The arrangement visually demonstrates how the window's position is controlled by the 'left' and 'right' pointers, allowing it to slide across the data sequence, processing a fixed-size subset at a time.

Sliding windows are particularly valuable in scenarios where algorithms might otherwise rely on using two nested loops to search through all possible subcomponents to find an answer, resulting in an O(n2)O(n^2)O(n2) time complexity, or worse. When using a sliding window, the subcomponent(s) we’re looking for can usually be found in O(n)O(n)O(n) time, in comparison. But before discussing how they work, let’s establish some terminology:

  • To expand the window is to advance the right pointer:
  • To shrink the window is to advance the left pointer:
  • To slide the window is to advance both the left and right pointers: Note that sliding is equivalent to expanding and shrinking the window at the same time.

Now, let’s break down the two main types of sliding window algorithms:

  1. Fixed sliding window.
  2. Dynamic sliding window.

Fixed Sliding Window

A fixed sliding window maintains a specific length as it slides across a data structure.

Image represents a visual depiction of coding patterns related to window manipulation.  The image shows three sequential stages.  Each stage features a light-blue rectangular 'window' containing several black dots representing data elements, enclosed in square brackets.  Above each window, rectangular boxes labeled 'left' (in gray) and 'right' (in orange) indicate directional operations.  In the first stage, a gray 'left' box points down with a solid arrow to the leftmost dot, while an orange 'right' box points down with a dashed orange arrow to a dot near the right edge.  The text below the first window reads 'expand window to desired size,' indicating that the window expands to accommodate new data.  The second and third stages depict a 'slide' operation.  Here, the 'left' and 'right' boxes point to the leftmost and rightmost dots respectively, using dashed orange arrows.  The text 'slide' appears below the second and third windows.  Solid black arrows connect the stages, showing the transition from expanding the window to sliding it.  The overall diagram illustrates how data is added to (expand) and then moved within (slide) a window, showcasing different data manipulation techniques.

We use the fixed sliding window technique when the problem asks us to find a subcomponent of a certain length. If we know what this length is, we can fix our window to that length and slide it through the array.

If a fixed window of length k traverses a data structure from start to finish, it’s guaranteed to see every subcomponent of length k in that data structure.

Below is a generic template of how a fixed sliding window traverses a data structure.

python
left = right = 0
while right < n:
    # If the window has reached the expected fixed length, we slide the window (move
    # both left and right).
    if right - left + 1 == fixed_window_size:
        # Process the current window.
        result = process_current_window()
        left += 1
    right += 1

Dynamic Sliding Window

Unlike fixed sliding windows, dynamic windows can expand or shrink in length as they traverse a data structure.

Image represents a sequence of three diagrams illustrating a data structure manipulation.  The first diagram shows a light-blue rounded rectangle representing a data structure containing several black dots, enclosed in square brackets.  A grey box labeled 'left' points down with an arrow to the leftmost dot, and an orange box labeled 'right' points down with an arrow to a dot near the right edge.  Orange dashed lines connect the dots, indicating an expansion of the structure. The label 'expand' is below the rectangle.  A solid arrow points to the second diagram, which shows the same structure but with fewer dots, labeled 'shrink,' indicating a reduction in size.  The 'left' and 'right' boxes similarly point to the leftmost and rightmost dots, respectively.  Finally, a solid arrow leads to the third diagram, which is identical in structure to the first, again labeled 'expand,' showing the data structure expanding again.  The 'left' and 'right' boxes maintain their positions and directional arrows.  The overall sequence demonstrates a dynamic data structure that can expand and shrink, with the 'left' and 'right' labels possibly indicating pointers or access points to the structure's boundaries.

Generally, dynamic sliding windows can be applied to problems that ask us to find the longest or shortest subcomponent that satisfies a condition (for example, a condition could be that the numbers in the window must be greater than 10).

When finding the longest subcomponent, the dynamics of expanding and shrinking are typically as follows:

  • If a window satisfies the condition, expand it to see if we can find a longer window that also meets the condition.
  • If the condition is violated, shrink the window until the condition is met again.

Below is a generic template demonstrating how this works:

python
left = right = 0
while right < n:
    # While the condition is violated, the window is invalid, so shrink the window by
    # advancing the left pointer.
    while condition is violated:
        left += 1
    # Once the window is valid, process it and then expand the window by advancing the
    # right pointer.
    result = process_current_window()
    right += 1

Note that the provided templates for fixed and dynamic windows primarily emphasize the movement of the left and right pointers rather than the specifics of updating the window itself. If the problem requires updating the window, the logic around it will be highly context-dependent. This is explored in detail through specific problems in this chapter.

Real-world Example

Buffering in video streaming: In video streaming, a dynamic sliding window can be used to manage buffering and ensure smooth playback.

For instance, when streaming a video, the player downloads chunks of the video data and stores them in a buffer. A sliding window controls which part of the video is buffered, with the window "sliding" forward as the video plays. The sliding window ensures the video player can adapt to varying network conditions by dynamically adjusting the buffer size and position, leading to a smoother streaming experience for the viewer.

Chapter Outline

Image represents a hierarchical diagram illustrating the concept of 'Sliding Windows' in coding patterns.  At the top, a rounded rectangle labeled 'Sliding Windows' acts as the parent node.  From this node, two dashed lines descend, each pointing to a child node represented by a rounded rectangle with a dashed border. The left child node is labeled 'Fixed Sliding Window' and contains a single bullet point: 'Substring Anagrams'. The right child node is labeled 'Dynamic Sliding Window' and contains two bullet points: 'Longest Substring With Unique Characters' and 'Longest Uniform Substring After Replacements'.  The arrangement shows that 'Sliding Windows' is a broader category encompassing both 'Fixed Sliding Window' and 'Dynamic Sliding Window' approaches, with specific problem examples listed under each subtype.  No URLs or parameters are present.

Substring Anagrams

Substring Anagrams

Given two strings, s and t , both consisting of lowercase English letters, return the number of substrings in s that are anagrams of t.

An anagram is a word or phrase formed by rearranging the letters of another word or phrase, using all the original letters exactly once.

Example:

python
Input: s = 'caabab', t = 'aba'
Output: 2

Explanation: There is an anagram of t starting at index 1 ("caabab") and another starting at index 2 ("caabab")

Intuition

We can reframe how we think about an anagram by altering the provided definition. A substring of s qualifies as an anagram of t if it contains exactly the same characters as t in any order.

For a substring in s to be an anagram of t, it must have the same length as t (denoted as len_t). This means we only need to consider substrings of s that match the length len_t, which saves us from examining every possible substring.

To examine all substrings of a fixed length, len_t, we can use the fixed sliding window technique because a window of len_t is guaranteed to slide through every substring of this length. We can see this in the example below:

Image represents a visual depiction of a sliding window algorithm, likely for string manipulation or pattern matching, where `len_t = 3` defines the window size.  The diagram shows four stages. Each stage features two orange rectangular boxes labeled 'left' and 'right,' representing pointers or indices at the beginning and end of a sliding window, respectively.  Arrows descend from these boxes to a light-blue rectangular box containing a sequence of characters ('c', 'a', 'a', 'b', 'a', 'b').  The sequence represents the input string.  In each stage, the window, indicated by the light-blue box, slides one position to the right. The first stage shows the window encompassing 'c', 'a', 'a'. The second stage shows the window shifted to 'a', 'a', 'b'. The third stage shows 'a', 'b', 'a'. The fourth stage shows 'b', 'a', 'b'.  Gray arrows separate the stages, indicating the progression of the sliding window across the input string.  The algorithm appears to be processing the string in a left-to-right manner, with the window size remaining constant at 3.

Now, we just need a way to check if a window is an anagram of t. Remember that in an anagram, the order of the letters doesn’t matter; only the frequency of each letter does. By comparing the frequency of each character in a window against the frequencies of characters in string t, we can determine if that window is an anagram of t.

Let's explore this reasoning with an example. Before starting the sliding window algorithm, we need a way to store the frequencies of the characters in string t. We could use a hash map for this, or an array, expected_freqs, to store the frequencies of each character in string t.

expected_freqs is an integer array of size 26, with each index representing one of the lowercase English letters (0 for 'a', 1 for 'b', and so on, up to 25 for 'z').

Image represents a diagram illustrating the concept of frequency counting in a string.  The diagram shows a string `s = 'caababb'` and a target string `t = 'aba'`.  Curved arrows connect the characters of `t` to a frequency array labeled `expected_freqs`. This array represents the expected frequencies of characters in `t`.  Specifically, the array `expected_freqs` shows that 'a' appears twice (2), 'b' appears once (1), 'c' appears zero times (0), and all other characters from 'd' to 'z' also appear zero times (represented by `... 0`). The arrangement visually demonstrates how the characters in the target string `t` are mapped to their corresponding counts within the `expected_freqs` array, which is used to determine the frequency of each character in the target string.

To set up the sliding window algorithm, let’s define the following components:

  • Left and right pointers: Initialize both at the start of the string to define the window's boundaries.
  • window_freqs: Use an array of size 26 to keep track of the frequencies of characters within the window.
  • count: Maintain a variable to count the number of anagrams detected.

Before we slide the window, we first need to expand it to a fixed length of len_t. This can be done by advancing the right pointer until the window length is equal to len_t. As we expand, ensure to keep window_freqs updated to reflect the frequencies of the characters in the window:

Image represents a diagram illustrating a data structure or algorithm related to character frequency counting within a sliding window.  Two rectangular boxes labeled 'left' and 'right' are positioned above a light-blue square labeled 'C'. Arrows descend from 'left' and 'right' pointing to 'C', suggesting that 'left' and 'right' might represent boundaries of a sliding window.  To the right of the 'C' square, a sequence of characters 'caabab' is shown.  Adjacent to this character sequence, a variable named 'window_freqs' is defined and assigned a list or array. This array appears to represent the frequency of each character within the window, indicated by the numbers 0, 0, 1, followed by an ellipsis (...) and ending with 0.  Subscripts beneath the numbers in the array 'window_freqs' show that the values correspond to characters 'a', 'b', 'c', ..., 'z', implying that the array tracks the frequency of each character from 'a' to 'z' within the defined window.  The diagram likely depicts a step in a process where the window moves across a larger sequence of characters, updating the 'window_freqs' array at each step.
Image represents a sliding window mechanism demonstrating character frequency counting.  Two rectangular boxes labeled 'left' and 'right' (in gray and orange respectively) point downwards with arrows towards a light-blue box containing the character 'c' and followed by the character sequence 'aabab'.  The arrows indicate the movement of a sliding window of size one character. The 'left' box indicates the leftmost position of the window, and the 'right' box indicates the rightmost position.  To the right of the character sequence, a variable named `window_freqs` is defined and assigned a list representing character frequencies within the window.  The list shows '1' for 'a', '0' for 'b', '1' for 'c', and ellipses (...) indicating continuation, ending with '0' for 'z'.  The numbers in the list represent the count of each character within the current window, with the characters 'a' to 'z' listed below the corresponding frequency values.  The diagram illustrates how the window moves across the sequence, updating the `window_freqs` list to reflect the character frequencies in each position.
Image represents a sliding window over a sequence of characters ('c', 'a', 'a', 'b', 'a', 'b').  A rectangular box highlights the current window, labeled 'len_t' indicating its length.  Above the window, two rectangular boxes labeled 'left' and 'right' show the direction of window movement.  An arrow points from 'left' to the leftmost character of the window, and a dashed arrow points from 'right' to the second 'a' in the window, suggesting the window's movement. To the right, a variable named `window_freqs` is defined as an array; this array represents the frequency of each character within the current window.  The array shows '2' for 'a', '0' for 'b', '1' for 'c', and ellipses (...) indicating other characters, finally showing '0' for 'z'.  The characters 'a', 'b', 'c', and 'z' are subscripted under their corresponding frequencies in the array, clarifying which character each frequency represents.

Once the window is at the fixed length of len_t, we can check if it’s an anagram of t by checking if the expected_freqs and window_freqs arrays are the same. This can be done in constant time since it requires only 26 comparisons, one for each lowercase English letter.

To slide this window across the string, advance both the left and right pointers one step in each iteration. Ensure to keep window_freqs updated by incrementing the frequency of each new character at the right pointer and decrementing the frequency of the character at the left pointer as we move past this left character:

Image represents a Python-like array assignment statement.  The statement `expected_freqs = [...]` assigns a numerical array to the variable `expected_freqs`. The array is enclosed in square brackets `[]` and contains numerical values representing frequencies.  Specifically, the first element is 2, associated with the character 'a'; the second is 1, associated with 'b'; the third is 0, associated with 'c'; and the remaining elements (indicated by an ellipsis '...') are all 0, extending up to and including the element associated with the character 'z'.  The characters 'a' through 'z' are shown below the corresponding frequency values, indicating that the array likely represents the expected frequencies of each letter in the alphabet.  There are no URLs or parameters present.
Image represents a data flow diagram illustrating a comparison process.  On the left, two labeled boxes, 'left' and 'right,' point downwards with arrows to a light-blue rectangular box containing the character sequence 'c a a b a b'. This sequence appears to be a data window. To the right, the variable `window_freqs` is defined and assigned an array-like value `[2 0 1 ... 0]`, where the subscripts 'a', 'b', 'c', and 'z' indicate that the array represents the frequencies of characters 'a', 'b', 'c', and so on, up to 'z' within the window.  The ellipsis (...) denotes that the array continues with frequencies for characters not explicitly shown.  Below, a dashed-line box shows a comparison: `window_freqs != expected_freqs`, indicating that the calculated frequencies (`window_freqs`) are being checked against some pre-defined `expected_freqs`. An arrow labeled 'continue' emerges from this box, suggesting that the process continues if the comparison is true (i.e., the frequencies do not match the expected values).
Image represents a sliding window algorithm visualization.  Two orange rectangular boxes labeled 'left' and 'right' point downwards with arrows to a light-blue rectangular box containing a sequence of characters 'c a a b a b'.  Dashed lines extend from the arrows to indicate the window's boundaries. The 'left' pointer is positioned at the 'a' and the 'right' pointer at the 'b', defining the current window. Above, a list `window_freqs = [2 1 0 ... 0]` shows the frequency count of characters within the window (2 'a's, 1 'b', 0 'c', etc., up to 'z').  To the right, a light-grey box depicts a conditional check: `window_freqs == expected_freqs`, implying a comparison with a pre-defined frequency array (`expected_freqs`). If the condition is true, a counter `count` is incremented (`count += 1`).  The diagram illustrates how the window moves across the character sequence, updating `window_freqs` and checking for matches against `expected_freqs` at each step.
Image represents a sliding window algorithm segment.  Two orange rectangular boxes labeled 'left' and 'right' point downwards with arrows to a light-blue rectangular box containing the character sequence 'a b a'.  Dashed arrows indicate the window's movement.  The character 'c' precedes the window, and 'b' follows it. To the right,  'window_freqs = [2<sub>a</sub> 1<sub>b</sub> 0<sub>c</sub> ... 0<sub>z</sub>]' shows a frequency array;  '2' indicates two occurrences of 'a' within the window, '1' shows one 'b', and '0' represents zero occurrences of 'c' and all other characters (represented by '...'). A light-grey dashed-line box displays the condition 'window_freqs == expected_freqs' which, if true, increments a counter: 'count += 1'.  The diagram illustrates how the algorithm checks character frequencies within a sliding window against an expected frequency distribution ('expected_freqs'), updating a counter when a match is found.
Image represents a sliding window algorithm visualization.  Two orange rectangular boxes labeled 'left' and 'right' point downwards via orange arrows to a light-blue rectangular box containing the character sequence 'c a a b'.  Dotted orange lines indicate the window's movement. Above, 'window_freqs = [1 2 0 ... 0]' shows an array representing the frequency of characters ('a', 'b', 'c', ..., 'z') within the current window, with 'a' having frequency 1, 'b' having frequency 2, and others having frequency 0.  A light-grey dashed-line box displays the condition 'window_freqs != expected_freqs' and an arrow pointing right labeled 'continue,' indicating that if the window's character frequencies do not match the 'expected_freqs' (not shown), the algorithm continues processing.  The overall diagram illustrates a step in a character frequency-based algorithm where a sliding window is used to compare observed frequencies against expected frequencies.

Once we’ve finished processing all substrings of length len_t, we can return count, which represents the number of anagrams found.

A small optimization we can make is returning 0 if t's length exceeds the length of s because forming an anagram of t from the substrings of s is impossible if t is longer.

Implementation

In Python, we can use ord(character) - ord('a') to find the index of a lowercase English letter in an array of size 26. The ord function takes an integer and returns its ASCII value. This formula calculates the distance of this character from 'a', resulting in an index between 0 and 25.

python
def substring_anagrams(s: str, t: str) -> int:
    len_s, len_t = len(s), len(t)
    if len_t > len_s:
        return 0
    count = 0
    expected_freqs, window_freqs = [0] * 26, [0] * 26
    # Populate 'expected_freqs' with the characters in string 't'.
    for c in t:
        expected_freqs[ord(c) - ord('a')] += 1
    left = right = 0
    while right < len_s:
        # Add the character at the right pointer to 'window_freqs' before sliding the
        # window.
        window_freqs[ord(s[right]) - ord('a')] += 1
        # If the window has reached the expected fixed length, we advance the left
        # pointer as well as the right pointer to slide the window.
        if right - left + 1 == len_t:
            if window_freqs == expected_freqs:
                count += 1
            # Remove the character at the left pointer from 'window_freqs' before
            # advancing the left pointer.
            window_freqs[ord(s[left]) - ord('a')] -= 1
            left += 1
        right += 1
    return count

Complexity Analysis

Time complexity: The time complexity of substring_anagrams is O(n)O(n)O(n), where nnn denotes the length of s. Here’s why:

  • Populating the expected_freqs array takes O(m)O(m)O(m) time, where m denotes the length of t. Since mmm is guaranteed to be less than or equal to nnn at this point, it’s not a dominant term in the time complexity.
  • Then, we traverse string s linearly with two pointers, which takes O(n)O(n)O(n) time.
  • Note that at each iteration, the comparison performed between the two frequency arrays (expected_freqs and window_freqs) takes O(1)O(1)O(1) time because each array contains only 26 elements.

Space complexity: The space complexity is O(1)O(1)O(1) because each frequency array contains only 26 elements.

Longest Substring With Unique Characters

Longest Substring With Unique Characters

Given a string, determine the length of its longest substring that consists only of unique characters.

Example:

python
Input: s = 'abcba'
Output: 3

Explanation: Substring "abc" is the longest substring of length 3 that contains unique characters ("cba" also fits this description).

Intuition

The brute force approach involves examining all possible substrings and checking if any consist of exclusively unique characters. Let’s break down this approach:

  • Checking a substring for uniqueness can be done in O(n)O(n)O(n) time by scanning the substring and using a hash set to keep track of each character, where n denotes the length of s. If we encounter a character already in the hash set, we know it’s a duplicate character.
  • Iterating through all possible substrings takes O(n2)O(n^2)O(n2) time.

This means the brute force approach would take O(n3)O(n^3)O(n3) time overall. This is quite slow, largely because we look through every substring. Is there a way to reduce the number of substrings we examine?

Sliding window Sliding window approaches can be quite useful for problems that involve substrings. In particular, because we’re looking for the longest substring that satisfies a specific condition (i.e., contains unique characters), a dynamic sliding window algorithm might be the way to go, as discussed in the introduction.

We can categorize any window in two ways. A window either:

  1. Consists only of unique characters (a window with no duplicate characters).
Image represents a visual depiction of a data structure or algorithm concept related to character uniqueness.  A light-blue rectangular box contains the characters 'a', 'b', and 'c' arranged horizontally.  A gray line underlines this box, with a downward-pointing arrow extending from the center of the line. Below the arrow, the text 'no duplicate characters' is displayed, indicating a constraint or condition.  The characters 'b' and 'a' appear to the right of the box, suggesting a potential input stream or sequence. The overall arrangement implies a process where the characters within the box are checked against subsequent characters (like 'b' and 'a') to ensure no duplicates exist, possibly as part of a validation or filtering step within a larger algorithm.
  1. Contains at least one character of a frequency greater than 1.

If A is true, we should expand the window by advancing the right pointer to find a longer window that also contains no duplicates.

If B is true because we encounter a duplicate character in the window, we should shrink the window by advancing the left pointer until it no longer contains a duplicate.

Let’s try this strategy over the following example. We initialize a hash set to keep track of the characters in a window.

Image represents a sequence of characters 'a', 'b', 'c', 'b', 'a' displayed horizontally, each character having a corresponding numerical index (0, 1, 2, 3, 4) shown below in a lighter gray font.  This sequence is positioned to the left of a rectangular box labeled 'hash set'.  There are no explicit connections drawn between the character sequence and the hash set, implying a conceptual relationship rather than a direct data flow. The image likely illustrates the concept of adding elements from the sequence into a hash set, where the hash set would only contain unique elements ('a', 'b', 'c' in this case), eliminating duplicates.  The indices suggest an ordered input, while the hash set represents an unordered collection of unique elements.

To implement the sliding window technique, we should establish the following:

  • Left and right pointers: Initialize both at the start of the string to define the window's boundaries.
  • hash_set: Maintain a hash set to record the unique characters within the window, updating it as the window expands. Note, the hash set shown in the diagram displays its state before the character at the right pointer is added to it.

Now, let’s start looking for the longest window. Expand the window from the beginning of the string by advancing the right pointer. Keep expanding until a duplicate character is found:

Image represents a diagram illustrating a sliding window algorithm.  Two rectangular boxes labeled 'left' and 'right' point downwards to a sequence of characters 'a b c b a' indexed 0 through 4.  A light-blue box highlights the first character 'a' at index 0.  To the right, an empty rectangular box labeled 'hash_set' is shown. A dashed-line rectangle contains the text ''a' not in hash_set → expand window,' indicating that if the character 'a' is not found within the hash_set, the window expands.  The arrows and text describe the conditional logic of the algorithm: the 'left' and 'right' pointers control the window's boundaries, and the 'hash_set' is used to track characters within the current window; the window expands if a character is not present in the hash_set.
Image represents a sliding window algorithm visualization.  Two rectangular boxes labeled 'left' (gray) and 'right' (orange) point downwards with arrows to a light-blue rectangular box containing a sequence of characters: 'a', 'b', 'c', 'b', 'a', indexed from 0 to 4.  A dashed orange arrow curves from index 0 to index 1, indicating a window's movement.  Separately, a rectangular box labeled 'hash_set' contains the character 'a'. A light-gray, dashed-bordered rectangle displays the condition ' 'b' not in hash_set → expand window', illustrating that if the character 'b' is not found within the hash_set, the window expands.  The overall diagram shows the interaction between a sliding window (represented by the light-blue box and the arrows), a hash set (containing elements), and a conditional statement that governs the window's expansion based on the presence or absence of elements in the hash set.
Image represents a sliding window algorithm visualization.  A light-blue rectangular box labeled 'a b c b a' with indices 0 through 4 underneath represents the input array.  A gray box labeled 'left' points down to the beginning of the array, and an orange box labeled 'right' points to the element 'c' at index 2. A dashed arrow from the 'b' at index 1 to the 'c' at index 2 indicates the movement of the right pointer.  Separately, a box labeled 'hash_set' contains the elements 'a' and 'b'. A light-gray, dashed-bordered rectangle displays the condition ' 'c' not in hash_set → expand window,' illustrating that because 'c' is not present in the hash set, the window expands (the right pointer moves).  The overall diagram shows the interaction between a sliding window, a hash set used for tracking elements within the window, and the condition that triggers window expansion.
Image represents a sliding window algorithm visualization for finding the longest substring without repeating characters.  A sequence 'abcba' is shown, with a sliding window indicated by a light red background.  A gray box labeled 'left' points down to the leftmost element ('a') of the window, and an orange box labeled 'right' points to the current rightmost element ('b').  The window's indices (0-4) are displayed below the sequence.  A dashed arrow from the second 'b' loops back to itself, highlighting the duplicate.  Separately, a box represents a 'hash_set' containing elements 'a', 'b', and 'c', with 'b' shaded to indicate its presence.  A light gray dashed box to the right explains the algorithm's logic: if 'b' is found in the 'hash_set' (indicating a duplicate), the window shrinks.  The red text 'window contains duplicate' clarifies the current state.

We see above that the ‘b’ at index 3 is a duplicate character in the window because ‘b’ is already in the hash set.

Now that we found a duplicate, we should shrink the window by advancing the left pointer until the window no longer contains a duplicate ‘b’. Once the window is valid again, continue expanding:

Image represents a sliding window algorithm for finding duplicates within a sequence.  Two rectangular boxes labeled 'left' (orange) and 'right' (gray) point downwards to a sequence 'a b c b a' with indices 0-4 displayed below.  The 'left' pointer indicates the start of the window, currently at index 0. The 'right' pointer indicates the end of the window, currently at index 3.  A dashed orange line connects the 'left' box to the 'b' at index 1, highlighting the window's movement. The sequence portion 'b c b' (indices 1-3) is highlighted in light red, indicating the current window.  Separately, a rectangular box labeled 'hash_set' contains 'b' (highlighted in gray) and 'c', representing a set storing elements within the current window.  A light gray, dashed-bordered rectangle displays the text ''b' still in hash_set → shrink window', indicating that because 'b' is already present in the hash_set, the window needs to shrink from the left to remove the duplicate.  The text 'window contains duplicate' in red below the sequence emphasizes the presence of a duplicate within the current window.
Image represents a sliding window algorithm visualization.  At the top, two rectangular boxes labeled 'left' (in orange) and 'right' (in gray) represent pointers indicating the window's boundaries.  Arrows from these boxes point downwards to a sequence of characters 'a b c b a' indexed 0 through 4, indicating the data stream.  A light-blue rectangle highlights the current window encompassing 'c b'. A dashed arrow from 'b' at index 1 points to the 'c' at index 2, suggesting the window's movement. To the right, a rectangular box labeled 'hash_set' contains the character 'c', representing a set storing characters within the current window. Finally, a light-gray, dashed-bordered rectangle describes the algorithm's logic: ' 'b' not in hash_set → expand window', indicating that if a character ('b' in this case) is not found in the hash_set, the window expands.
Image represents a sliding window algorithm visualization.  A sequence of characters 'a', 'b', 'c', 'b', 'a' is shown, indexed from 0 to 4.  Two rectangular boxes labeled 'left' (gray) and 'right' (orange) point downwards with arrows to the sequence, indicating the boundaries of a sliding window.  The window initially encompasses 'c', 'b', and 'a' (indices 2, 3, and 4). A separate box labeled 'hash_set' contains 'c' and 'b', representing a set of characters encountered within the window. A dashed light-blue rectangle highlights the current window. A light-gray, dashed-bordered box describes the algorithm's logic: if the character 'a' is not found in the `hash_set`, the window expands.  The arrows visually connect the 'right' pointer, the window's expansion, and the update of the `hash_set`.  The indices below the character sequence provide positional context.

Expanding the window any further will cause the right pointer to exceed the string’s boundary, at which point we end our search. The longest substring we’ve found with no duplicates is of length 3. We can use the variable max_len to keep track of this length during our search.

Implementation

python
def longest_substring_with_unique_chars(s: str) -> int:
    max_len = 0
    hash_set = set()
    left = right = 0
    while right < len(s):
        # If we encounter a duplicate character in the window, shrink the window until
        # it’s no longer a duplicate.
        while s[right] in hash_set:
            hash_set.remove(s[left])
            left += 1
        # Once there are no more duplicates in the window, update 'max_len' if the
        # current window is larger.
        max_len = max(max_len, right - left + 1)
        hash_set.add(s[right])
        # Expand the window.
        right += 1
    return max_len

Complexity Analysis

Time complexity: The time complexity of longest_substring_with_unique_chars is O(n)O(n)O(n) because we traverse the string linearly with two pointers.

Space complexity: The space complexity is O(m)O(m)O(m) because we use a hash set to store unique characters, where m represents the total number of unique characters within the string.

Optimization

The above approach solves the problem, but we can still optimize it. The optimization has to do with how we shrink the window when encountering a duplicate character. Consider the following example, where the right pointer encounters a duplicate ‘c’:

Image represents a diagram illustrating a coding pattern, possibly related to array manipulation or string processing.  A light pink rectangle displays a sequence of characters: 'c a b c_ d e c a'.  Above this rectangle, two rectangular boxes labeled 'left' and 'right' are positioned, each with a downward-pointing arrow indicating data flow. The 'left' arrow points to the beginning of the sequence, and the 'right' arrow points to the last 'c' within the sequence.  Below the rectangle, a curved arrow points upward from the last 'c' to the first 'c', labeled 'duplicate c', suggesting a duplication or mirroring operation. The underscore ('_') under the third 'c' might indicate a placeholder or a position where an operation is performed. The overall diagram visually depicts a process where a character ('c') is duplicated or inserted at a specific location within a sequence, potentially based on pointers or indices represented by 'left' and 'right'.

In the previous approach, we respond to encountering a duplicate by continuously advancing the left pointer to shrink the window until the window no longer contains a duplicate:

Image represents a diagram illustrating a merging operation, possibly within a sorting algorithm or data structure manipulation.  Two labeled boxes, 'left' (in orange) and 'right' (in gray), represent input sequences.  The 'left' box points downwards with an arrow to a light-blue rectangular box containing the elements 'd' and 'e'.  The 'right' box similarly points to the same light-blue box, adding the element 'c' to its right.  Before the light-blue box, three elements 'c', 'a', and 'b' are shown, with dashed orange arrows indicating they are part of the 'left' input sequence, and are to be merged into the light-blue box.  Another element 'c' is shown to the right of the light-blue box, and an element 'a' is shown further to the right, indicating these are part of the 'right' input sequence. The overall arrangement suggests that elements from the 'left' and 'right' sequences are being combined into a single sorted or ordered sequence within the light-blue box, with the final result potentially being 'c', 'a', 'b', 'c', 'd', 'e', 'c', 'a'.

The crucial insight here is that we advanced the left pointer until it passed the previous occurrence of ‘c’ in the window. This indicates that if we know the index of the previous occurrence of ‘c’, we can move our left pointer immediately past that index to remove it from the window:

Image represents a diagram illustrating a data manipulation or merging process.  A light-blue rectangular box contains the characters 'd', 'e', 'c', and 'a'.  Above this box, two rectangular boxes are labeled 'left' (in orange) and 'right' (in gray). A solid orange arrow descends from 'left' pointing to the 'd' character in the light-blue box, indicating data insertion or modification at that point. A solid gray arrow descends from 'right', pointing to the 'c' character in the light-blue box, suggesting data insertion or modification at that location.  To the left of the light-blue box, the characters 'c', 'a', 'b', and 'c' (with an underscore) are shown. A dashed orange curved line connects the 'c' and 'b' characters to the 'd' character in the light-blue box, suggesting a potential relationship or data flow from the left side, possibly indicating data being moved or combined. The overall diagram likely depicts a coding pattern involving the merging or manipulation of data streams or arrays, with 'left' and 'right' representing input sources and the light-blue box representing the resulting output.

This gives us a new strategy for advancing the left pointer: if the right pointer encounters a character whose previous index (i.e., previous occurrence) is in the window, move the left pointer one index past that previous index.

We can use a hash map (prev_indexes) to store the previous index of each character in the string.

Now we just need to ensure the previous index of a character is in the window. To do this, we compare its index to the left pointer:

  • If this index is after the left pointer, it’s inside the window.
  • If it is before the left pointer, it’s outside the window

Below is a visual of how to check whether a character is inside the window:

Image represents a comparison of how a sliding window algorithm handles duplicate elements within and outside its current view.  The diagram is split into two sections, each illustrating a scenario.  The left section, titled 'Previous index inside the window:', shows a sequence 'cabcdeca' where a sliding window is highlighted, encompassing 'abcde'.  Above this sequence, 'left' and 'right' boxes indicate the window's boundaries. Arrows point from these boxes to the 'a' at the left and 'c' at the right edge of the highlighted section. Below, a box displays the code `prev_indexes['c'] = 3 ≥ left`, indicating that the previous index of 'c' (3) is greater than or equal to the left boundary of the window (implied as 0), signifying that 'c' is already present within the window.  Two arrows then point to the conclusions: 'inside window' and 'duplicate in the window'. The right section, titled 'Previous index outside the window:', shows a similar setup with the sequence 'cabcdeca', but the highlighted window is 'abc'.  Again, 'left' and 'right' boxes point to the 'd' and 'a' respectively.  The code `prev_indexes['a'] = 1 < left` shows that the previous index of 'a' (1) is less than the left boundary, meaning 'a' is outside the current window.  Consequently, two arrows point to 'outside window' and 'not a duplicate in the window'.  Both sections use color-coding: the highlighted window sections are shaded differently (pink/light blue) to distinguish the current window's contents, and the element being checked for duplication ('c' and 'a') is highlighted in a distinct color (peach/orange).

Implementation - Optimized Approach

python
def longest_substring_with_unique_chars_optimized(s: str) -> int:
    max_len = 0
    prev_indexes = {}
    left = right = 0
    while right < len(s):
        # If a previous index of the current character is present in the current
        # window, it's a duplicate character in the window.
        if s[right] in prev_indexes and prev_indexes[s[right]] >= left:
            # Shrink the window to exclude the previous occurrence of this character.
            left = prev_indexes[s[right]] + 1
        # Update 'max_len' if the current window is larger.
        max_len = max(max_len, right - left + 1)
        prev_indexes[s[right]] = right
        # Expand the window.
        right += 1
    return max_len

Complexity Analysis

Time complexity: The time complexity of the optimized implementation is O(n)O(n)O(n) because we traverse the string linearly with two pointers.

Space complexity: The space complexity is O(m)O(m)O(m) because we use a hash map to store unique characters, where mmm represents the total number of unique characters within the string.

Longest Uniform Substring After Replacements

Longest Uniform Substring After Replacements

A uniform substring is one in which all characters are identical. Given a string, determine the length of the longest uniform substring that can be formed by replacing up to k characters.

Example:

The image represents a visual illustration of finding the longest uniform substring within a given string.  The input string 'a b c d c c c a' is shown on the left.  Two 'c' characters within this string are highlighted with orange diagonal lines, indicating they are part of a uniform substring. A rightward-pointing arrow separates the input string from the output. The output, displayed in a light-blue rectangular box, shows the identified longest uniform substring 'c c c c c'. Above the output box, the text 'Longest uniform substring' is written in light-blue, clearly labeling the purpose of the diagram. The diagram visually demonstrates the process of identifying and extracting the longest consecutive sequence of identical characters from a given input string.
python
Input: s = 'aabcdcca', k = 2
Output: 5

Explanation: if we can only replace 2 characters, the longest uniform substring we can achieve is "ccccc", obtained by replacing 'b' and 'd' with 'c'.

Intuition

Determining if a substring is uniform Before we try finding the longest uniform substring, let’s first determine the most efficient way to make a string uniform with the fewest character replacements. Consider the example below:

Image represents a sequence of lowercase letters arranged horizontally.  The sequence is 'a b a a b a c', where each letter is individually spaced. There are no connections or arrows between the letters; they are simply presented as a linear string of characters. No URLs, parameters, or other information beyond the letters themselves are present in the image.  The letters appear to be a simple example data set, possibly illustrating a concept within the context of coding patterns, such as sequence analysis or pattern recognition.

Which characters should we replace to ensure the minimum number of replacements are performed to make the string uniform? There are three main choices: make the string all ‘a’s, or all ‘b’s, or all ‘c’s. The most efficient choice, requiring the fewest replacements, is to make all characters ‘a’, which involves just three replacements:

Image represents a transformation process illustrated using sequences of characters.  On the left, we see three subsequences: 'ab', 'aa', and 'ac'.  Each subsequence is individually marked with an orange arrow pointing diagonally upwards and labeled with 'a', indicating that the 'a' element is being processed or removed. A thick black arrow points from the left-hand sequences to the right-hand sequence.  The right-hand side shows a sequence of eight 'a's. The diagram visually depicts a transformation where the input consists of three subsequences containing 'a's and other characters ('b' and 'c'). The transformation removes elements other than 'a' from each subsequence, resulting in an output sequence containing only 'a's, effectively filtering or extracting 'a's from the input.

The key observation is that the minimum number of replacements needed to achieve uniformity is obtained by replacing all characters except the most frequent one.

This suggests that if we know the highest frequency of a character in a substring, we can determine if our value of k is sufficient to make that substring uniform. The number of characters that need to be replaced (num_chars_to_replace) can be found by subtracting this highest frequency from the total number of characters in the substring:

Image represents a calculation to determine the number of characters to replace in a substring.  At the top, a sequence of characters 'a b a a b a c' is shown, with the character 'b' and 'c' crossed out in orange, indicating they are to be replaced. Below this, a grey box contains a calculation: `num_chars_to_replace = len(substring) - highest_freq`. This equation calculates the number of characters to replace by subtracting the highest frequency of any single character (`highest_freq`) from the total length of the substring (`len(substring)`).  The calculation is then shown step-by-step:  `len(substring)` is explicitly given as 7, `highest_freq` as 4, resulting in a final answer of 3 characters to replace (`num_chars_to_replace = 3`).  The variables `num_chars_to_replace`, `len(substring)`, and `highest_freq` are written in orange, black, and blue respectively, highlighting their roles in the calculation.

Once we’ve calculated num_chars_to_replace for a given substring, we can assess if the substring can be made uniform:

  • If num_chars_to_replace ≤ k, the substring can be made uniform.
  • If num_chars_to_replace > k, the substring cannot be made uniform.

To calculate num_chars_to_replace, we need to know the value of highest_freq. This requires tracking the frequency of each character, which can be efficiently managed using a hash map (freqs). This hash map allows us to update highest_freq whenever we encounter a character with a higher frequency. Below is an illustration of how freqs is updated:

Image represents a data flow diagram illustrating the process of finding the highest frequency of a character in a sequence.  The input, shown as 'a b c' with a downward arrow indicating data flow, represents a sequence of characters. This input feeds into a table labeled 'freqs', which is a two-column table with headers 'char' and 'freq'.  The table shows the character 'a' and its frequency '1'.  This table acts as an intermediate data structure storing character frequencies.  A separate box contains a code snippet: `highest_freq = max(highest_freq, freqs['a'])`, which calculates the highest frequency encountered so far.  The variable `highest_freq` is updated using the `max` function, comparing its current value with the frequency of character 'a' (obtained from the `freqs` table using the key 'a'). The result of this calculation, '1', is displayed below the equation, indicating that the highest frequency found so far is 1.  The overall diagram shows how the input sequence is processed to determine the highest character frequency.
Image represents a data flow diagram illustrating a frequency counting algorithm.  The input consists of a sequence of characters 'a', 'b', 'b', 'c', depicted by a downward-pointing arrow above the sequence. This input feeds into a table labeled 'freqs,' which is a two-column table with headers 'char' and 'freq.'  The table stores the frequency of each character; 'a' has a frequency of 1, and 'b' has a frequency of 1.  A separate, light-grey, dashed-bordered box contains a Python code snippet: `highest_freq = max(highest_freq, freqs['b'])`, which calculates the highest frequency encountered so far.  The result of this calculation, `= 1`, is shown below the code snippet.  The code implies an iterative process where `highest_freq` is updated by comparing it with the frequency of each character in the `freqs` table.  The 'b' is used as an example in the code snippet, suggesting the algorithm iterates through each character in the input sequence.
Image represents a data flow diagram illustrating the process of finding the highest frequency of characters in a sequence.  The input is a sequence of characters 'a b b c', depicted as a horizontal list. A downward arrow indicates this sequence is processed to generate a frequency table labeled 'freqs'. This table is a 2-column structure with the left column labeled 'char' representing characters and the right column labeled 'freq' representing their frequencies. The table shows 'a' with frequency 1 and 'b' with frequency 2.  To the right, a separate box shows a Python-like code snippet calculating the highest frequency. The code `highest_freq = max(highest_freq, freqs['b'])` calculates the maximum frequency by comparing a current `highest_freq` (implicitly initialized to 0 or a lower value not shown) with the frequency of character 'b' (which is 2 from the table). The result, `= 2`, is displayed below, indicating that 2 is the highest frequency found in the input sequence.
Image represents a data flow diagram illustrating the process of finding the highest frequency of characters in a sequence.  The input is a sequence of characters 'a b b c' shown with a downward arrow indicating data flow into a frequency table labeled 'freqs'. This table is a two-column structure with 'char' representing the character and 'freq' representing its frequency. The table shows 'a' with frequency 1, 'b' with frequency 2, and 'c' with frequency 1.  To the right, a calculation is shown: `highest_freq = max(highest_freq, freqs['c']) = 2`. This equation indicates that the `highest_freq` variable is updated by taking the maximum value between its current value (implicitly assumed to be initialized to a value less than or equal to 2) and the frequency of character 'c' (which is 1 from the table), resulting in a final `highest_freq` value of 2.  The diagram visually demonstrates how the frequency table is generated from the input sequence and how a maximum frequency is calculated using the table's data.

Now that we have the tools to determine if a substring can be made uniform, the next step is to figure out how to identify the longest uniform substring. Let’s explore a technique that lets us do this.

Dynamic sliding window We know sliding windows can be useful for solving problems involving substrings. This problem requires that we find the longest substring that satisfies a specific condition:

num_chars_to_replace <= k

So, a dynamic sliding window might be appropriate, as discussed in the chapter introduction.

We can use the above condition to determine how to expand or shrink the window:

  • If the condition is met (i.e., the window is valid), we expand the window to find a longer window that still meets this condition.
  • If the condition is violated (i.e., the window is invalid), we shrink the window until it meets the condition again.

Let’s see how this works over the example below:

Image represents a sequence of characters 'a a b c d c c a' followed by a comma and then an equation 'k = 2'.  The sequence of characters appears to be a data set or input, possibly representing a string or an array of elements.  The characters 'a', 'b', 'c', and 'd' are individual elements within this sequence, arranged linearly from left to right.  There's no explicit connection shown between the character sequence and the equation 'k = 2', but the proximity suggests a relationship, possibly indicating that 'k' is a parameter or variable related to the processing or analysis of the character sequence.  The value '2' assigned to 'k' might represent a length, a count, or some other relevant property of the sequence.  The comma acts as a separator between the character sequence and the equation.

Start by defining the left and right boundaries of the window at index 0. Continue expanding the window for as long as it satisfies our condition (nums_char_to_replace <= k):

Image represents a visual explanation of a sliding window algorithm, possibly for character replacement or frequency analysis.  The left side shows a sequence of characters 'a a b c d c c a' with a light-blue highlighted 'a' at the beginning. Above this sequence, two boxes labeled 'left' and 'right' point downwards, indicating pointers or indices into the character sequence.  A value 'k = 2' is displayed, likely representing a parameter limiting the number of replacements or operations. The right side shows a calculation box. Inside, the variable `highest_freq` is set to 1.  A formula `num_chars_to_replace = window_length - highest_freq` is presented, followed by its evaluation as `1 - 1 = 0`. Finally, the condition `0 ≤ k` is shown, resulting in the action 'expand,' suggesting the window's size will increase. The overall diagram illustrates a step in an algorithm where the frequency of characters within a window is analyzed, and a decision is made based on the comparison of the number of characters to replace with the parameter 'k' to determine whether to expand the window.
Image represents a sliding window algorithm visualization.  Two labeled boxes, 'left' (in gray) and 'right' (in orange), point downwards with arrows towards a light-blue rectangular box containing the character sequence 'aa'. This sequence is part of a larger sequence 'aabcdcca' shown to the right.  A dashed arrow connects the 'right' box to the second 'a' in the light-blue box, indicating the window's current position. A separate gray box on the right displays calculations:  `highest_freq = 2`, representing the highest frequency of a character within the current window (which is 'a').  The next line calculates `num_chars_to_replace = window_length - highest_freq`, which evaluates to `2 - 2 = 0`. The final line states `0 ≤ k --> expand`, indicating that because the number of characters to replace is 0 or less, the window should expand.  The overall diagram illustrates a step in a sliding window algorithm where the window's size is determined by character frequencies.
Image represents a diagram illustrating a sliding window algorithm.  A sequence of characters 'a a b c d c c a' is shown.  Two rectangular boxes labeled 'left' and 'right' in gray and orange respectively, point downwards with arrows indicating the left and right boundaries of a sliding window currently encompassing 'a a b'.  A dashed orange arrow shows the window's right boundary moving one position to the right.  An upward red arrow labeled 'chars to replace' points to the character 'b' within the window, suggesting this character is a candidate for replacement. A separate gray box contains calculations:  `highest_freq` is set to 2, representing the highest frequency of a character within the window.  The formula `num_chars_to_replace = window_length - highest_freq` is shown, with a substitution using `window_length = 3` resulting in `num_chars_to_replace = 1`. Finally, the condition `1 ≤ k → expand` indicates that if the number of characters to replace (k) is greater than or equal to 1, the window should expand.
Image represents a diagram illustrating a sliding window algorithm.  A light-blue rectangular box displays a sequence of characters: 'a a b_c_ d c c a'.  A gray rectangular box to the right shows calculations: `highest_freq = 2`, `num_chars_to_replace = window_length - highest_freq = 4 - 2 = 2`, and `2 ≤ k → expand`.  Above the character sequence, a rectangular box labeled 'left' points down with an arrow to the beginning of the sequence, and another rectangular box labeled 'right' (in orange) points down with a dashed arrow to the characters 'b_c_'. Red upward arrows under 'b_c_' are labeled 'chars to replace', indicating these characters are targeted for replacement. The calculation in the gray box determines the number of characters to replace (`num_chars_to_replace`) based on the window length (4) and the highest frequency of any character within the window (2). The result (2) is compared to a variable 'k', and if 2 is less than or equal to k, the window expands.

Once the window expands to the fifth character (‘d’), it will contain 3 characters that must be replaced to make the window uniform. Since we can only replace up to k = 2 characters, the window is invalid. So, we shrink the window:

Image represents a sliding window algorithm visualization.  A sequence of characters 'a a b c_d c c a' is shown, with a light-red background highlighting a sub-sequence 'a a b c_d'.  Arrows pointing upwards from beneath this sub-sequence are labeled in red as 'chars to replace'.  A grey box to the right displays calculations: 'highest_freq = 2', indicating the highest frequency of any character within the window.  The next line calculates 'num_chars_to_replace = window_length - highest_freq', substituting values to get '5 - 2 = 3'. Finally, '3 > k → shrink' suggests that if the number of characters to replace (3) exceeds a threshold 'k' (not explicitly defined), the window should shrink.  Rectangles labeled 'left' and 'right' above the character sequence indicate the direction of window movement, with a dashed arrow suggesting the right pointer's movement within the window.
The image represents a data structure manipulation process.  A sequence of characters 'a b c d' is shown centrally, highlighted in a light pink rectangle.  Above this sequence, two rectangular boxes labeled 'left' (in orange) and 'right' (in gray) indicate the direction of data input.  A downward-pointing arrow from 'left' connects to the first 'a' in the sequence, with a dashed arrow indicating an additional 'a' to the left of the main sequence. A downward-pointing arrow from 'right' connects to the last 'd' in the sequence. Below the central sequence, red upward-pointing arrows connect to each character (b, c, and d) with the text 'chars to replace' indicating these characters are targets for modification. To the right, a dashed-line rectangle displays the text 'highest_freq = 2 X', suggesting that the character with the highest frequency (presumably 'a' or 'c' based on the visible data) has a frequency of 2, and the 'X' likely represents a placeholder or an operation to be performed based on this frequency. The overall diagram illustrates a process where data is input from left and right, and characters within a central sequence are identified and potentially replaced based on frequency analysis.

Notice that after shrinking the window, the value of highest_freq is still 2, which is no longer correct. Recall that our current method for updating highest_freq only increases it when encountering a character with a higher frequency, meaning highest_freq can only remain the same or increase, but it can never decrease.

One way to work around this is to develop a new method for updating highest_freq that accurately decreases it when the highest frequency in a window decreases. However, our goal is to find the longest substring that meets the condition, so shrinking the window might not even be necessary. The crucial point here is that when we find a valid window of a certain length, no shorter window will provide a longer uniform substring.

This means we can just slide the window instead of shrinking it whenever we encounter an invalid window, effectively maintaining the length of the current window.

With this observation, we should correct our previous logic:

  • If the window satisfies the condition: expand.
  • If the window doesn’t satisfy the condition: slide.

Let’s correct the action taken in the first invalid window above by sliding instead of shrinking. Then, we can continue processing the rest of the string.

Image represents a sliding window algorithm visualization.  Two rectangular boxes labeled 'left' and 'right' point downwards to a sequence of characters 'a a b c d c c a' displayed horizontally. A light red background highlights the characters 'b c d' within the sequence. Red upward-pointing arrows beneath these highlighted characters are labeled 'chars to replace'.  To the right, a grey box shows a calculation:  `highest_freq` is set to 2; `num_chars_to_replace` is calculated as `window_length` (5) minus `highest_freq` (2), resulting in 3.  The final line indicates that if this result (3) is greater than some value `k` (not explicitly defined), a 'slide' operation should occur, implying the window moves along the character sequence.  The diagram illustrates how a sliding window identifies characters to be replaced based on frequency within the window, and the calculation determines the window's movement.
Image represents a sliding window algorithm visualization.  The left side shows a sequence of characters 'aabcdcca' with a highlighted sub-sequence 'a_b_c_d_c' in a light pink rectangle.  Orange boxes labeled 'left' and 'right' indicate pointers to the beginning and end of this window, respectively. Dashed orange arrows show the pointers moving across the sequence. Red upward arrows under the highlighted subsequence point to the text 'chars to replace,' indicating these characters within the window are candidates for replacement. The right side displays a calculation:  `highest_freq` is set to 2, representing the highest frequency of a character within the window.  The number of characters to replace is calculated as `window_length` (5) minus `highest_freq` (2), resulting in 3.  The final line states that if this result (3) is greater than a variable `k` (not defined in the image), then the window should slide.  The entire diagram illustrates how a sliding window algorithm determines which characters to replace based on frequency within a given window size.
Image represents a visual explanation of a sliding window algorithm, likely for character replacement or frequency analysis.  The left side shows a sequence of characters 'a a b c d c c a' with a light-blue highlighted window encompassing 'b c d c c'.  Orange rectangular boxes labeled 'left' and 'right' indicate pointers to the window's boundaries. Dashed arrows show the window's movement. Red upward arrows under the window point to the characters 'b', 'd', indicating these are characters to be replaced. The right side displays a calculation: `highest_freq` is set to 3, representing the highest frequency of a character within the window.  The number of characters to replace is calculated as `window_length - highest_freq` (5 - 3 = 2). The result (2) is compared to a variable 'k' (2 ≤ k), implying a condition for window expansion based on this comparison.  If the condition is met, the window expands; otherwise, it might contract or remain the same.
Image represents a sliding window algorithm visualization.  A sequence of characters 'a a b c d c c a' is shown.  A light-red rectangular box highlights a sub-sequence 'b c d c c a', representing a sliding window of length 6.  A grey box to the right displays calculations: `highest_freq = 3` (indicating the highest frequency of any character within the window), and `num_chars_to_replace = window_length - highest_freq = 6 - 3 = 3`.  This calculation determines the number of characters to replace within the window.  Red upward arrows under the window point to the characters 'b', 'd', and 'c' labeled 'chars to replace'.  Rectangular boxes labeled 'left' and 'right' with arrows indicate the direction of the sliding window movement.  A dashed arrow shows the rightward movement of the window. The final equation `3 < k → slide` suggests that if the number of characters to replace (3) is less than some variable 'k', the window slides to the right.

The above window is the final window because we cannot expand or slide it any further. The longest valid window encountered during this process is a window of length 5.

Implementation

python
def longest_uniform_substring_after_replacements(s: str, k: int) -> int:
    freqs = {}
    highest_freq = max_len = 0
    left = right = 0
    while right < len(s):
        # Update the frequency of the character at the right pointer and the highest
        # frequency for the current window.
        freqs[s[right]] = freqs.get(s[right], 0) + 1
        highest_freq = max(highest_freq, freqs[s[right]])
        # Calculate replacements needed for the current window.
        num_chars_to_replace = (right - left + 1) - highest_freq
        # Slide the window if the number of replacements needed exceeds 'k'.
        # The right pointer always gets advanced, so we just need to advance 'left'.
        if num_chars_to_replace > k:
            # Remove the character at the left pointer from the hash map before
            # advancing the left pointer.
            freqs[s[left]] -= 1
            left += 1
        # Since the length of the current window increases or stays the same, assign
        # the length of the current window to 'max_len'.
        max_len = right - left + 1
        # Expand the window.
        right += 1
    return max_len

Complexity Analysis

Time complexity: The time complexity of longest_uniform_substring_after_replacements is O(n)O(n)O(n), where nnn is the length of the input string. This is because we traverse the string linearly with two pointers.

Space complexity: The space complexity is O(m)O(m)O(m), where mmm is the number of unique characters in the string stored in the hash map freqs.

Chapter 6

Binary Search

~49 min read

Intuition

Let's say you have a standard, hard-copy English dictionary, and you want to find the definition of the word "inheritance." How could we do this?

One approach is to flick through it page by page, until reaching the page with "inheritance." But this is inefficient, and realistically, you're more likely to immediately open a page somewhere in the middle.

Let's say you do this and land on a page of words beginning with "M." Realizing that "inheritance" is somewhere to the left of "M" in the alphabet, you open a page somewhere between the letters "A" and "M." You continue this process of checking if "inheritance" is to the right or left of the current page, and narrowing the search down until you find the correct page. With this method, you've significantly reduced the number of pages to be searched, in contrast to linearly turning every page in order. This is the fundamental concept of binary search.

Image represents a sequential process depicted using three open book icons connected by arrows and a final book with a bookmark. The first book shows multiple lines starting with 'M...', suggesting entries related to 'M'.  An arrow points from this book to the second, with the text 'turn to a page between A and M.' indicating a search or transition. The second book displays lines beginning with 'F...', representing entries related to 'F'. Another arrow, labeled 'turn to a page between F and M.', connects this book to the third. The third book contains lines starting with 'I...', with one line specifically labeled 'Internet' in green, suggesting a search result. Finally, a vertical orange bookmark labeled 'Found it!' is inserted into the third book, signifying the successful completion of the search.  The overall diagram illustrates a search process where the user iteratively narrows down the search space ('M' then 'F') until finding the desired information ('Internet') within a larger dataset represented by the books.

Binary search is an algorithm that searches for a value in a sorted data set.

Even experienced developers can find it quite tricky to implement binary search correctly because "the devil's in the detail."

  • How should the boundary variables left and right be initialized?
  • Should we use left < right or left ≤ right as the exit condition in our while-loop?
  • How should the boundary variables be updated? Should we choose left = mid, left = mid + 1, right = mid, or right = mid - 1?

This chapter offers clear, intuitive guidance on how to master these challenges, and confidently handle even the trickiest edge case.

To begin any binary search implementation, do the following:

  1. Define the search space.
  2. Define the behavior inside the loop for narrowing the search space.
  3. Choose an exit condition for the while-loop.
  4. Return the correct value.

Let's break down these steps.

1. Defining the search space The search space encompasses all possible values that may include the value we're searching for. For instance, when searching for a target in a sorted array, the search space should cover the entire array, as the target could be anywhere within it. This is illustrated in the array below, where the left and right pointers define the search space:

Image represents a visual depiction of a search space within a binary search algorithm.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above a horizontal line representing the 'search space'.  This space contains a sorted array of integers: [1, 3, 4, 9, 10, 12, 14, 17, 20].  Arrows descend from 'left' and 'right' pointing to the beginning and end of the array, respectively.  An orange line underneath the array is labeled 'search space,' indicating the range of values currently being considered during the search.  The diagram illustrates the initial state of a binary search, where the algorithm is about to begin its search for a target value within the defined 'search space'.

2. Narrowing the search space Narrowing the search space involves progressively moving the left or right pointer inward until the search space is reduced to one element or none.

At each point in the binary search, we need to decide how we narrow our search space. We can either:

  • Narrow the search space toward the left (by moving the right pointer inward):
Image represents a diagram illustrating a data structure concept, possibly related to merging or combining data from two sources.  Two orange rectangular boxes labeled 'left' and 'right' are positioned at the top, each pointing downwards with an arrow towards a series of black dots enclosed in square brackets.  The 'left' box points to three black dots, while the 'right' box points to one black dot.  To the right of the 'right' box's black dot, a sequence of three grey dots connected by light grey lines is shown, also enclosed in square brackets. A dashed orange curved arrow originates from the rightmost grey dot and points back towards the black dot associated with the 'right' box, suggesting a connection or data flow from the right-hand sequence back to the initial point. This implies a potential merging or interaction between the data represented by the black dots and the grey dots.
  • Narrow the search space toward the right (by moving the left pointer inward):
Image represents a diagram illustrating a coding pattern, possibly related to data structures or algorithms.  The diagram shows two rectangular boxes labeled 'left' and 'right' in orange, positioned above a horizontal line of elements.  Three grey circles connected by a light grey line represent a sequence on the left side.  To the right of this sequence are three black circles, representing another sequence. An orange dashed curved arrow originates from the leftmost grey circle and extends to the first black circle, indicating a connection or data transfer from the 'left' sequence to the rightmost element of the left sequence. A solid orange arrow points downwards from the 'left' box to the first black circle, suggesting that the 'left' element influences or acts upon this point. Similarly, a solid orange arrow points downwards from the 'right' box to the last black circle, implying an action or influence from the 'right' element on the last black circle.  The entire sequence of circles is enclosed within square brackets, suggesting a defined array or list.

Using the midpoint We decide whether to move the left or right pointer based on the value in the middle of the search space, indicated by the midpoint variable (mid).

Image represents a visual depiction of a data structure, likely an array or list, being accessed by three pointers labeled 'left,' 'mid,' and 'right.'  The pointers are represented by rectangular boxes with their respective labels in orange ('left,' 'right') and light blue ('mid').  Arrows descend from each pointer, indicating their position within the data structure. The data structure itself is shown as a horizontal row of black dots enclosed in square brackets, representing individual elements. The 'left' pointer points to the first element, the 'right' pointer points to the last element, and the 'mid' pointer points to an element somewhere in the middle.  The arrangement suggests a scenario where these pointers might be used in algorithms like binary search or other operations requiring access to specific array positions.

The main question to ask at each iteration of binary search is: is the value being searched for to the left or the right of the midpoint?

Here's the general idea: if the value we're looking for is to the right of the midpoint, narrow the search space toward the right. Otherwise, narrow the search space toward the left.

To narrow the search space towards the right, there are two options:

  • left = mid + 1
Image represents a visual depiction of an algorithm, likely a binary search or a similar divide-and-conquer approach.  The diagram shows two phases. The first phase displays three rectangular boxes labeled 'left' (orange), 'mid' (light blue), and 'right' (orange), each pointing downwards with an arrow to a corresponding position within a sequence of dots representing an array.  The 'left' box points to the beginning of the array, 'mid' to the middle, and 'right' to the end. The second phase shows a transformation.  The initial array is partially redrawn, and a solid arrow with the label 'left = mid + 1' connects the rightmost dot of the left half of the array to the leftmost dot of the right half.  This indicates an update to the 'left' index.  Above the second phase, a light gray 'mid' box and orange 'left' and 'right' boxes are shown, pointing down to a smaller section of the array.  A dashed orange line connects the 'mid' box from the first phase to the 'left' box in the second phase, suggesting a relationship between the midpoint of the initial array and the new starting point of the search in the second phase.  The overall illustration demonstrates how an algorithm iteratively narrows down a search space by updating index pointers based on the midpoint.

We do this if the midpoint value is definitively not the value we're looking for and should excluded from the search space.

  • left = mid We do this if the midpoint value itself could potentially be the value we're looking for and should still be included in the search space.

The exclude/include logic applies when narrowing the search space towards the left, as well (i.e., between right = mid - 1 and right = mid).

Calculating the midpoint In most cases, the midpoint is calculated using mid = (left + right) // 2 in Python.1 To lower the risk of integer overflow, mid = left + (right - left) // 2 is preferred in many other languages.

3. Choosing an exit condition Choosing an exit condition for when the while-loop should terminate can be tricky. Our choices are primarily between left < right and left ≤ right. Both conditions are applicable in different situations.

  • When the exit condition is left < right, the while-loop will break when left and right meet.2
  • On the other hand, left ≤ right ends once left has surpassed right.
Image represents two diagrams illustrating different loop termination conditions in a search algorithm, likely binary search.  The left diagram shows a loop continuing 'while left < right', terminating 'once left == right'.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above a series of dots within square brackets [...], representing an array or list. Arrows descend from 'left' and 'right' pointing to separate dots within the array, suggesting the pointers are converging. The right diagram shows a loop continuing 'while left ≤ right', terminating 'once left > right'.  Similarly, two orange boxes labeled 'right' and 'left' (note the order change) are above a series of dots within square brackets [...]. Arrows descend from 'right' and 'left', again pointing to separate dots within the array, but this time illustrating the pointers potentially crossing each other before termination.  Both diagrams visually represent the iterative process of narrowing down a search space until a condition is met, highlighting the subtle difference in loop conditions and their impact on the final pointer positions.

When the left and right pointers meet after exiting the left < right condition, both converge to a single value. This is the final value in the search space after the binary search process is complete. This will be the exit condition we use throughout this chapter.

4. Returning the correct value As previously mentioned, the while-loop terminates once we've narrowed the search space down to a final value (assuming no value was returned during earlier iterations), pointed at by both left and right:

Image represents a visual depiction of a data structure, possibly illustrating a binary search or a similar algorithm.  The diagram shows a horizontal line representing a sorted sequence, with several small, grey circles evenly spaced along it, symbolizing data elements.  At the center of the line is a larger, black dot, indicating a pivot or search point. Above this central point are two rectangular boxes, colored orange, labeled 'left' and 'right,' respectively. Arrows point downwards from these boxes to the central black dot, suggesting that the 'left' box represents the portion of the sequence to the left of the pivot, and the 'right' box represents the portion to the right. The entire sequence is bounded by vertical brackets at either end, indicating the limits of the data set.  The arrangement visually communicates the partitioning of a sorted sequence around a pivot point, a common step in divide-and-conquer algorithms.

This final value is the answer we're looking for, assuming a valid answer exists.

Time Complexity

The time complexity of the binary search is O(log⁡(n))O(\log(n))O(log(n)), where nnn is the number of values in the search space. The algorithm is logarithmic because, in each iteration of the algorithm, it divides the search space in half until a single value is located, or no value is found. This reduction by half in each step is characteristic of logarithmic behavior.

Real-world Example

Transaction search in financial systems: In financial systems, a binary search can be used to quickly find a transaction or record by narrowing down the search range, as the data is typically stored in order. This makes it efficient to retrieve specific entries without searching through the entire database.

Chapter Outline

Binary search has many applications, and we explore a range of problems covering various uses.

Image represents a flowchart illustrating applications of the Binary Search algorithm.  The topmost box, 'Binary Search,' acts as the root, branching down into two main categories: 'Sorted Arrays' and 'Non-intuitive Search Space.'  The 'Sorted Arrays' box details finding the first and last occurrences of a number and determining the insertion index within a sorted array. This further leads to 'Partially Sorted Arrays,' focusing on finding a target within a rotated sorted array.  The 'Non-intuitive Search Space' box encompasses problems like 'Cutting Wood,' finding 'Local Maxima in Array,' and 'Weighted Random Selection.' This branch connects to 'Multiple Arrays,' which involves finding the median from two sorted arrays. Finally, all paths converge at the bottom to 'Matrix,' which describes the application of binary search to 'Matrix Search.'  The connections between boxes are represented by dashed arrows indicating the flow of problem types stemming from the core Binary Search algorithm.

Footnotes

  1. In rare cases, we calculate the midpoint differently, such as when doing an upper-bound binary search. The details are explained in the First and Last Occurrences of a Number problem. ↩
  2. There are some rare situations where left and right will cross, which is also explored in the First and Last Occurrences of a Number problem. ↩

Find the Insertion Index

Find the Insertion Index

You are given a sorted array that contains unique values, along with an integer target.

  • If the array contains the target value, return its index.
  • Otherwise, return the insertion index. This is the index where the target would be if it were inserted in order, maintaining the sorted sequence of the array.

Example 1:

python
Input: nums = [1, 2, 4, 5, 7, 8, 9], target = 4
Output: 2

Example 2:

python
Input: nums = [1, 2, 4, 5, 7, 8, 9], target = 6
Output: 4

Explanation: 6 would be inserted at index 4 to be positioned between 5 and 7: [1, 2, 4, 5, 6, 7, 8, 9].

Intuition

The goal of this problem varies depending on whether the sorted input array contains the target value or not. If it does, we should return its index. If it doesn’t, we return its insertion index.

Image represents two scenarios illustrating a search or insertion operation within a sorted integer array.  The left side, titled 'case 1 - target exists in array:', shows an array `[1, 2, 4, 5, 7, 8, 9]` with indices displayed below each element (0-6). The target value, 4, is highlighted in a light green circle at index 2.  The right side, titled 'case 2 - target not in array:', displays the array `[1, 2, 4, 5, 7, 8, 9]` with indices (0-6) similarly shown.  Here, the target value is 6, which is not present. A light green circle highlights the index 4, indicating where the target value *would* be inserted to maintain the sorted order. A green upward-pointing arrow labeled 'insertion index' points to this highlighted index (4) in the second array.  Both cases demonstrate how a target value's presence or absence affects the outcome of a search or insertion algorithm within a sorted array.

When the target doesn’t exist in the array, we observe the insertion index is found at the first value in the array greater than the target, as can be seen in the second diagram above, where the first value larger than the target of 6, is 7.

Since we don’t know if the target exists in the array before we start looking for it, we can combine both cases by finding the first value greater than or equal to the target. This gives us a universal objective regardless of whether the target is in the array or not.

As the array is sorted, we can use binary search to find the desired index.

Binary search In this binary search, we’re effectively looking for the first value that matches a condition, where the condition is that the number is greater than or equal to the target. Let’s visualize which values of the array meet this condition and which don’t:

Image represents a visual explanation of a conditional operation applied to an array.  The top displays a conditional statement, `nums[i] >= target ?`, indicating a comparison where each element (`nums[i]`) of an array `nums` is checked against a `target` value. Below are two examples. Each example shows an array of numbers ([1, 2, 4, 5, 7, 8, 9]) and a specified `target` value (4 in the first example and 6 in the second).  Underneath each array, a corresponding array of 'F' (False) and 'T' (True) values is shown.  These boolean values represent the result of the comparison `nums[i] >= target` for each element.  Grey downward-pointing arrows connect each element in the number array to its corresponding boolean value in the result array.  In the first example (target = 4), elements less than 4 result in 'F', while elements greater than or equal to 4 result in 'T'.  Similarly, in the second example (target = 6), elements less than 6 are 'F', and those greater than or equal to 6 are 'T'.  The diagram illustrates how the conditional statement processes each array element to produce a boolean result array.

From this diagram, we can see the value we want to find is effectively the lower bound of values that satisfy this condition:

The image represents two examples illustrating a lower bound in a data structure, likely an array.  Each example shows a sequence enclosed in square brackets `[]`.  The sequences consist of elements represented by 'F' (in red) and 'T' (in green).  In both examples, a single 'T' element, highlighted within a light green circle, is positioned near the beginning of the sequence.  An upward-pointing arrow originates from below this circled 'T' and points towards it, labeled 'lower bound'.  The first example shows the sequence `[F F F T T T T T]`, while the second example shows `[F F F F F T T T]`.  The arrangement demonstrates how the 'T' element acts as a lower bound, indicating a point in the sequence where the condition represented by 'T' begins to hold true. The difference between the two examples highlights that the exact position of the lower bound can vary depending on the data.

Note that finding the lower bound is equivalent to finding the leftmost value. With this in mind, let’s come up with an algorithm.

First, define the search space. If the target exists in the array, it could be located at any index within the range from 0 to n - 1. However, if the target is not in the array and is larger than all the elements, its insertion index is n. Therefore, our search space should cover all indexes in the range [0, n].

To figure out how we narrow the search space, let’s first explore an example array that contains the target, then dive into an example where the array doesn’t contain the target.

Target exists in the array Consider searching for target value 4 in the following sorted array.

Image represents a visual depiction of a two-pointer approach to searching within a sorted array.  Two rectangular boxes labeled 'left' and 'right' are positioned above a sorted integer array [1, 2, 4, 5, 7, 8, 9].  Arrows descend from 'left' and 'right' pointing to the beginning and end of the array respectively.  The array elements are numbered below with their indices (0 to 7).  To the right of the array, the text 'target = 4' indicates the value being searched for.  The diagram illustrates the initial state of a binary search or similar algorithm where the 'left' pointer starts at index 0 and the 'right' pointer starts at index 6, preparing to search for the target value (4) within the array.

Initially, the midpoint is positioned at element 5, which is a number that satisfies our condition of being greater than or equal to the target. This means the lower bound is either at this midpoint, or to its left since the lower bound is the leftmost value that satisfies the condition.

So, narrow the search space toward the left, while including the midpoint (i.e., right = mid):

Image represents a visual depiction of a binary search algorithm step.  Three rectangular boxes labeled 'left,' 'mid,' and 'right' are positioned above a numerical array [1, 2, 4, 5, 7, 8, 9], with each box pointing downwards via an arrow to its corresponding index in the array.  The 'mid' box is highlighted in light blue, indicating its current focus. The number 4 in the array is highlighted in light green, representing the element at the 'mid' index. A dashed-line rectangle to the right displays a conditional statement: 'nums[mid] ≥ 4 → right = mid,' indicating that if the value at the middle index ('nums[mid]') is greater than or equal to 4, then the 'right' index is updated to the current 'mid' index.  This illustrates a step in narrowing down the search space within the array.  The numbers below the array represent the indices (0-7) of each element.
Image represents a visual depiction of a two-pointer approach in array traversal.  A rectangular box labeled 'left' points down to a bracketed sequence `[1 2 4 5 7 8 9]`, indicating the starting point of a left pointer.  The numbers are indexed below, starting from 0.  A second rectangular box labeled 'right' points down to the number 5 within the sequence. A light-green circle highlights the number 4, visually separating the left and right pointers' initial positions. A dashed orange line curves from the number 5 (the right pointer's initial position) to the closing bracket `]`, suggesting the right pointer's traversal direction.  The numbers 7, 8, and 9 are shown in lighter gray, indicating they are yet to be processed. The overall arrangement illustrates the initial state of a two-pointer algorithm operating on an array, where the left pointer starts at index 0 and the right pointer starts at index 3, with the right pointer moving towards the end of the array.

Now, the midpoint is positioned at element 2. The lower bound should be a value greater than or equal to the target (4), which means the current midpoint is too small. To look for a larger value, we need to search to the right of the midpoint.

So, narrow the search space toward the right while excluding the midpoint:

Image represents a visual depiction of a step within a binary search algorithm.  Three rectangular boxes labeled 'left,' 'mid,' and 'right' are positioned above a numerical array [1, 2, 4, 5, 7, 8, 9], with index numbers displayed below each element.  Arrows descend from 'left,' 'mid,' and 'right' pointing to the array elements 1, 2, and 5 respectively. The element at index 2 (value 4) is highlighted in light green. To the right, a dashed-line box contains a conditional statement: 'nums[mid] < 4 → left = mid + 1,' indicating that if the value at the middle index (nums[mid], which is 4 in this case) is less than 4, then the 'left' index should be updated to 'mid + 1'.  This illustrates how the algorithm adjusts the search space based on the comparison of the middle element with the target value (implicitly 4 in this example).
Image represents a visual depiction of a data structure manipulation, possibly illustrating an algorithm or a coding pattern.  A horizontal line represents a sequence of numbered elements, bracketed by '[' and ']'.  The numbers 1 through 9 are shown along this line, with numbers 0 through 7 displayed below as indices.  A light green circle labeled '4' highlights an element within the sequence. Two rectangular boxes, one orange labeled 'left' and one gray labeled 'right', are positioned above the sequence. A solid orange arrow descends from 'left' pointing to the element '4', indicating a potential insertion or modification at that position. A solid gray arrow descends from 'right' pointing to the element '5', suggesting a similar action.  A dashed orange curved line connects the 'left' box to the element '4', implying a possible search or selection process leading to the element '4' from the left side.  The overall diagram likely illustrates how elements are accessed or manipulated within a sequence based on directional cues ('left' and 'right'), potentially demonstrating concepts like array indexing or list traversal.

Now, the midpoint is positioned at element 4, which satisfies the condition of being greater than or equal to the target. So, narrow the search space toward the left while including the midpoint:

Image represents a visual depiction of a step within a binary search algorithm.  At the top, three rectangular boxes labeled 'left,' 'mid,' and 'right' are shown horizontally.  Light blue arrows descend from 'mid' to highlight its index within a numerical array displayed below. This array, enclosed in square brackets `[ ]`, contains the numbers 1, 2, 4, 5, 7, 8, 9, with their indices (0-7) shown faintly beneath. The number 4, at index 2, is highlighted with a light green circle.  The array's element at the index indicated by 'mid' (which is 4) is being compared to the value 4. To the right, a light gray, dashed-bordered rectangle displays the conditional logic: `nums[mid] ≥ 4 → right = mid`, indicating that if the value at the middle index (`nums[mid]`) is greater than or equal to 4, then the 'right' index is updated to be equal to the 'mid' index. This illustrates a step in narrowing down the search space within the binary search.
Image represents a visual depiction of a data structure, possibly an array or list, with elements numbered 1 through 9, enclosed within square brackets '[' and ']'.  A light-green circle labeled '4' highlights the element at index 2. Two rectangular boxes, one labeled 'left' in gray and the other 'right' in orange, indicate directional pointers.  A solid gray arrow points from 'left' to the circled '4', while a solid orange arrow points from 'right' to the circled '4'. A dashed orange curved arrow originates near the circled '4' and points back towards it, suggesting an iterative or cyclical process.  The numbers below the main line represent the indices of the array elements, ranging from 0 to 7, corresponding to the elements above. The overall diagram illustrates a concept related to accessing or manipulating an element within a data structure, potentially within the context of algorithms like binary search or other pointer-based operations.

Once the left and right pointers meet, the lower bound is located, which is the first value greater than or equal to the target. Now, we just return left to return this value’s index.

Target doesn’t exist in the array Let’s test the above logic in the following example where the target is not in the array:

The image represents a visual depiction of a search for a target value within a numerical array.  The top row displays an array of integers enclosed in square brackets: [1, 2, 4, 5, 7, 8, 9].  To the right, separated by a comma, is the text 'target = 6,' indicating that the goal is to find the number 6 within the array. The bottom row shows the indices of the array elements, ranging from 0 to 7, corresponding to each element's position. A light-green circle highlights the index '4' which is below the number '7' in the array, suggesting that the algorithm is currently examining or has examined this element during its search for the target value 6.  The absence of the target value (6) in the array is implicitly shown by the highlighting of an index that does not correspond to the target value.
Image represents a visual depiction of a binary search algorithm step.  Three rectangular boxes labeled 'left,' 'mid,' and 'right' are positioned above a numerical array [1, 2, 4, 5, 7, 8, 9], with each box pointing downwards via an arrow to its corresponding element's index in the array.  The 'mid' box is highlighted in light blue, indicating its current focus.  Below the array, indices 0 through 7 are displayed.  The element at index 4 (value 7) is highlighted in light green. A dashed-line rectangular box contains the conditional statement 'nums[mid] < 6 → left = mid + 1,' illustrating that if the value at the middle index ('nums[mid]') is less than 6, the 'left' index is updated to 'mid + 1,' effectively narrowing the search space to the right half of the array for the next iteration.  The arrows visually represent the data flow and the conditional logic of the algorithm.
Image represents a visual depiction of a data structure, possibly an array or list, illustrating a coding pattern.  A numerical sequence [1, 2, 4, 5, 7, 8, 9] is displayed, with numbers positioned along a horizontal axis labeled with indices 0 through 7 below.  An orange dashed line curves from a point between 1 and 2, reaching a point above the number 7.  An orange rectangle labeled 'left' is connected by a downward-pointing arrow to the number 7.  A gray rectangle labeled 'right' is connected by a downward-pointing arrow to the number 9.  A light green circle containing the number 4 is positioned below the number 7, possibly indicating an index or a specific element's value. The dashed line suggests a connection or operation involving the 'left' pointer and the element at index 4, potentially illustrating a search or traversal algorithm within the data structure.
Image represents a visual depiction of a step within a binary search algorithm.  The top shows three labeled boxes: 'left,' 'mid' (highlighted in cyan), and 'right.' Arrows descend from 'left' and 'mid' pointing to the numbers 7 and 8 respectively, within a numerical array [1, 2, 4, 5, 7, 8, 9].  The array's indices are shown below, with index 4 highlighted in light green, corresponding to the value 7.  An arrow descends from 'right' to the number 9 in the array. A separate, light-grey, dashed-bordered box displays the conditional logic: 'nums[mid] ≥ 6 → right = mid,' indicating that if the element at the middle index (`mid`) is greater than or equal to 6, then the 'right' index is updated to the value of `mid`. This illustrates how the algorithm adjusts the search space based on the comparison of the middle element with a target value (implicitly 6 in this case).
Image represents a visual depiction of a coding pattern, likely illustrating array indexing or pointer manipulation.  A numbered array, represented by `[1 2 4 5 7 8 9]`, is shown with indices displayed below (0-7). Two rectangular boxes labeled 'left' (gray) and 'right' (orange) point downwards to array elements 7 and 8 respectively. A downward-pointing arrow from 'left' indicates selection of element 7, while a similar arrow from 'right' points to element 8.  A dashed orange curved line connects 'right' to the array's closing bracket `]`, suggesting a potential iteration or traversal to the end of the array.  A small light green circle containing the number 4 is positioned below the element 7, possibly indicating an additional variable or index value related to the 'left' pointer.  The overall arrangement suggests a scenario where two pointers, 'left' and 'right,' operate on the array, potentially for searching, sorting, or other array-processing algorithms.
Image represents a visual depiction of a step within a binary search algorithm.  A numerical array `[1, 2, 4, 5, 7, 8, 9]` is shown, with indices displayed below.  Three labeled boxes, 'left,' 'mid' (highlighted in cyan), and 'right,' are positioned above the array, indicating pointers or indices. Arrows from 'left,' 'mid,' and 'right' point downwards to the elements 7, 7, and 8 respectively in the array.  The element at index 4 (value 7) is highlighted in light green, suggesting it's the current `mid` point. A separate, dashed-bordered box displays a conditional statement:  `nums[mid] ≥ 6 → right = mid`, indicating that if the value at the `mid` index (7 in this case) is greater than or equal to 6, then the `right` pointer is updated to the `mid` index's value. This illustrates a decision-making step within the algorithm's iterative process of narrowing down the search space.
Image represents a visual depiction of a binary search algorithm operating on a sorted array.  A numbered array [1, 2, 4, 5, 7, 8, 9] is shown, with numbers 0 through 7 displayed below as indices.  A light green circle labeled '4' indicates the midpoint of the array after an initial search.  Two rectangular boxes, one labeled 'left' in gray and the other 'right' in orange, point downwards with arrows to the number '7' within the array.  The 'left' arrow indicates a search starting from the left side of the array, while the 'right' arrow indicates a search from the right. A dashed orange arrow from 'right' to '7' shows the right pointer converging on '7'. This illustrates how the algorithm iteratively narrows down the search space by comparing the target value (implicitly understood to be 7) with the middle element, adjusting the search boundaries ('left' and 'right' pointers) until the target is found.

As we can see, by the end of the binary search, we’ve narrowed down the search space to a single value, identifying index 4 as the insertion index. Return left.

Summary For clarity, let’s summarize the two main scenarios we encounter while narrowing down the search space.

Case 1: The midpoint value is greater than or equal to the target, indicating the lower bound is either at the midpoint, or to its left. In this case, we narrow the search space toward the left, ensuring the midpoint is included:

Image represents a visual depiction of a binary search algorithm's step.  At the top, a code snippet shows the conditional statement `if nums[mid] ≥ target: right = mid;`, indicating that if the element at the middle index (`mid`) of the array `nums` is greater than or equal to the target value, the `right` boundary of the search space is updated to `mid`. Below, two arrays, `[1, 2, 4, 5, 7, 8, 9]` and `[1, 2, 4, 5, 7, 8, 9]`, represent the initial and updated search spaces respectively.  Index numbers are shown below each element.  Boxes labeled 'left,' 'mid,' and 'right' indicate the boundaries of the search space. Arrows show the flow of information:  'left' points to the first element (index 0), 'mid' points to the element at index 3 (value 5), and 'right' initially points to the last element (index 7).  In the updated array, a dashed orange line connects the previous `right` pointer (index 7) to the new `right` pointer (index 3), visually representing the narrowing of the search space due to the condition being met. The element at `mid` (value 5) is highlighted, and the number 4 is highlighted in both arrays to emphasize its position relative to the `mid` and `left` pointers.

Case 2: The midpoint value is less than the target, indicating the lower bound is somewhere to the right. In this case, we narrow the search space toward the right, ensuring the midpoint is excluded:

Image represents a visual depiction of a binary search algorithm's iteration.  The top shows a code snippet: `if nums[mid] < target: left = mid + 1`, indicating a conditional statement within the algorithm where if the value at the middle index (`mid`) of the array `nums` is less than the target value, the `left` boundary is updated to `mid + 1`. Below, two arrays `[1, 2, 4, 5, 7, 8, 9]` are shown, representing the search space.  The first array shows the initial state with index numbers (0-7) beneath each element.  Arrows labeled 'left,' 'mid,' and 'right' point to the respective boundaries of the search space (1, 5, and 9 initially).  The second array illustrates the next iteration.  The `mid` pointer has moved, and the `left` pointer has shifted to the right (indicated by an orange dashed arrow) because the condition `nums[mid] < target` was true.  The `right` pointer remains unchanged.  The index numbers are again shown below the array.  The number 8 is highlighted in light green in both arrays, suggesting it might be the target value or relevant to the search.

Implementation

python
from typing import List
    
def find_the_insertion_index(nums: List[int], target: int) -> int:
    left, right = 0, len(nums)
    while left < right:
        mid = (left + right) // 2
        # If the midpoint value is greater than or equal to the target, the lower
        # bound is either at the midpoint, or to its left.
        if nums[mid] >= target:
            right = mid
        # The midpoint value is less than the target, indicating the lower bound is
        # somewhere to the right.
        else:
            left = mid + 1
    return left

Complexity Analysis

Time complexity: The time complexity of find_the_insertion_index is O(log⁡(n))O(\log(n))O(log(n)) because it performs a binary search over a search space of size n+1n+1n+1.

Space complexity: The space complexity is O(1)O(1)O(1).

First and Last Occurrences of a Number

First and Last Occurrences of a Number

Given an array of integers sorted in non-decreasing order, return the first and last indexes of a target number. If the target is not found, return [-1, -1] .

Example 1:

python
Input: nums = [1, 2, 3, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11],
       target = 4
Output: [3, 5]

Explanation: The first and last occurrences of number 4 are indexes 3 and 5, respectively.

Intuition

A brute-force solution to this problem involves a linear search to find the first and last occurrences of the target number. However, since the array is sorted, we can try searching for these two occurrences using binary search.

The challenge of using binary search here is that we need to find two separate occurrences of the same number. This means that a standard binary search alone isn’t sufficient.

To help us find a solution, it’s important to understand that the problem is effectively asking us to find the lower and upper bound of a number in the array:

Image represents a sequence of integers from 1 to 11, displayed in two rows. The top row shows the integers [1, 2, 3, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11], with the three consecutive '4's highlighted in a light peach/orange rectangle.  The bottom row displays the corresponding indices, ranging from 0 to 12.  Two orange downward-pointing arrows connect the bottom row to labels below. The arrow originating from index 3 points to the label 'lower bound,' and the arrow originating from index 5 points to the label 'upper bound,' indicating that the highlighted sequence of three '4's starts at the lower bound (index 3) and ends at the upper bound (index 5).

This indicates we can solve this problem in two main steps:

  1. Perform a binary search to find the lower bound of the target.
  2. Perform a binary search to find the upper bound of the target.

Lower-bound binary search To find the start position of the target, let’s first define the search space. The target could be anywhere in the array, so the search space should encompass all the array’s indexes.

Next, let’s figure out how to narrow the search space. At each point in the binary search, there are three conditions to consider based on the midpoint value:

  • when it's greater than the target
  • when it's less than the target
  • when it’s equal to the target

In each of these cases, think about where the target is in relation to the midpoint. Note that we’re effectively looking for the leftmost occurrence of the target value.

Midpoint value is greater than the target If the midpoint value is greater, it means the target is to the left of this number. So, narrow the search space toward the left.

When we do this, we can exclude the midpoint from the search space (i.e., right = mid - 1) because its value is not equal to the target:

Image represents a visual depiction of a step within a binary search algorithm or a similar search/sorting process operating on a numerical array.  The array `nums` is displayed horizontally, with elements [1, 2, 3, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11].  Index values are shown below each element (0-12).  Three labeled pointers, 'left', 'mid', and 'right', indicate array boundaries or positions.  'left' points to the beginning of the array (index 0), 'right' points to the end (index 12), and 'mid' points to element 4 at index 3 (highlighted in light green). A dashed box below the array displays a conditional statement: `nums[mid] > 4`.  An arrow indicates that if this condition is true, then the 'right' pointer is updated to `right = mid - 1`, effectively narrowing the search space to the left half of the array.  The visual arrangement clearly shows the pointers' positions and how the condition affects the 'right' pointer's movement, illustrating a key step in a divide-and-conquer approach to searching or sorting.
Image represents a visual depiction of a merging or combining operation, possibly within a sorting or merging algorithm.  Two labeled input streams, 'left' and 'right,' each feed into a sequence of numbered elements. The 'left' stream contributes elements 1, 2, and 3, while the 'right' stream contributes elements 4, 4. Element 3 from the left stream is highlighted in light green, suggesting a potential comparison or selection point.  The numbers 5 through 11 are shown in light gray, representing the output sequence. A dashed orange line connects the last element of the 'left' stream (3) to the second element of the 'right' stream (4), and then curves to the last element of the output sequence (11), visually indicating the flow of data and the merging process.  The numbers below the main sequence (0-12) likely represent indices or positions within the arrays.

Midpoint value is less than the target If the midpoint value is smaller, it means the target is to the right of this number. So, narrow the search space toward the right. Again, we can exclude the midpoint from the search space (i.e., left = mid + 1) because its value is not equal to the target:

Image represents a visual depiction of a binary search algorithm's step.  Three labeled boxes, 'left,' 'mid,' and 'right,' point downwards to respective indices within a numbered array [1, 2, 3, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11].  The array's indices are shown below the numbers, ranging from 0 to 12.  'left' points to index 0 (value 1), 'mid' points to index 2 (value 3), highlighted in light green, and 'right' points to index 5 (value 4).  A dashed box at the bottom displays the condition `nums[mid] < 4`, which evaluates to true because 3 < 4.  An arrow indicates this condition leads to the assignment `left = mid + 1`, updating the 'left' index to 3 (the next index after 'mid').  The visual emphasizes the iterative process of narrowing down the search space within the array based on the comparison of the middle element with the target value (implicitly 4).
Image represents a visual depiction of a data structure or algorithm, possibly illustrating array manipulation or pointer movement.  A numbered horizontal line, representing an array or sequence from index 0 to 11, is shown.  Two rectangular boxes labeled 'left' (orange) and 'right' (gray) are positioned above the line.  A light-green circle containing the number '4' sits on the line at index 3.  A dashed orange arrow originates from the 'left' box and points to the circle at index 3, indicating a potential left-side operation or pointer. A solid gray arrow descends from the 'right' box to the number '4' at index 4, suggesting a right-side operation or pointer.  The numbers '4' at indices 3 and 4 are emphasized, possibly highlighting the current focus or manipulation within the array. The remaining numbers on the line (1-3, 5-11) are light gray, suggesting they are not the current focus of the operation.  The bottom row of numbers (0-12) acts as a secondary index for the array, clarifying the position of each element.

Midpoint value is equal to the target Now is when things get interesting. When the midpoint value is equal to the target, there are two possibilities:

  1. This is the lower bound of the target value.
  2. This is not the lower bound, so the lower bound is somewhere further to the left.

We don’t know which possibility is true at the moment, so we should narrow the search space toward the left to continue looking for the lower bound, while also including the midpoint itself in the search space (i.e., right = mid).

Image represents a visual depiction of a binary search algorithm step.  At the top, three rectangular boxes labeled 'left,' 'mid' (highlighted in light blue), and 'right' indicate index pointers. Arrows descend from each box, pointing to a numbered sequence [1, 2, 3, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11] displayed below.  The numbers are positioned above their corresponding indices (0-12) shown in a lighter gray. The 'mid' pointer points to the value '4' at index 4.  A light green circle highlights the '4' at index 3, visually representing the 'left' pointer's position.  The 'right' pointer also points to a '4' at index 5. At the bottom, a dashed-line box displays the code snippet 'nums[mid] == 4 → right = mid,' illustrating the algorithm's logic: if the value at the middle index ('nums[mid]') equals the target value (4), then the 'right' pointer is updated to the 'mid' index. This signifies that the search space has been narrowed down.
Image represents a visual depiction of a data structure or algorithm, possibly related to array manipulation or pointer movement.  A numbered horizontal line, representing an array or sequence from index 0 to 11, is shown.  Two rectangular boxes labeled 'left' (in gray) and 'right' (in orange) are positioned above the line. A downward arrow from 'left' points to a light green circle containing the number '4' at index 3 on the horizontal line. A downward arrow from 'right' points to the number '4' at index 4 on the horizontal line. A dashed orange arrow connects the number '4' at index 4 to the number '4' at index 3, suggesting a possible iterative or comparative operation between these two elements.  The numbers below the horizontal line represent the indices of the array, while the numbers above represent the values at those indices. The overall diagram illustrates a concept where two pointers, 'left' and 'right,' are positioned at specific indices within a data structure, potentially for searching, sorting, or other operations.

Continue this reasoning for the next midpoint as it’s also equal to the target:

Image represents a visual depiction of a binary search algorithm step.  A numbered array [1, 2, 3, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11] is shown with indices displayed below.  Three labeled boxes, 'left,' 'mid,' and 'right,' represent pointers to array elements.  A light-green circle highlights the element at index 3, which is the value 4, indicating the current 'mid' point.  Arrows point from 'left' and 'right' to the 'mid' element, showing how these pointers converge.  Below the array, a code snippet 'nums[mid] == 4 → right = mid' illustrates the algorithm's logic:  if the value at the midpoint ('nums[mid]') equals the target value (4), the 'right' pointer is updated to the current 'mid' index, effectively narrowing the search space to the left half of the array for the next iteration.
Image represents a number line ranging from 1 to 11, with a light green circle labeled '4' positioned at the index 3 on the number line.  Above the number line, two rectangular boxes are present; one labeled 'left' in dark gray and the other labeled 'right' in orange. A dark gray arrow points from the 'left' box to the circle '4', indicating a leftward movement or association.  Simultaneously, an orange dashed arrow points from the 'right' box to the circle '4', suggesting a rightward movement or association. The number line itself is marked with numbers from 0 to 12 below and 1 to 11 above, providing a visual context for the position of the circle '4'.  The arrows and labels suggest a concept of directional movement or selection relative to a central point ('4') within a larger numerical context.

The final value, once the left and right pointers meet, is the lower bound of the target.

Upper-bound binary search There’s a lot of similarity between this and lower-bound binary search. For starters, the search space is identical. Additionally, the cases when the midpoint value is greater than or less than the target are handled the same. This makes sense because, in both binary searches, we’re seeking the same target value. So, when the midpoint value is not equal to the target, the actions we take will be the same as the actions taken in the lower-bound binary search.

The difference in logic occurs when the midpoint is equal to the target, as we’re now looking for the rightmost value of the target, instead of the leftmost value. As such, let’s just focus on the logic for when the midpoint value is equal to the target.

Midpoint value is equal to the target Similar to lower-bound binary search, there are two possibilities: either this is the upper bound, or it’s not. Again, we’re not sure which is true. So, let’s narrow the search space toward the right to continue looking for an upper bound while including the midpoint in the search space (i.e., left = mid):

Image represents a visual depiction of a binary search algorithm step.  At the top, three rectangular boxes labeled 'left,' 'mid' (highlighted in light blue), and 'right' are shown.  Arrows descend from each box, pointing to elements within a numbered array represented horizontally below. The array, indexed from 0 to 12, contains the numbers [1, 2, 3, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11]. The 'left' arrow points to the number 4 at index 3, the 'mid' arrow points to the number 4 at index 4, and the 'right' arrow points to the number 4 at index 5 (highlighted in light green).  At the bottom, a gray dashed box displays the code snippet 'nums[mid] == 4 -> left = mid,' indicating that because the value at the middle index ('mid') is equal to 4, the 'left' index is updated to equal the 'mid' index. This illustrates a step in the binary search where the target value (4) has been found at the middle index, resulting in an update of the search space.
Image represents a numbered line ranging from 1 to 11, with a secondary numbering system below indicating positions 0 through 12.  Two rectangular boxes, one orange labeled 'left' and one gray labeled 'right,' are positioned above the line.  A downward-pointing arrow from the 'left' box points to the number 4 on the main line, while a dashed arrow from the 'left' box also points to the number 4. A downward-pointing arrow from the 'right' box points to a light green circle containing the number 4, positioned at the 5th position on the main line.  The arrangement visually depicts two different approaches or methods ('left' and 'right') affecting the placement of the number 4, with the 'left' method potentially indicating a less precise or approximate placement compared to the 'right' method's more precise placement at position 5.

When we continue this logic for the next midpoint values, we notice something peculiar happen:

Image represents a visual depiction of a binary search algorithm step.  A numbered array [1, 2, 3, 4, 4, 5, 6, 7, 8, 9, 10, 11] is shown, with indices displayed below.  Above the array, three rectangular boxes labeled 'left,' 'mid,' and 'right' represent pointers or indices within the array.  A light-green circle highlights the element at index 4 (value 4).  Arrows point from 'left' and 'right' to this highlighted element, indicating the current midpoint of the search. Below the array, a code snippet 'nums[mid] == 4 → left = mid' shows that the value at the midpoint (nums[mid]) is 4, and consequently, the 'left' pointer is updated to equal the 'mid' pointer. This suggests the algorithm is searching for a value (likely 4), and this step shows the narrowing of the search space by updating the 'left' pointer.
Image represents a numbered horizontal line segment ranging from 0 to 12, with markers indicating each integer value.  Two rectangular boxes labeled 'left' and 'right' are positioned above the line.  Arrows descend from these boxes, pointing to the number '4' on the line.  The 'left' arrow points to a black '4' at position 4 on the line, while the 'right' arrow points to a light green '4' at position 5 on the line.  The numbers on the line are displayed in a lighter gray except for the two '4's, which are prominently displayed in black and light green, respectively, highlighting their significance in relation to the 'left' and 'right' labels.  The entire diagram visually illustrates a concept related to left and right positioning or indexing within a numerical sequence.
Image represents a visual depiction of a binary search algorithm step.  At the top, three rectangular boxes labeled 'left,' 'mid,' and 'right' represent index pointers within a sorted numerical array shown below.  The array, depicted as a number line from 0 to 12, contains the numbers 1 through 11.  A light green circle highlights the element at index 4, which holds the value 4.  Light blue arrows point from 'mid' to this highlighted element, indicating that the midpoint of the search is currently at index 4.  Below the array, a dashed box displays the code snippet 'nums[mid] == 4 -> left = mid,' illustrating that because the value at the midpoint (nums[mid]) equals the target value (4), the 'left' pointer is updated to equal the 'mid' pointer. This signifies that the search has successfully found the target value at the midpoint.
Image represents a visual depiction of a data structure, possibly an array or list, illustrating a coding pattern.  A numbered horizontal line, ranging from 0 to 12, represents the indices of the data structure.  The numbers 1 through 11 are placed above the line, indicating the elements' positions.  Two rectangular boxes labeled 'left' and 'right' are positioned above the line, each pointing downwards with an arrow towards the number 4 at index 4.  A light green circle highlights the number 4 at index 5.  A series of three dots below the number 4 at index 4 suggests a continuation or further elements not explicitly shown. The overall arrangement shows the selection or highlighting of a specific element (4 at index 5) potentially based on the 'left' and 'right' pointers, suggesting a concept like a window or range selection within the data structure.

It looks like we just got stuck in an infinite loop, where the position of the left pointer keeps getting set to mid when they’re both at the same index. Why does this happen?

Debugging the infinite loop When the left and right pointers are directly next to each other, mid ends up being positioned where the left pointer is:

Image represents a visual depiction of a midpoint calculation.  On the left, two rectangular boxes labeled 'left' and 'right' are shown, each pointing downwards with an arrow to a separate bracket representing an array or list. A thick black arrow points from these brackets to another set of brackets on the right. Above this arrow, the formula `mid = (left + right) // 2` is displayed in light blue, indicating that the midpoint (`mid`) is calculated by summing the values of 'left' and 'right', then performing integer division by 2. On the right side, three boxes are shown: 'left', a light blue box labeled 'mid', and 'right'. Arrows from 'left', 'mid', and 'right' point downwards to the brackets, illustrating that the midpoint calculation results in the addition of a 'mid' value between the 'left' and 'right' values within the array or list.  The overall diagram illustrates a step in a binary search or similar algorithm where the midpoint is calculated to efficiently search a sorted data structure.

Since one of our operations is left = mid, we get stuck in an infinite loop where left and mid are continuously set to each other’s positions, which means progress cannot be made. The reason this wasn’t a problem during lower-bound binary search was that we never had left = mid as an operation in the logic. One way to avoid this issue is by biasing the midpoint to the right.

When the midpoint is biased to the right, we avoid an infinite loop during upper-bound binary search.

  • We no longer need to worry about the left pointer since mid is now being positioned at the right pointer when the search space has two elements.
  • We won’t encounter any infinite loops with the right pointer because it never gets set to the midpoint’s position, as we use right = mid - 1.

A right bias of the midpoint can be achieved using mid = (left + right) // 2 + 1:

Image represents a visual depiction of a midpoint calculation within a coding pattern, likely related to binary search or similar algorithms.  The diagram shows two stages. The initial stage displays two rectangular boxes labeled 'left' and 'right' in grey, each pointing downwards via an arrow to a single dot within square brackets, representing an array or list.  A thick black arrow connects this initial stage to a second stage.  The arrow is labeled with the calculation `mid = (left + right) // 2 + 1` in cyan, indicating that the midpoint (`mid`) is computed by adding the values of 'left' and 'right', performing integer division by 2 (using the `//` operator), and then adding 1. The second stage shows three boxes: 'left' and 'right' (in grey) and a cyan box labeled 'mid'. Arrows point from 'left', 'mid', and 'right' to individual dots within square brackets, suggesting that the midpoint calculation has partitioned the original array or list.  The overall diagram illustrates how the midpoint is calculated and used to divide a data structure into sub-parts.

Now, performing left = mid allows us to properly narrow the search space.

As a general rule, in upper-bound binary search, we should bias mid to the right.

Let’s apply this change to the same example and see what happens:

Image represents a visual depiction of a step within a binary search algorithm.  At the top, three rectangular boxes labeled 'left,' 'mid' (highlighted in light blue), and 'right' are shown. Arrows descend from each box, pointing to elements within a numbered array visualized horizontally below. The array contains the numbers 1 through 11, with indices displayed below. The 'left' arrow points to the number 4 at index 3, the 'mid' arrow points to the number 4 at index 4, and the 'right' arrow points to the number 4 at index 5 (highlighted in light green).  At the bottom, a dashed-line box displays the code snippet 'nums[mid] == 4 → left = mid,' indicating that because the value at the middle index ('mid') of the array is equal to 4, the 'left' index is updated to be equal to the 'mid' index. This illustrates a key step in the binary search where the search space is narrowed based on the comparison of the middle element with the target value.
Image represents a visual depiction of a step within a binary search algorithm.  A numbered array, indexed from 0 to 12, is shown with values implicitly represented by their index positions.  The array segment from index 0 to 12 is displayed.  Three labeled boxes, 'left' (orange), 'mid' (cyan), and 'right' (gray), represent index pointers.  Arrows indicate that 'left' and 'right' initially point to indices 3 and 5 respectively, while 'mid' points to index 4.  A light green circle highlights the element at index 4, which has a value of 4. A dashed orange arrow shows a tentative movement of the 'left' pointer. A solid cyan arrow shows the movement of the 'mid' pointer. A separate box displays the condition `nums[mid] == 4 -> left = mid`, indicating that because the element at the midpoint (index 4) equals 4, the 'left' pointer is updated to the value of the 'mid' pointer (index 4).  This illustrates a step where the target value is found at the midpoint, resulting in the update of the 'left' pointer.
Image represents a number line ranging from 1 to 11, with a lighter gray scale used for the numbers and a darker gray line connecting them.  Below the number line, a fainter gray scale shows the corresponding numerical values from 0 to 12, acting as a reference scale.  A light green circle labeled '4' is positioned at the 4.5 mark on the number line. Two rectangular boxes, one orange labeled 'left' and one gray labeled 'right,' are positioned above the number line.  A dark orange arrow points from the 'left' box to the circle, and a dark gray arrow points from the 'right' box to the circle. A dashed orange line curves from the number '4' on the number line to the green circle, suggesting an association or movement towards the circle from the left.  The overall arrangement visually depicts a concept related to directional movement or selection on a numerical scale, possibly illustrating a binary search or similar algorithm.

As we can see, we’ve avoided an infinite loop, with the left and right pointers meeting at the upper bound of the target.

If the target doesn’t exist The last step in both algorithms is to check that the final values located are equal to the target. If the target does not exist in the array, the final values in both binary search algorithms won’t be equal to the target. In this case, we should return -1.

Implementation

python
from typing import List
    
def first_and_last_occurrences_of_a_number(nums: List[int], target: int) -> List[int]:
    lower_bound = lower_bound_binary_search(nums, target)
    upper_bound = upper_bound_binary_search(nums, target)
    return [lower_bound, upper_bound]
    
def lower_bound_binary_search(nums: List[int], target: int) -> int:
    left, right = 0, len(nums) - 1
    while left < right:
        mid = (left + right) // 2
        if nums[mid] > target:
            right = mid - 1
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid
    return left if nums and nums[left] == target else -1
    
def upper_bound_binary_search(nums: List[int], target: int) -> int:
    left, right = 0, len(nums) - 1
    while left < right:
        # In upper-bound binary search, bias the midpoint to the right.
        mid = (left + right) // 2 + 1
        if nums[mid] > target:
            right = mid - 1
        elif nums[mid] < target:
            left = mid + 1
        else:
            left = mid
    # If the target doesn’t exist in the array, then it's possible that
    # 'left = mid + 1' places the left pointer outside the array when `mid == n - 1`.
    # So, we use the right pointer in the return statement instead.
    return right if nums and nums[right] == target else -1

Complexity Analysis

Time complexity: The time complexity of both the lower_bound_binary_search and upper_bound_binary_search helper functions is O(log⁡(n))O(\log(n))O(log(n)), where nnn is the length of the input array. This is because each function performs a binary search over the entire array. Therefore, first_and_last_occurrences_of_a_number is also O(log⁡(n))O(\log(n))O(log(n)) because it calls each helper function once.

Space complexity: The space complexity is O(1)O(1)O(1).

Interview Tip

Tip: Always test your algorithm. Binary search can be a tricky algorithm to implement. The best way to uncover unexpected errors is to test your code. The infinite loop encountered during the upper-bound binary search problem is quite easy to miss while designing the algorithm. If you're unable to recognize this issue during implementation, manual testing can help reveal it. In binary search, an infinite loop can be uncovered when testing a search space that contains just two elements, similar to what we did in the explanation.

Cutting Wood

Cutting Wood

You are given an array representing the heights of trees, and an integer k representing the total length of wood that needs to be cut.

For this task, a woodcutting machine is set to a certain height, H. The machine cuts off the top part of all trees taller than H, while trees shorter than H remain untouched. Determine the highest possible setting of the woodcutter (H) so that it cuts at least k meters of wood.

Assume the woodcutter cannot be set higher than the height of the tallest tree in the array.

Example:

Image represents a bar chart illustrating a coding pattern, likely related to data structures or algorithms.  The horizontal axis (labeled 'i' at the far right) represents an index or position, while the vertical axis is labeled 'height'. Four bars are shown, with heights varying. The first bar, at index 0, has a height of 2. The second bar, at index 1, has a base height of 3 (light gray) and an additional height of 3 (orange hatched pattern), totaling 6. The third bar, at index 2, has a height of 3. The fourth bar, at index 3, has a base height of 3 (light gray) and an additional height of 5 (orange hatched pattern), totaling 8. A horizontal orange line is drawn at height 3, labeled 'H = 3'.  Braces in light blue indicate the additional height above this line for bars at indices 1 and 3, with the values '3' and '5' respectively written next to them.  The chart visually demonstrates a pattern where a base height is consistently present, with varying additional heights stacked on top.
python
Input: heights = [2, 6, 3, 8], k = 7
Output: 3

Explanation: The highest possible height setting that yields at least k = 7 meters of wood is 3, which yields 8 meters of wood. Any height setting higher than this will yield less than 7 meters of wood.

Constraints:

  • It's always possible to attain at least k meters of wood.
  • There's at least one tree.

Intuition

At first, it might strike you as strange that this problem is in the Binary Search chapter. The given input array isn't necessarily sorted, so how is binary search applicable here? Well, this is an example of a common application of binary search, where the search space does not encompass the input array.

Let’s consider a visualization of four trees of heights [2, 6, 3, 8] and assume k = 7:

Image represents a bar chart illustrating a data set where the horizontal axis, labeled 'i', represents an index or identifier, and the vertical axis, labeled 'height', represents a corresponding numerical value.  Four bars are displayed, each with a distinct height.  The first bar, at index '0', has a height of approximately 2 units. The second bar, at index '1', reaches approximately 6 units. The third bar, at index '2', has a height of around 3 units. Finally, the fourth bar, at index '3', has the greatest height, approximately 8 units.  The bars are light gray, outlined in black, and are positioned along the horizontal axis with equal spacing between them.  The chart visually depicts the relationship between the index 'i' and its associated 'height' value, showing a non-linear pattern of increasing and decreasing heights across the data points.

The tallest tree above has a height of 8. So the height setting, H, can be set to any height between 0 and 8.

  • The most amount of wood is cut at H = 0, where all trees are cut from the bottom.
  • The least amount of wood is cut at H = 8, where no wood is cut at all.
Image represents a comparative visualization of two scenarios involving wood cutting.  The left side depicts a scenario where the height of the axe (H) is 0, resulting in a total wood cut of 19 units. This is shown as a bar chart with four bars of varying heights (2, 6, 3, 8 units respectively) representing the amount of wood cut at each of four positions (i = 0, 1, 2, 3).  Each bar is orange with diagonal stripes.  The y-axis represents the 'height' of the wood cut at each position, and the x-axis represents the position 'i'.  The right side shows a scenario where the axe height (H) is 8.  In this case, the total wood cut is 0, represented by a horizontal orange line at height 8.  The bar chart on this side shows four bars (2, 6, 3, 8 units respectively) representing the initial wood heights at each position, but they are light gray, indicating no wood was cut.  Both charts share the same x-axis scale (i = 0, 1, 2, 3) and a similar y-axis scale ('height').  A small axe icon is drawn next to each chart, with the corresponding axe height (H) value labeled in orange text.  The total wood cut for each scenario is displayed above each chart.

Gradually increasing the height setting of the woodcutter from H = 0 to H = 8 yields less and less wood, and our goal is to find the highest value of H that gives us at least k meters of wood.

Determining if a height setting yields enough wood We need a function that determines if any given height setting H yields at least k meters of wood.

Let’s name this function cuts_enough_wood(H, k), which will calculate the total wood obtained by cutting the trees taller than H, and return true if this total meets or exceeds k. Below is a visual representation of how to determine if a height setting of 3 yields enough wood in the example:

Image represents a bar chart illustrating a function `cuts_enough_wood(H = 3, k = 7)`.  The chart's x-axis is labeled 'i' and represents the index of wood pieces, ranging from 0 to 3. The y-axis is labeled 'height' and represents the height of wood pieces in meters. Three bars are shown; the first has a height of 2 meters, the second has a height of 6 meters with an additional 3 meters highlighted in orange stripes, and the third has a height of 3 meters with an additional 5 meters highlighted in orange stripes.  Arrows point upwards from the top of the orange sections of the second and third bars, labeled '3 meters' and '5 meters' respectively.  These values represent the heights of the additional wood collected from those pieces.  To the right, a box displays the calculation `wood_collected = 3 + 5`, indicating the sum of the additional wood heights.  This sum, 8, is then compared to 'k' (which is 7, as defined in the function's parameters), resulting in the condition `8 ≥ k`, which evaluates to true.  Finally, an arrow points to the text 'return True,' indicating the function's output based on the condition's result.

Applying this function to all possible values of H (from 0 to 8) gives us the outcome below, where heights 0 to 3 yield at least k meters of wood and heights 4 to 8 are too high and don’t yield enough. Note that here, we visualize which H values make the function cuts_enough_wood return true, and which will result in false.

Image represents a sequence of nine rectangular boxes arranged horizontally.  The first four boxes are colored green and contain the text 'True', while the remaining five boxes are colored red and contain the text 'False'.  Below each box is a label of the form 'H=n', where 'n' is an integer ranging from 0 to 8, sequentially corresponding to each box.  The arrangement visually depicts a boolean array or list, where the value of each element (True or False) is associated with an index (H=n). The color-coding further emphasizes the distinction between the True and False values, suggesting a possible visual representation of a condition or a result of a computation.

We could call cuts_enough_wood on each value of H from 0 to 8 until we reach the highest value that still causes cuts_enough_wood to return true. However, a key observation is that the above sequence of boolean outcomes is effectively a sorted sequence, since all true outcomes are positioned before false ones.

As this is a sorted sequence, we should try using binary search.

Binary search The goal is to find the last value of H that cuts at least k meters of wood. In other words, we’re looking for the upper bound value of H that satisfies this condition.

The image represents a visual depiction of a boolean array or list, likely illustrating a concept within a binary search or similar algorithm.  Nine rectangular boxes are arranged horizontally. The first four boxes are green, labeled 'True' and indexed H=0 through H=3.  The remaining five boxes are red, labeled 'False' and indexed H=4 through H=8.  Each box displays a boolean value (True or False) and its corresponding index (H=n, where n is an integer from 0 to 8). An orange upward-pointing arrow below the fourth box (H=3) is labeled 'upper bound,' indicating that this index represents the upper boundary of a subset within the larger array.  The arrangement visually separates the 'True' values from the 'False' values, suggesting a potential division point in a search algorithm where the 'True' values represent a successful search condition and the 'False' values represent an unsuccessful one.

As such, we should use upper-bound binary search. This means we’ll need to calculate the midpoint using mid = (left + right) // 2 + 1, as mentioned in the First and Last Occurrences of a Number problem.

Let’s first define the search space. Our search space should encompass all values of H between 0 and the height of the tallest tree in the array, as these are all possible answers.

To figure out how to narrow the search space, let's use the example below, setting left and right pointers at the ends of the search space:

Image represents a diagram illustrating a binary search concept or a similar partitioning algorithm.  Two orange rectangular boxes labeled 'left' and 'right' are positioned above a row of nine rectangular boxes.  Arrows descend from 'left' and 'right' pointing to the first and last boxes in the row respectively. The nine boxes represent a sequence of values, each labeled with 'H=n' where 'n' ranges from 0 to 8, indicating an index or position. The first four boxes (H=0 to H=3) are colored green and contain the value 'True,' while the remaining five boxes (H=4 to H=8) are colored red and contain the value 'False.'  The arrangement shows a split between 'True' and 'False' values, suggesting a search or partitioning process where the 'left' pointer initially points to a set of 'True' values and the 'right' pointer points to a set of 'False' values.  The diagram likely visualizes the state of a data structure at a specific point during a search or sorting algorithm.

Initially, the midpoint is set to H = 4. When we call cuts_enough_wood(4, k), it returns false. This means the height setting is not yielding enough wood and is, hence, set too high. To find a lower height setting, we should narrow our search space toward the left:

Image represents a visual depiction of a binary search algorithm or a similar iterative process.  Three rectangular boxes labeled 'left,' 'mid,' and 'right' at the top represent the initial boundaries and a midpoint of a search space. Arrows descend from 'left' and 'right' to a series of nine rectangular boxes, each displaying either 'True' (in green) or 'False' (in red) and labeled with 'H=0' through 'H=8,' indicating an index or iteration.  An arrow descends from 'mid' to the boxes starting at index 4. The boxes labeled 'True' from H=0 to H=3 are shaded light green, while those labeled 'False' from H=4 to H=8 are shaded light red.  A separate box at the bottom shows the code snippet 'cuts_enough_wood(4) = False,' indicating a function call that returned false at the midpoint (H=4).  Below this, an arrow and the assignment 'right = mid - 1' illustrate the algorithm's adjustment of the search space's right boundary based on the result of the function call, implying a narrowing of the search range in subsequent iterations.  The overall diagram illustrates a step in a search algorithm where the midpoint evaluation leads to a reduction of the search space.
Image represents a diagram illustrating a coding pattern, possibly related to searching or traversal.  The diagram shows two distinct sections. The left section features a grey box labeled 'left' with a downward arrow pointing to four green boxes, each containing the word 'True' and a label 'H=n' (where n is 0, 1, 2, and 3 respectively) indicating an index or counter.  A similarly styled orange box labeled 'right' sits above a series of grey boxes, each containing 'False' and labeled 'H=n' (where n ranges from 4 to 8). A dashed orange line connects the 'right' box to the last 'True' box on the left, suggesting a connection or data flow between these two points. The arrangement implies a process where a 'left' operation produces a sequence of 'True' values up to a certain point, after which a 'right' operation yields a sequence of 'False' values.  The overall structure suggests a binary search or a similar algorithm where a condition is checked, resulting in either a 'True' or 'False' outcome at each step.

The next midpoint is set to H = 2. When we call cuts_enough_wood(2, k), it returns true. This means the upper bound is either at the midpoint or to its right, as the upper bound is the rightmost height setting that cuts enough wood.

So, narrow the search space toward the right while including the midpoint:

The image represents a visual depiction of a binary search algorithm or a similar iterative process.  Three rectangular boxes labeled 'left,' 'mid' (in light blue), and 'right' (in dark gray) are positioned at the top, each pointing downwards with an arrow. Below these, a series of rectangular boxes represent the evaluation of a function, `cuts_enough_wood`, at different index values (H=0 through H=8). The boxes labeled H=0, H=1, H=2, and H=3 contain 'True' in green, indicating that the condition is met for these indices. The remaining boxes (H=4 through H=8) contain 'False' in light gray, showing the condition is not met. A separate gray box at the bottom displays the result of the function call `cuts_enough_wood(2) = True` and the subsequent assignment `left = mid`, indicating that the 'left' variable is updated to the value of 'mid' based on the function's output.  The arrangement shows the flow of information: the initial values of 'left', 'mid', and 'right' trigger the evaluation of the function at different indices, and the results of these evaluations determine the update of the 'left' variable.
Image represents a diagram illustrating a binary search algorithm or a similar traversal process.  A sequence of rectangular boxes, each labeled with 'True' or 'False' and an index 'H=n' (where n represents an integer from 0 to 8), are arranged horizontally.  Two boxes labeled 'True' at indices H=2 and H=3 are highlighted in green, indicating a potential search result or a point of interest.  Above these highlighted boxes, two orange rectangular boxes labeled 'left' and 'right' represent directional pointers or decision points.  A solid downward arrow from 'right' points to the 'True' box at H=3, while a dashed orange curved arrow connects 'left' to the 'True' box at H=2.  The remaining boxes, labeled 'False,' are light gray, suggesting they were not selected during the search. The overall structure suggests a search algorithm that starts at a point and moves left or right based on some condition, ultimately identifying the highlighted 'True' values.

The next midpoint is set to H = 3. When we call cuts_enough_wood(3, k), it returns true. So, narrow the search space toward the right while including the midpoint:

Image represents a visual depiction of a binary search or similar algorithm's execution.  At the top, three labeled boxes, 'left,' 'mid' (in light blue), and 'right,' represent index pointers or variables.  Arrows indicate that 'left' points downwards to a 'True' box labeled 'H=2,' and 'mid' and 'right' point downwards to 'True' (H=3) and a series of 'False' boxes (H=4 through H=8) respectively. These boxes, arranged horizontally, represent the evaluation of a condition, 'cuts_enough_wood(3),' at different indices (H values). The green boxes ('True' at H=2 and H=3) signify that the condition is met at those indices. The grey boxes ('False' at H=4 to H=8) indicate the condition is not met.  A separate box at the bottom shows the result of the function call `cuts_enough_wood(3)` as 'True' and the subsequent assignment `left = mid`, indicating an update to the 'left' pointer based on the search result.  The overall diagram illustrates a step in a search process where the search space is narrowed down based on the evaluation of a condition at different indices.
Image represents a diagram illustrating a coding pattern, likely related to searching or traversal.  A horizontal sequence of nine rectangular boxes displays boolean values ('True' or 'False'), each labeled with an index 'H=n' (where n ranges from 0 to 8).  Three additional rectangular boxes are positioned above: two labeled 'left' (in orange) and 'right' (in gray).  A solid arrow points from 'right' to the box labeled 'True' at index H=3. A dashed arrow points from 'left' to the same 'True' box at H=3.  The boxes at indices H=0, H=1, and H=2 show 'True,' while those at indices H=4 through H=8 show 'False.' The central 'True' box (H=3) is highlighted in green, suggesting it's the result or focus of the operation. The arrows indicate that the 'left' and 'right' labels represent directional parameters or conditions influencing the selection or identification of the 'True' value at H=3.

Once the left and right pointers meet, we have located the upper bound height setting that yields at least k meters of wood.

Summary Case 1: The midpoint is set at a height that allows us to cut at least k meters of wood, indicating the upper bound is somewhere to the right. Narrow the search space to the right while including the midpoint:

Image represents a visual depiction of a binary search algorithm, likely within a function called `cuts_enough_wood`.  The top section shows a code snippet: `if not cuts_enough_wood(mid): right = mid - 1`, indicating a conditional statement where if the function `cuts_enough_wood` returns `False` for a given `mid` value, the `right` boundary is adjusted. Below this, two rows of boxes illustrate the algorithm's iterations. Each box represents a value (H=0 to H=6) and displays whether `cuts_enough_wood` returned `True` (green) or `False` (red) for that value.  Arrows labeled 'left' and 'right' show the search space boundaries.  In the first row, the `mid` value is tested, and based on the result (False), the `right` boundary is updated (as per the code snippet). The second row shows the updated search space after the `right` boundary has been moved to the left.  A dashed orange line connects the old and new `right` boundaries, visually representing the adjustment.  The boxes in the second row that are greyed out represent values that are no longer considered in the search after the boundary update.

Case 2: The midpoint is at a height that doesn’t allow us to cut enough wood, indicating the upper bound is somewhere to the left. Narrow the search space to the left while excluding the midpoint:

Image represents a visual depiction of a binary search algorithm, likely within a wood-cutting context.  The top section shows a conditional statement: `if cuts_enough_wood(mid): left = mid;`. This implies a function `cuts_enough_wood` is used to determine if a sufficient amount of wood is cut at a midpoint (`mid`). If true, the `left` boundary is updated to `mid`. Below, two rows of rectangular boxes represent an array, each box showing a boolean value (`True` or `False`) indicating whether a cut at that index (`H=0` to `H=6`) meets the condition.  The top row shows the initial state.  A light-blue box labeled `mid` points down to the middle of the top row, indicating the initial midpoint.  Gray boxes labeled `left` and `right` point to the leftmost and rightmost elements respectively.  The bottom row shows the state after the conditional statement is executed.  A dashed orange line connects the initial `left` section to the updated `left` section in the bottom row, visually demonstrating the shift of the `left` boundary.  The `right` boundary remains unchanged.  The green boxes represent `True` values (enough wood cut), while red boxes represent `False` values (insufficient wood cut).  The overall diagram illustrates how the search space is narrowed down iteratively based on the result of the `cuts_enough_wood` function.

Implementation

python
from typing import List
    
def cutting_wood(heights: List[int], k: int) -> int:
    left, right = 0, max(heights)
    while left < right:
        # Bias the midpoint to the right during the upper-bound binary search.
        mid = (left + right) // 2 + 1
        if cuts_enough_wood(mid, k, heights):
            left = mid
        else:
            right = mid - 1
    return right
    
# Determine if the current value of 'H' cuts at least 'k' meters of wood.
def cuts_enough_wood(H: int, k: int, heights: List[int]) -> bool:
    wood_collected = 0
    for height in heights:
        if height > H:
            wood_collected += (height - H)
    return wood_collected >= k

Complexity Analysis

Time complexity: The time complexity of cutting_wood is O(nlog⁡(m))O(n\log(m))O(nlog(m)), where mmm is the maximum height of the trees. This is because we perform a binary search over the range [0, mmm]. Each iteration of the binary search calls the cuts_enough_wood function, which runs in O(n)O(n)O(n) time, where nnn is the number of trees. This results in an overall time complexity of O(log⁡(m))O(n)=O(nlog⁡(m))O(\log(m))O(n)=O(n\log(m))O(log(m))O(n)=O(nlog(m)).

Space complexity: The space complexity is O(1)O(1)O(1).

Find the Target in a Rotated Sorted Array

Find the Target in a Rotated Sorted Array

A rotated sorted array is an array of numbers sorted in ascending order, in which a portion of the array is moved from the beginning to the end. For example, a possible rotation of [1, 2, 3, 4, 5] is [3, 4, 5, 1, 2] , where the first two numbers are moved to the end.

Given a rotated sorted array of unique numbers, return the index of a target value. If the target value is not present, return -1.

Example:

python
Input: nums = [8, 9, 1, 2, 3, 4, 5, 6, 7], target = 1
Output: 2

Intuition

A naive solution is to iterate through the input array until we find the target number. This strategy takes linear time, and doesn’t take into account that the input is a rotated sorted array. Given the array was sorted before it was rotated, we should figure out how binary search might be useful in finding the target.

First, let’s define the search space for the binary search. Since the target value could exist anywhere in the array, the search space should encompass the entire array.

Now, let’s figure out how to narrow the search space, which is an interesting challenge considering the array isn’t perfectly sorted. Let’s work through this by exploring an example:

Image represents a depiction of a search problem.  The top row shows a list of integers, represented as an array `[8, 9, 1, 2, 3, 4, 5, 6, 7]`, enclosed in square brackets.  This array is implicitly indexed, with the indices 0 through 8 shown in a smaller font below the corresponding array elements.  The text 'target = 1' indicates that the goal is to find the element with the value 1 within the array.  The arrangement visually suggests a linear search scenario, where the algorithm would sequentially check each element of the array until it finds the element matching the target value.  There are no explicit connections or information flow depicted beyond the visual representation of the array and the target value.

Let’s set left and right pointers at the boundaries of the array and consider the first midpoint value:

Image represents a visual depiction of array partitioning, likely within the context of a divide-and-conquer algorithm.  Three rectangular boxes labeled 'left' (orange), 'mid' (light blue), and 'right' (orange) are positioned above a numerical array [8, 9, 1, 2, 3, 4, 5, 6, 7].  Arrows descend from each box, pointing to the array elements. The 'left' box's arrow points to the first two elements (8 and 9), the 'mid' box's arrow points to the element 3 (the middle element in this example), and the 'right' box's arrow points to the last two elements (6 and 7).  The numbers beneath the array (0-8) represent the indices of the array elements.  The diagram illustrates how an array is conceptually divided into sections, possibly for recursive processing, with the 'mid' pointer potentially indicating a pivot point or a division boundary.

In a normal sorted array, we’d be able to logically assess whether to search to the left or the right of the midpoint, based solely on the midpoint value and the target. In a rotated sorted array, it’s much less straightforward to determine which side the target value is on.

To decide whether to narrow the search space toward the left or right of the midpoint, let’s visualize the height of each number in the array and pay attention to subarrays [left : mid] and [mid : right] separately. Note, we include the midpoint in both subarrays since the midpoint is used to decide which subarray to narrow the search space towards:

Image represents a visual depiction of a sorting algorithm, likely merge sort, illustrating the merging of two sorted subarrays.  The top section shows an unsorted array [8, 9, 1, 2, 3, 4, 5, 6, 7] divided into three parts: 'left' [8, 9, 1, 2], 'mid' [3], and 'right' [4, 5, 6, 7].  Arrows labeled 'left', 'mid', and 'right' point downwards to their respective subarrays, indicating the input to the merging process.  The bottom section displays a bar chart representing the same numbers. The 'left' subarray's bars are taller and unsorted, while the 'right' subarray's bars are shorter, light blue, and sorted in ascending order, as indicated by the label 'ascending' below the chart.  The bars visually represent the magnitude of each number. The merging process is implied by the juxtaposition of the sorted 'right' subarray with the unsorted 'left' subarray, suggesting the next step would be to combine them into a single sorted array.

Here, we see the subarray [mid : right] is sorted in ascending order. Since it’s sorted, we know what the smallest and largest numbers in that range are: 3 and 7, respectively. This means we can check if the target is in this subarray by checking if it’s in between 3 and 7. The target (1) is not in this range, so therefore, it must be in the left subarray.

So, we should narrow the search space toward the left, excluding the midpoint (right = mid - 1):

Image represents a visual depiction of a two-pointer approach in a coding pattern, likely for array traversal or manipulation.  A gray rectangular box labeled 'left' points down to a numerical array [8, 9, 1, 2, 3, 4, 5, 6, 7].  Below the array, gray numbers 0 through 8 indicate the indices. An orange rectangular box labeled 'right' points down to the element '2' in the array, signifying its initial position. A dashed orange line curves from the element '2' to the element '7', illustrating a connection or interaction between these two points.  The numbers 3, 4, 5, 6, and 7 are shown in gray, indicating they are part of the array but not directly involved in the initial pointer positions. The overall diagram suggests a process where two pointers, one starting at the left and one at a position within the array, interact or move through the array, possibly to find a specific element or perform a comparison.

The reason we excluded the midpoint value was because it wasn’t equal to the target, and so should no longer be considered in the search space.

Let’s continue with the example.

Image represents a visual depiction of a sorting algorithm, likely merge sort, illustrating the merging step.  The top section shows three labeled boxes: 'left,' 'mid' (highlighted in cyan), and 'right,' each pointing downwards via an arrow to a numerical value; 'left' points to 8, 'mid' to 9, and 'right' to 1 and 2.  These values are positioned along a numbered horizontal axis (0-8), indicating their indices within an array.  Below, a bar chart visually represents the same numbers, with the heights of the bars corresponding to the magnitude of the values.  The bars representing 8 and 9 are taller and light blue, indicating they are already sorted in ascending order (labeled below the chart). The bars for 1 and 2 are smaller and white, representing the next portion to be merged.  The numbers 3-7 on the top horizontal axis represent the remaining unsorted portion of the array, which is not visually represented in the bar chart. The overall arrangement demonstrates the process of merging two sorted subarrays ('left' and 'right') to create a larger sorted array.

This time, the sorted subarray is the left subarray, [left : mid]. So, we can use this subarray to check where the target value is. Since the target (1) doesn't fall within the range between 8 and 9, it indicates the target resides in the right subarray. Therefore, we narrow our search space toward the right, excluding the midpoint (left = mid + 1):

Image represents a visual depiction of a coding pattern, likely illustrating array indexing or pointer manipulation.  A numbered horizontal line, representing an array or sequence, ranges from 0 to 7, with numbers 0-7 marked below.  Above the line, the numbers 1 and 2 are prominently displayed, positioned at indices 2 and 3 respectively.  Two rectangular boxes, labeled 'left' (in orange) and 'right' (in gray), are positioned above the line.  A solid downward-pointing arrow connects 'right' to the number 2 on the line, indicating a direct assignment or access.  A dashed orange curved arrow connects 'left' to the number 1 on the line, suggesting an indirect or potentially iterative access to this element, possibly involving a search or traversal from a position before index 2.  The numbers 3 through 7 on the line are lightly colored, indicating they are not the primary focus of the illustration.  The overall diagram suggests a scenario where two pointers or indices ('left' and 'right') are used to access or manipulate elements within a data structure.

Again, we excluded the midpoint value (9) as it was not equal to the target.

Finally, we’ve found the target at the midpoint, so we can return its index (mid):

Image represents a visual depiction of a binary search algorithm's midpoint calculation.  Three rectangular boxes labeled 'left,' 'mid,' and 'right' are positioned above a number line ranging from 0 to 8. The number line is marked with integers from 0 to 8 at the bottom and shows a subset of numbers (8, 9, 1, 2, 3, 4, 5, 6, 7) above.  A light gray line connects the numbers on the number line.  The 'left' box points with a downward arrow to the number 1 on the number line, and the 'right' box points to the number 2.  Crucially, a bright blue arrow points from the 'mid' box to the number 1, indicating that the midpoint of the search space (between the left and right boundaries) is calculated as 1 in this iteration.  This visualization illustrates how the midpoint is determined and used to narrow down the search range in a binary search.

Summary From our discussion above, an important strategy emerges: between the two subarrays, [left : mid] and [mid : right], we can use the sorted one to determine where the target is.

Before examining the subarrays, let’s first compare the target with the value at the midpoint. If they match, we've found our target at mid. If not, then we decide where to adjust our search depending on which subarray is sorted, as detailed in the following two test cases:

Case 1: the left subarray, [left : mid], is sorted

  • If the target falls in the range of this left subarray, we narrow the search space toward the left.
  • Otherwise, we narrow the search space toward the right.
Image represents a visual explanation of a binary search algorithm's behavior, specifically Case 1 where the subarray [left:mid) is sorted.  The top section shows an initial array [3, 4, 5, 6, 7, 1, 2] represented as bars with their values labeled.  Gray boxes labeled 'left' and 'right' indicate the initial search boundaries. A light-blue box labeled 'mid' highlights the middle element (6).  A downward-pointing arrow from 'left' points to 3, and another from 'right' points to 2. A cyan arrow points from 'mid' to the element 6. The bottom-left shows pseudocode:  `if nums[left] <= target < nums[mid] → right = mid - 1` else `→ left = mid + 1`. This logic dictates how the search boundaries ('left' and 'right') are adjusted based on the target value's relationship to the middle element. The bottom-right shows two subsequent steps of the algorithm. The first shows the array after the first iteration, with the 'right' pointer moved to the element 5. The second shows the array after the second iteration, with the 'left' pointer moved to the element 7.  Gray curved arrows connect the pseudocode to these steps, illustrating how the algorithm updates the search space based on the condition.  The dashed orange lines show the movement of the 'right' and 'left' pointers across the array during the iterations.

Case 2: the right subarray, [mid : right] is sorted

  • If the target falls in the range of this right subarray, we narrow the search space toward the right.
  • Otherwise, narrow the search space toward the left.
Image represents a visual explanation of a coding pattern, specifically a case within a binary search algorithm.  The top section shows an array of numbers [6, 7, 1, 2, 3, 4, 5] partitioned into three sections labeled 'left,' 'mid,' and 'right.'  The 'mid' section (containing 2) is highlighted in light blue. Arrows indicate the boundaries of these sections.  Below this, two further examples illustrate the algorithm's iterative steps. The first shows the 'left' pointer moving to the right, encompassing the 'mid' section, after a comparison. The second example shows the 'right' pointer moving to the left, after a different comparison.  A gray box at the bottom contains pseudocode:  `if nums[mid] < target < nums[right] → left = mid + 1`  and `else → right = mid - 1`. This code describes the conditional logic determining the pointer movement based on the target value's relationship to the values at the 'mid' and 'right' indices.  Curved arrows in orange and gray connect the array representations to show the pointer movements ('left' in orange, 'right' in gray) during the algorithm's iterations.  The overall diagram demonstrates how the algorithm iteratively narrows the search space by adjusting the 'left' and 'right' pointers until the target is found or the search space is exhausted.

It’s possible to encounter a situation where both subarrays are sorted. In this case, it doesn’t matter which subarray we use to check where the target is.

One final thing to note is that the array might not contain the target value at all. In this case, once the binary search terminates and narrows down to a single value, we need to check if this value is the target. If not, it indicates the target is not present in the array, and we return -1.

Implementation

python
from typing import List
    
def find_the_target_in_a_rotated_sorted_array(nums: List[int], target: int) -> int:
    left, right = 0, len(nums) - 1
    while left < right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid
        # If the left subarray [left : mid] is sorted, check if the target falls in
        # this range. If it does, search the left subarray. Otherwise, search the
        # right.
        elif nums[left] <= nums[mid]:
            if nums[left] <= target < nums[mid]:
                right = mid - 1
            else:
                left = mid + 1
        # If the right subarray [mid : right] is sorted, check if the target falls
        # in this range. If it does, search the right subarray. Otherwise, search the
        # left.
        else:
            if nums[mid] < target <= nums[right]:
                left = mid + 1
            else:
                right = mid - 1
    # If the target is found in the array, return it’s index. Otherwise, return -1.
    return left if nums and nums[left] == target else -1

Complexity Analysis

Time complexity: The time complexity of find_the_target_in_a_rotated_sorted_array is O(log⁡(n))O(\log(n))O(log(n)) because we're performing a binary search over an array of length nnn.

Space complexity: The space complexity is O(1)O(1)O(1).

Find the Median From Two Sorted Arrays

Find the Median From Two Sorted Arrays

Given two sorted integer arrays, find their median value as if they were merged into a single sorted sequence.

Example 1:

python
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:

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

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 O((m+n)log⁡(m+n))O((m+n)\log(m+n))O((m+n)log(m+n)) time where mmm and nnn denote the lengths of each array, respectively. This complexity is primarily due to the cost of sorting the merged array of length m+nm+nm+n. This approach can be improved to O(m+n)O(m+n)O(m+n) time by merging both arrays in order, which is possible because both arrays are already sorted. However, is there a way to find the median without merging the two arrays?

In this explanation, we use "total length" to refer to the combined length of both input arrays. Let's discuss odd and even total lengths separately, as these result in two different types of medians.

Consider the following two arrays that have an even total length:

Image represents two Python list assignments. The first line assigns a list named `nums1` containing the integer elements 0, 2, 5, 6, and 8.  The second line assigns a list named `nums2` containing the integer elements 1, 3, and 7.  Both lists are presented using standard Python list notation, with square brackets `[]` enclosing the elements, and commas separating them. There is no explicit connection or data flow shown between the two lists; they are simply defined independently of each other.

Below is what these two arrays would look like when merged. Let's see if we can draw any insights from this.

Image represents a one-dimensional array labeled 'merged array:'.  The array is enclosed in square brackets `[]` and contains the following integer elements, listed sequentially: 0, 1, 2, 3, 5, 6, 7, 8.  There are no other visible components, connections, or information flows depicted in the image beyond the array itself and its descriptive label.  The elements are presented without any apparent structure or organization beyond their numerical order within the array.

Observe that the merged array can be divided into two halves, which reveals the median values on the inner edge of each half.

Image represents a visual depiction of splitting an array into two halves.  A numerical array, represented by elements [0, 1, 2, 3, 5, 6, 7, 8] enclosed in square brackets, is shown.  A dashed-line box visually separates the array into a 'left half' (elements 0, 1, 2, 3) and a 'right half' (elements 5, 6, 7, 8).  The element '4' is seemingly absent.  The text 'left half' in cyan is positioned below the left half of the array, while 'right half' in orange is below the right half.  Upward-pointing arrows connect the end of the left half (element 3) with the text 'end of left half' in cyan above it, and the start of the right half (element 5) with the text 'start of right half' in orange above it.  This illustrates the division point and the labeling of the two resulting sub-arrays.

A challenge here is identifying which values in either input array belongs 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: half of the total length.

Slicing both arrays To figure out which values belong to each half, we can try "slicing" both arrays into two segments, where the left segments of both arrays and the right segments of both arrays each have 4 total values. Let's refer to the values on the left and right of the slice as the "left partition" and "right partition." Below are three examples of what this slice could look like:

Image represents a visual depiction of a partitioning algorithm, likely quicksort, showing the process of dividing an array into smaller sub-arrays.  Three stages are shown, each displaying an array represented as two horizontal rows of numbers enclosed in square brackets `[]`. The top row contains elements `[0, 2, 5, 6, 8]` in all three stages, while the bottom row contains `[1, 3, 7]` which remains constant.  The arrays are partitioned using two colored lines: light blue for the 'left partition' and light orange for the 'right partition'.  Triangles (pointing upwards) indicate pivot points within each partition.  Numbers above the lines indicate the lengths of the partitions. A curved arrow labeled 'slice' shows the movement of the partition boundaries, illustrating how the algorithm iteratively divides the array.  The legend clarifies that the arrow indicates data movement from the right partition to the left partition.  The initial array is implicitly divided into two partitions based on the pivot.  Subsequent stages show the refinement of these partitions, with the final stage suggesting a nearly sorted array.

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:

Image represents a visual depiction of array splitting or partitioning.  The top section shows two 2D arrays, one light blue containing the elements [0, 2] in the first row and [1, 3] in the second, and the other peach-colored containing [5, 6, 8] in the first row and [7] in the second.  A vertical line separates these arrays.  Light blue horizontal lines with small triangles at their ends indicate the lengths of the rows in the light blue array (2 elements in the top row and 2 in the bottom). Similarly, orange lines with triangles show the lengths of the rows in the peach array (3 elements in the top row and 1 in the bottom). The numbers '2' and '3' above the arrays indicate the lengths of the respective rows. The bottom section displays the same elements arranged as two 1D arrays: a light blue array [0, 1, 2, 3] and a peach array [5, 6, 7, 8], separated by a vertical line, representing the concatenated elements from the original 2D arrays.  The diagram illustrates how a larger array might be conceptually divided into smaller sub-arrays, potentially for parallel processing or other algorithmic purposes.

Let's refer to this as the "correct slice." We'll explain how to identify the correct slice shortly, but first, let's consider how to identify which slice correctly corresponds to the halves of the merged array.

Determining the correct slice

An important observation is that all values in the left partition must be less than or equal to the values in the right partition.

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.

Image represents a diagram illustrating a coding pattern, likely related to array or list comparisons.  The diagram shows two rectangular regions, one light blue and one peach, representing potentially two different arrays or lists.  Each region contains several black dots representing elements, and two circled elements labeled L1, L2 (light blue) and R1, R2 (peach).  L1 and L2 are positioned at the right edge of their respective regions, while R1 and R2 are positioned at the left edge of their respective regions. A horizontal line connects the right edge of the light blue region to the left edge of the peach region. The text below the diagram specifies a condition to be checked:  `(L1 ≤ R1, L1 ≤ R2)` and `(L2 ≤ R1, L2 ≤ R2)`. This indicates a comparison where the values of L1 and L2 (from the light blue region) are checked to see if they are less than or equal to the values of R1 and R2 (from the peach region). The brackets `[` and `]` around the elements suggest that the regions represent ordered sequences, possibly arrays or lists.

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 from the previous three example slices:

Image represents three examples illustrating a coding pattern, possibly related to array partitioning or range checks. Each example shows a numerical array enclosed in square brackets, partitioned into sub-arrays.  The first array in each example is split into two sub-arrays, with the partitioning point indicated by a vertical line.  Numbers within the array are represented by circles; light blue circles represent the lower bounds of sub-arrays, and peach circles represent upper bounds. Below each example is a small box containing two inequality checks.  The first example shows the array `[1, 3, 5, 6, 8]` partitioned at `5`, with checks `5 ≤ 3` (false, marked with 'x') and `1 ≤ 6` (true, marked with '✓'). The second example shows `[1, 3, 2, 5, 7]` partitioned at `2`, with checks `2 ≤ 7` (true, '✓') and `3 ≤ 5` (true, '✓'). The third example shows `[-∞, 1, 3, 6, 8]` partitioned at `6`, with checks `6 ≤ 1` (false, 'x') and `-∞ ≤ 8` (true, '✓').  The pattern appears to involve checking inequalities based on the partitioning point and elements within the sub-arrays.

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.

Searching for the correct slice Now, our goal is to search through all possible slices until we find the correct one. We do this by searching through all possible placements of L1, R1, L2, and R2. Note that we only need to search for L1 since the other three values can be inferred based on L1's index.

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.

Image represents two diagrams illustrating a coding pattern, likely related to array partitioning.  Each diagram shows a horizontal array represented by square brackets containing dots (representing array elements) and labeled circular nodes. The left diagram shows a light-blue node labeled 'L1' connected by a downward arrow from a rectangular box labeled 'L1_index'.  'L1' is connected horizontally to a light-blue node labeled 'L2' via a line labeled 'R2'.  'L2' is connected by an upward arrow to a rectangular box labeled 'L2_index'. The right diagram mirrors this structure but with 'L1' and 'R1' (peach-colored) nodes in gray, indicating a different state.  A curved arrow connects 'L2_index' to 'R2', suggesting an iterative process. Below the diagrams, formulas describe the calculation of the number of left partition valves in two arrays, 'nums1' and 'nums2', based on 'L1_index' and 'half_total_len'.  The final formula defines 'L2_index' as a function of 'half_total_len' and 'L1_index'.  The diagrams visually represent the relationship between indices and array partitioning, while the formulas provide the mathematical logic behind the index calculations.

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 because 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 because we expect R1 to be less than or equal to L2. To search for a larger R1, narrow the search space toward the right:
  • If L1 ≤ R2, and L2 ≤ R1, the correct slice has been located:

Search space optimization A small optimization here is to ensure that nums1 is the smallest array between the two input arrays. This ensures our search space is as small as possible. If nums2 is smaller than nums1, we can just swap the two arrays, allowing nums1 to always be the smaller array.

Returning the median Once binary search has identified the correct slice, we need to return the median. With an even total length, the median is calculated using the array's two middle values. From our set of partition slice values (L1, R1, L2, and R2), which of them are the middle two? We know one of the median values is from the left partition and the other is from the right partition. From the left partition, the largest value between L1 and L2 will be closest to the middle. From the right, the smallest value between R1 and R2 is closer to the middle:

Image represents a visual explanation of a merge sort algorithm step.  Two arrays, `[0 2 5 6 8]` and `[0 3 7]`, are shown, with their elements visually separated by a vertical line.  Light blue circles highlight the numbers 2 and 3 in the respective arrays, indicating these are the largest element in the left array and the smallest element in the right array, respectively.  A curved arrow points from the 3 to indicate that it's the smallest element on the right, and another curved arrow points to the 3 from the left to indicate that it's the largest element on the left.  An orange circle highlights the number 5 in the right array, indicating it's the smallest element in that array.  Below, a merged array `[0 1 2 3 5 6 7 8]` is displayed, showing the result of merging the two input arrays in sorted order.  The numbers 1 and 2 are implicitly understood to be part of the merged array, as they are the next smallest elements after 0.  The diagram illustrates the process of selecting the smallest element from the right array and the largest element from the left array during the merging phase of the merge sort.

So, to return the median, we just return the sum of these two values, divided by 2 using floating-point division.

What if the total length of both arrays is odd? The main difference when the total length of both arrays is odd compared to an even length is that we can no longer slice the arrays into two equal halves. One half must have an additional value.

Image represents a demonstration of merging two sorted arrays.  The top line shows an array named `nums1` containing the integer elements [0, 2, 5, 6, 8]. The second line displays another array, `nums2`, with elements [1, 3, 7, 9].  Below these, labeled 'merged array:', is the result of merging `nums1` and `nums2` into a single sorted array: [0, 1, 2, 3, 5, 6, 7, 8, 9]. The elements from `nums1` are shown in black, while elements from `nums2` are displayed in light blue and orange, illustrating their origin within the merged array.  There are no URLs or parameters present; the image solely depicts the data structures and the outcome of the merging operation.

The diagram above shows that the right half ends up with one extra value. This is because when we calculate the slice position, we ensure the left half has a size of half the total length. In this example, this calculation using integer division gives us a left half size of (5 + 4) // 2 = 4. Consequently, this means the right half ends up with 5 values. When the total length is odd, the median can be found in the right half:

Image represents a partially merged array visualized during a merge sort algorithm.  The text 'merged array:' labels a numerical array represented within square brackets `[]`. The array contains elements colored in two different colors:  `0`, `1`, `2`, and `3` are displayed in cyan, while `5`, `6`, `7`, `8`, and `9` are in orange.  A vertical bar `|` and brackets `[]` highlight the number `5` within the orange section. An orange curved arrow points from the text 'start of right half' to the highlighted `5`, indicating that this element represents the beginning of the right half of an array that is being merged with a left half (represented by the cyan numbers). The overall image illustrates a step in the merge sort process where the algorithm is combining sorted sub-arrays.

So, after the binary search narrows down the correct slice, we can just return the smallest value between R1 and R2.

Image represents a visual illustration of a sorting algorithm, specifically highlighting a step where the smallest element is placed at the rightmost position.  Two arrays, represented as rows, are shown. The first array contains the numbers 0, 2, 5, 6, and 8, enclosed in square brackets.  The second array contains 0, 3, and 7, also enclosed in square brackets.  Numbers 2 and 3 are circled in light blue, while 5 and 7 are circled in a peach color. A vertical line separates the light blue circled numbers from the peach circled numbers and the remaining uncircled numbers. An orange arrow points from the text 'smallest on the right' to the peach-colored circle containing the number 5, indicating that 5 is the smallest element among the numbers to the right of the vertical line in the first array.  The arrangement visually demonstrates a partitioning step in a sorting algorithm, where smaller elements are moved to one side.

Implementation

python
from typing import List
   
def find_the_median_from_two_sorted_arrays(nums1: List[int], nums2: List[int]) -> float:
    # Optimization: ensure 'nums1' is the smaller array.
    if len(nums2) < len(nums1):
        nums1, nums2 = nums2, nums1
    m, n = len(nums1), len(nums2)
    half_total_len = (m + n) // 2
    left, right = 0, m - 1
    # A median always exists in a non-empty array, so continue binary search until
    # it's found.
    while True:
        L1_index = (left + right) // 2
        L2_index = half_total_len - (L1_index + 1) - 1
        # Set to -infinity or +infinity if out of bounds.
        L1 = float('-inf') if L1_index < 0 else nums1[L1_index]
        R1 = float('inf') if L1_index >= m - 1 else nums1[L1_index + 1]
        L2 = float('-inf') if L2_index < 0 else nums2[L2_index]
        R2 = float('inf') if L2_index >= n - 1 else nums2[L2_index + 1]
        # If 'L1 > R2', then 'L1' is too far to the right. Narrow the search space
        # toward the left.
        if L1 > R2:
            right = L1_index - 1
        # If 'L2 > R1', then 'L1' is too far to the left. Narrow the search space
        # toward the right.
        elif L2 > R1:
            left = L1_index + 1
        # If both 'L1' and 'L2' are less than or equal to both 'R1' and 'R2', we
        # found the correct slice.
        else:
            if (m + n) % 2 == 0:
                return (max(L1, L2) + min(R1, R2)) / 2.0
            else:
                return min(R1, R2)

Complexity Analysis

Time complexity: The time complexity of find_the_median_from_two_sorted_arrays is O(log⁡(min(m,n)))O(\log(min(m,n)))O(log(min(m,n))) because we perform binary search over the smaller of the two input arrays.

Space complexity: The space complexity is O(1)O(1)O(1).

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.

Determine if a target value exists in a matrix. Each row of the matrix is sorted in non-decreasing order, and the first value of each row is greater than or equal to the last value of the previous row.

Example:

Image represents a 3x4 matrix of numerical data, with a target value declared as 'target = 21' above the matrix.  The matrix is indexed by row and column numbers, starting from 0.  Each cell within the matrix contains a single integer value.  The integers are arranged seemingly randomly, ranging from 2 to 33.  One specific cell, located at row index 2 and column index 1, contains the value 21, which is highlighted with a light green background, visually indicating it matches the declared target value.  No other connections or information flow beyond the simple presentation of the numerical data and the target value are depicted.
python
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:

Image represents a data transformation process visualized using a matrix and arrows.  A 3x4 matrix is shown, with rows labeled 0, 1, and 2, and columns labeled 0, 1, 2, and 3.  Each cell in the matrix contains a numerical value (2, 3, 4, 6, 7, 10, 11, 17, 20, 21, 24, 33).  Light orange arrows indicate data flow.  Two arrows originate from the top-left corner of the matrix, one from row 0 and one from row 1, pointing downwards to a horizontal array below the matrix. This array displays the values from the first two rows of the matrix, concatenated horizontally ([2, 3, 4, 6, 7, 10, 11, 17]). A third arrow originates from the right side of the matrix, pointing downwards to another horizontal array, which displays the values from the last row of the matrix, concatenated horizontally ([20, 21, 24, 33]).  The final output is a single horizontal array containing all the values from the matrix, arranged sequentially.

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 O(m⋅n)O(m\cdot n)O(m⋅n) time, and also takes O(m⋅n)O(m\cdot n)O(m⋅n) space, where mmm and nnn are the dimensions of the matrix. Is there a way to perform a binary search on the matrix without flattening it?

Let’s map the indexes of the flattened array to their corresponding cells in the matrix:

Image represents a 3x4 matrix where each cell contains two numbers.  The top row and leftmost column are labeled with indices 0, 1, 2, and 3.  Each cell displays a larger number in black and a smaller number in peach within a circle. The larger number appears to be the result of some calculation involving the row and column indices. For example, the cell at row 0, column 0 contains 2 (larger number) and 0 (smaller number in a circle).  Below the matrix, a peach-colored horizontal bar displays the larger numbers from each cell, arranged sequentially from left to right, starting with 2 and ending with 33.  Beneath this bar, a second horizontal bar displays the smaller numbers from each cell, also arranged sequentially from left to right, starting with 0 and ending with 11.  The arrangement suggests a mapping or transformation between the row and column indices and the resulting larger and smaller numbers within each cell.

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.

Image represents a visual depiction of a coding pattern, likely related to a 2D array or matrix manipulation.  A horizontal number line labeled 'n = 4' shows indices 0, 1, 2, and 3. Below, a 3x4 matrix is displayed, with rows labeled 'r = 0', 'r = 1', and 'r = 2', and columns implicitly corresponding to the number line's indices.  The matrix cells contain numerical values; specifically, the first column shows values 0, n (representing the value of n, which is 4), and 2n (representing 2 times the value of n, which is 8).  Other cells contain seemingly calculated values, possibly based on a specific algorithm.  A downward arrow points from the cell containing '2n' to the label 'r . n', suggesting that the matrix's values are somehow related to or result in a calculation involving the row index 'r' and the value 'n'.  The highlighted cells (containing '0', 'n', and '2n') emphasize a pattern or relationship within the matrix's first column.

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 understand how the 2D matrix maps to the 1D flattened array, let’s work backward to obtain the row and column indexes from an index in the flattened array. Let i = r⋅n + c. The row and column values are:

  • r = i // n
  • c = i % n

We can see how these are obtained below:

Image represents a comparison of two methods for calculating the quotient (r) and remainder (c) when integer 'i' is divided by integer 'n'.  The left side demonstrates a sequential approach:  First, it establishes the relationship `i = rn + c`, then derives `i - c = rn`, followed by `(i - c) // n = r` (integer division to find the quotient), and finally `i // n - c // n = r` (showing an alternative calculation for 'r').  The last line, `i // n = r (c < n → c // n = 0)`, clarifies that `i // n` directly equals 'r' only if 'c' is less than 'n', otherwise, the subtraction of `c // n` is necessary. The right side presents an alternative, more concise method using the modulo operator (`%`). It starts with the same initial equation, `i = rn + c`, then directly calculates the remainder using `i % n = (rn + c) % n`. This simplifies to `i % n = rn % n + c % n`, and finally, if 'c' is less than 'n', it reduces to `i % n = c`.  The two sides illustrate different mathematical approaches to achieve the same result, highlighting the efficiency of the modulo operator for remainder calculation.

Now that we have these formulas, let’s use binary search to find the target.

Binary search To define the search space, we need 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:

Image represents a visual explanation of a search algorithm within a 4x4 matrix.  The top shows 'target = 21,' indicating the value being searched for. A 4x4 matrix is displayed below, with each cell containing a number and its row and column index shown as subscripts.  The matrix is labeled with 'left', 'mid', and 'right' indicating index pointers.  The 'mid' pointer highlights the value 10 at row 1, column 1. To the right, calculations are shown: 'r = mid // n' (integer division of 'mid' by 4, resulting in 1) and 'c = mid % n' (modulo operation of 'mid' by 4, resulting in 1). This determines the row (r=1) and column (c=1) of the element to check. The condition 'matrix[r][c] < target' (10 < 21) is shown, indicating that the element at row 1, column 1 is less than the target.  Finally, the consequence 'left = mid + 1' is shown, suggesting the algorithm updates the 'left' pointer based on the comparison result.  The highlighted cell (21) shows the target value found in the matrix.
Image represents a 4x4 grid, visually resembling a matrix or table, with rows and columns indexed from 0 to 3.  Each cell contains a number and a smaller subscript indicating an associated value.  A dashed orange line connects a highlighted orange circle in the top-left cell (containing the number 2 and subscript 0) to a cell in the second row and third column. This cell contains the number 11 and subscript 6, and is labeled 'left' with an orange rectangle.  Another orange rectangle labeled 'right' is present in the bottom-right cell (containing the number 33 and subscript 11). A light green circle highlights the number 21 (subscript 9) in the bottom-left cell.  A smaller, light gray rectangle labeled 'mid' is located above and slightly to the left of the 'left' labeled cell, suggesting a possible midpoint or intermediary step in the process indicated by the dashed line. The numbers in the subscripts appear to represent additional data associated with each main number in the grid.

The new midpoint value is still less than the target, so let’s narrow the search space towards the right:

Image represents a visual explanation of a search algorithm within a 4x4 matrix.  The top shows 'target = 21,' indicating the value being searched for. A 4x4 matrix is displayed below, with rows labeled 0, 1, 2 and columns labeled 0, 1, 2, 3. Each cell contains a number, and the cell containing 20 is labeled 'mid' (in cyan), the cell containing 11 is labeled 'left' (in orange), and the cell containing 33 is labeled 'right' (in orange).  To the right, a separate box details the calculation process: 'r = mid // n' (integer division of mid by n, where mid is 8 and n is 4, resulting in r=2) and 'c = mid % n' (modulo operation of mid by n, resulting in c=0).  The line 'matrix[r][c] < target' indicates a comparison between the value at matrix position [2][0] (which is 20) and the target value (21). The result of this comparison (20 < 21) leads to the assignment 'left = mid + 1,' implying an adjustment of the search range based on the comparison.  The overall diagram illustrates a step in a binary search or similar algorithm applied to a 2D array.
Image represents a 3x4 matrix, visually resembling a table or grid, with numerical data organized into cells.  The rows are labeled 0, 1, and 2, and the columns are labeled 0, 1, 2, and 3. Each cell contains two numbers; a larger, prominent number and a smaller, less prominent number below it.  For example, the cell at row 1, column 1 contains '10' and '5' below it.  The cells at row 2, column 1 and row 2, column 3 are highlighted in orange with the labels 'left' and 'right' respectively.  A dashed orange line with a checkmark at its end originates from the cell containing '10' and '5' and points to the cell containing '21' and '9', indicating a possible connection or flow of information between these two cells.  An orange dot is present in the cell at row 1, column 2, containing '11' and '6'.  The numbers in the cells appear to be related, possibly representing some kind of data structure or algorithm, with the smaller numbers potentially representing additional information or indices.

The midpoint value is now larger than the target, which means the target is to the left of the midpoint. So, let’s move the search space to the left:

Image represents a visual explanation of a search algorithm within a 4x4 matrix.  The top shows 'target = 21,' indicating the value being searched for. A 4x4 matrix is displayed below, with each cell containing a number and its row and column indices subtly indicated.  The matrix's rows are indexed 0, 1, and 2, and columns are indexed 0, 1, 2, and 3.  The number 21 is highlighted within the matrix, labeled 'left,' indicating its position in the search.  To its right is the number 24, labeled 'mid,' and to its further right is 33, labeled 'right.' These labels suggest a binary search-like approach. A separate box to the right details the calculation process:  'r = mid // n' and 'c = mid % n' calculate the row (r) and column (c) indices of the 'mid' element (24 in this case), where 'mid' is 10 (its index in a flattened array), 'n' is 4 (the number of columns). The calculations show r = 2 and c = 2.  Finally, 'matrix[r][c] > target → right = mid - 1' indicates that if the value at matrix[2][2] (which is 24) is greater than the target (21), the right boundary of the search is updated to 'mid - 1', effectively narrowing the search space.
Image represents a 3x4 matrix, visually resembling a table, with rows and columns indexed from 0 to 2 and 0 to 3 respectively.  Each cell contains a number, with smaller numbers beneath indicating a secondary value.  A dashed orange line with an arrowhead connects cell (1,2) containing '11' to cell (1,3) containing '17', labeled 'mid' near the arrowhead.  Two rectangular orange boxes, labeled 'left' and 'right', are positioned near cell (2,1) containing '21', which is highlighted in a green circle.  The numbers within the matrix appear to be related, possibly representing data or results from a computation, with the orange labels and dashed line suggesting a process or algorithm involving a 'left', 'right', and 'mid' component, potentially indicating a partitioning or traversal strategy within the data structure.

Now, the midpoint is equal to the target, so we return true to conclude the search.

Image represents a visual explanation of a search algorithm within a matrix.  A 3x4 matrix is shown, with each cell containing a number and its index (row, column) subtly displayed below.  The top row displays column indices (0, 1, 2, 3), and the leftmost column displays row indices (0, 1, 2). The number 21 is highlighted within the matrix at index (2,1), indicating a 'mid' value.  To the right, a separate box details the calculation: `r = mid // n` (integer division of `mid` (9) by `n` (4), resulting in `r = 2`) and `c = mid % n` (modulo operation of `mid` (9) by `n` (4), resulting in `c = 1`).  This calculation determines the row (`r`) and column (`c`) indices of the element being checked. Finally, the condition `matrix[r][c] == target` is shown, indicating that if the element at the calculated index (`matrix[2][1]`) equals the `target` value (21), the function returns `True`.  The overall diagram illustrates how a search algorithm might locate a target value within a matrix using integer division and modulo operations to determine the index.

Note that our exit condition should be while left ≤ right in order to also examine the above search space when left == right.

Implementation

python
from typing import List
    
def matrix_search(matrix: List[List[int]], target: int) -> bool:
    m, n = len(matrix), len(matrix[0])
    left, right = 0, m * n - 1
    # Perform binary search to find the target.
    while left <= right:
        mid = (left + right) // 2
        r, c = mid // n, mid % n
        if matrix[r][c] == target:
            return True
        elif matrix[r][c] > target:
            right = mid - 1
        else:
            left = mid + 1
    return False

Complexity Analysis

Time complexity: The time complexity of matrix_search is O(log⁡(m⋅n))O(\log(m\cdot n))O(log(m⋅n)) because it performs a binary search over a search space of size m⋅nm\cdot nm⋅n.

Space complexity: The space complexity is O(1)O(1)O(1).

Local Maxima in Array

Local Maxima in Array

A local maxima is a value greater than both its immediate neighbors. Return any local maxima in an array. You may assume that an element is always considered to be strictly greater than a neighbor that is outside the array.

Example:

Image represents a line graph illustrating the concept of local maxima.  The horizontal axis is labeled 'index' and ranges from 0 to 4. The vertical axis represents a numerical value, ranging from 0 to 4. A black line connects several data points: (0, 1), (1, 4), (2, 3), (3, 2), and (4, 3).  The points (1, 4) and (4, 3) are highlighted with larger, peach-colored circles and labeled 'local maxima' in orange text, indicating that these points represent peaks within their immediate neighboring data points. The graph visually demonstrates how local maxima are identified within a dataset by showing points that are higher than their adjacent points on the line.
python
Input: nums = [1, 4, 3, 2, 3]
Output: 1 # index 4 is also acceptable

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:

Image represents a pair of line graphs illustrating the concept of 'local maxima' in a numerical sequence.  Each graph displays a sequence of numbers ('nums') plotted against their index. The left graph shows a decreasing line, starting at (0, 4) and ending at (4, 0), with intermediate points at (1, 3), (2, 2), (3, 1).  The right graph shows an increasing line, starting at (0, 0) and ending at (4, 4), with intermediate points at (1, 1), (2, 2), (3, 3). In both graphs, the point (0,4) on the left graph and (4,4) on the right graph are highlighted with a larger, light-orange circle and labeled 'local maxima' in orange text, indicating that these points represent the highest values within their immediate neighborhood in the respective sequences.  Both graphs share the same axes labels: 'nums' for the y-axis representing the numerical values and 'index' for the x-axis representing the position of each number in the sequence.  The graphs visually demonstrate how a local maximum is identified within a data set by comparing a point to its immediate neighbors.
Image represents two line graphs juxtaposed side-by-side, both sharing the same axes labels: 'nums' on the vertical axis (ranging from 0 to 4) and 'index' on the horizontal axis (ranging from 0 to 4).  Each graph depicts a line connecting several data points. The left graph shows a line that increases from (0,1) to a peak at (2,3), labeled 'local maxima' in orange text, and then decreases to (4,1). The right graph shows a line that decreases from (0,3), labeled 'local maxima' in orange text, to a minimum at (2,1), and then increases to (4,3), also labeled 'local maxima' in orange text.  Both graphs use black lines to connect the data points, with the local maxima points highlighted with larger, peach-colored circles.  The graphs illustrate the concept of local maxima within a dataset, showing how a point can be a local maximum even if it's not the absolute maximum value across the entire dataset.

Now, let's say we're at some random index in the array, index i. An interesting observation is that if the next number (at index i + 1) is greater than the current, there’s definitely a local maxima somewhere to the right of i. This is because the two points at index i and i + 1 form an ascending slope, and this slope would be heading upwards towards some maxima:

Image represents a diagram illustrating a condition within a coding pattern, likely related to finding local maxima in an array.  Two orange circles represent array indices 'i' and 'i+1', with downward-pointing orange arrows indicating access to the corresponding array elements `nums[i]` and `nums[i+1]`. A thick black line connects the circle representing 'i' to a black dot representing an intermediate state. A dashed line extends from this dot, suggesting a continuation.  An orange arrow points from 'i+1' to this black dot. The text `nums[i] < nums[i+1]` indicates a comparison between the values at these indices.  A rightward arrow follows this comparison, leading to the text 'local maxima somewhere to the right of i,' implying that if the condition `nums[i] < nums[i+1]` is true, a local maximum is expected to exist at some index greater than 'i'. The overall diagram visually represents the logic flow of checking for an increasing trend in the array to potentially identify a local maximum.

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:

Image represents a diagram illustrating a condition for identifying local maxima within a numerical sequence represented by the array `nums`.  The diagram features two nodes, one colored orange and the other black, connected by a solid black arrow pointing from the orange node to the black node.  Above the orange node, a downward-pointing orange arrow labeled 'i' indicates an index `i` in the `nums` array. Similarly, a downward-pointing orange arrow labeled 'i+1' points to the black node, representing the next index `i+1`. A dashed line extends from a point slightly to the left and above the orange node, suggesting a continuation of the sequence before index `i`.  The core logic is expressed as `nums[i] > nums[i+1]`, indicating that if the value at index `i` is greater than the value at index `i+1`, then either a local maximum exists somewhere to the left of index `i`, or the value at `nums[i]` itself is a local maximum.  The text to the right of the inequality explains these two possible outcomes.

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.

Binary search First, let’s define the search space. A local maxima could exist at any index of the array. So, the search space should encompass the entire array.

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:

Image represents a line graph illustrating a numerical array.  The horizontal axis is labeled 'index' and ranges from 0 to 6. The vertical axis is labeled 'nums' and ranges from 0 to 4.  A black line connects data points representing the values of an array [0, 1, 4, 2, 1, 2, 3], where each number corresponds to its index.  The array is displayed above the graph. Two orange vertical lines highlight the leftmost (index 0, value 0) and rightmost (index 6, value 3) elements of the array.  Above these lines, orange rectangular boxes labeled 'left' and 'right' respectively, indicate the boundaries. The area under the line graph is filled with a light peach color.

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:

Image represents two line graphs illustrating a coding pattern, likely a binary search algorithm.  Both graphs display the same data points: `[0, 1, 4, 2, 1, 2, 3]` plotted against an index from 0 to 6.  The y-axis represents the 'nums' values.  In both graphs, vertical orange lines mark 'left' and 'right' boundaries, and a light-blue arrow indicates 'mid'.  The left graph shows the initial state with 'left' at index 0, 'mid' at index 3, and 'right' at index 6.  A shaded peach area highlights the section between 'left' and 'right'.  The right graph depicts an iteration where a descending slope is detected (nums[mid] > nums[mid + 1]), resulting in 'right' being updated to the 'mid' index (index 3).  A dashed orange line connects the old and new 'right' positions. A grey box in the left graph explains the condition for updating 'right' in the algorithm: `descending slope: nums[mid] > nums[mid + 1] → right = mid`.  A small arrow points from this box to the right graph, indicating the application of this rule.

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:

Image represents a visual explanation of a coding pattern, likely within a sorting or searching algorithm.  The image is divided into two nearly identical parts, each showing a line graph with a series of points connected by lines. The x-axis represents an index (0-6), and the y-axis represents numerical values ('nums').  In both parts, orange vertical lines highlight sections of the graph.  Above each graph, rectangular boxes labeled 'left,' 'mid,' and 'right' indicate index values (e.g., [0 1 4 2 1 2 3] in the first part) that are associated with the graph's points.  Arrows point from these boxes down to their corresponding points on the graph.  A dashed gray line connects the 'left' and 'right' indices in the second part, suggesting a range.  A light peach-colored rectangle highlights a section of the graph between the 'left' and 'right' markers. A separate box explains the condition 'ascending slope: nums[mid] < nums[mid + 1] → left = mid + 1,' indicating that if the value at the midpoint is less than the value at the next point, the 'left' index is updated to the midpoint plus one. This suggests an iterative process of narrowing down a search range based on the slope of the line graph.  The two parts likely illustrate different stages of this iterative process.

The next midpoint is set at index 2, which forms a descending slope with its right neighbor. So, we continue by searching to the left, while including the midpoint:

Image represents a visual explanation of a coding pattern, likely within a binary search algorithm.  The image is divided into two nearly identical halves, each showing a line graph plotting a numerical array `nums` against its index.  The x-axis represents the index (0 to 6), and the y-axis represents the values within the `nums` array.  Both graphs depict the same data, a series of points connected by lines forming a V-shape.  At the top of each half, labeled boxes indicate pointers `left`, `mid`, and `right`, initially pointing to indices 0, 2, and 3 respectively in the left half and 0, 1, and 3 in the right half.  These pointers define a search window within the array.  A shaded orange vertical bar highlights the `mid` index in the left graph.  A gray box in the left half explains a condition: 'descending slope: nums[mid] > nums[mid + 1] → right = mid,' indicating that if the value at `mid` is greater than the value at `mid + 1`, the `right` pointer is updated to `mid`, effectively narrowing the search space.  The right half likely shows the result of applying this condition, with the `right` pointer moved to index 1.  The overall diagram illustrates how the algorithm iteratively refines the search range based on the slope of the data.

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:

Image represents a visual explanation of a step within a binary search algorithm, specifically illustrating a condition where the middle element is greater than the element to its right.  The image is divided into two parts separated by a large right-pointing arrow.  The left part shows an array `[0, 4, 3, 1, 0]` represented graphically as a line graph with the x-axis representing the index (0 to 4) and the y-axis representing the array's values (0 to 4).  Vertical orange lines mark the `left` (index 0), `mid` (index 2), and `right` (index 4) pointers, with corresponding labels above the lines.  A shaded peach area highlights the section of the array between `left` and `right`.  A black line connects the data points, showing the array's values.  The right part shows the updated state after the condition `if nums[mid] > nums[mid + 1]: right = mid;` is executed.  The `right` pointer has moved from index 4 to index 2, indicated by a dashed orange line connecting the old and new positions. The graph is updated to reflect this change, with the new `right` pointer at index 2.  Above both parts, a code snippet displays the condition `if nums[mid] > nums[mid + 1]: right = mid;`, which is the decision-making logic behind the pointer movement.  The overall image demonstrates how the algorithm adjusts its search space based on the comparison of the middle element with its neighbor to the right.

Case 2: The midpoint forms an ascending slope with its right neighbor, indicating a local maxima exists to the right. Narrow the search space toward the right while excluding the midpoint:

Image represents a visual explanation of a coding pattern, likely within a sorting algorithm.  The top shows a code snippet: `if nums[mid] < nums[mid + 1]: left = mid + 1;`, indicating a conditional statement comparing two elements of an array `nums` at indices `mid` and `mid + 1`.  Below, a line graph displays an array `nums` = [0, 4, 3, 1, 0] with indices 0-4 on the x-axis and values on the y-axis.  Orange vertical lines mark `left` (index 0), `mid` (index 2), and `right` (index 4), initially.  A shaded peach region highlights the section between `left` and `right`. A black line connects the data points. A grey dot marks the `mid` point's value.  A thick black arrow separates this from a second graph showing the array after the conditional statement's execution. In the second graph, `left` has shifted to index 3, indicated by a new orange line, while `right` remains at index 4. The peach shaded region now reflects the updated `left` and `right` boundaries.  The dashed orange line connects the old and new `left` positions, visually demonstrating the shift.  The second graph's line plot remains the same, but the `left` pointer's position has changed, illustrating the algorithm's step in adjusting the search space based on the comparison.

Implementation

python
from typing import List
    
def local_maxima_in_array(nums: List[int]) -> int:
    left, right = 0, len(nums) - 1
    while left < right:
        mid = (left + right) // 2
        if nums[mid] > nums[mid + 1]:
            right = mid
        else:
            left = mid + 1
    return left

Complexity Analysis

Time complexity: The time complexity of local_maxima_in_array is O(log⁡(n))O(\log(n))O(log(n)), where nnn denotes the length of the array. This is because we use binary search to find a local maxima.

Space complexity: The space complexity is O(1)O(1)O(1).

Weighted Random Selection

Weighted Random Selection

Given an array of items, each with a corresponding weight, implement a function that randomly selects an item from the array, where the probability of selecting any item is proportional to its weight.

In other words, the probability of picking the item at index i is: weights[i] / sum(weights).

Return the index of the selected item.

Example:

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

Let’s say we had weights 1 and 4 for indexes 0 and 1, respectively:

Image represents a Python code snippet assigning a list of weights.  The snippet shows the variable `weights` being assigned a list containing two numerical values: 1 and 4.  The assignment is represented using the equals sign (`=`). The list is denoted by square brackets (`[` and `]`).  The numbers 0 and 1 are faintly visible below the numbers 1 and 4 respectively, possibly indicating indices or positions within the list.  There is no other information, such as URLs or parameters, present in the image. The overall structure is a simple variable assignment statement common in programming languages.

Here, index 1 should be selected with a probability of 4/5, significantly higher than index 0’s probability of 1/5:

Image represents a simple illustration of weight normalization in a context likely related to machine learning or weighted averaging.  The top line shows a Python-like variable assignment, `weights = [1 4]`, defining a weight vector with two elements: 1 and 4.  Below, these weights are individually depicted with small downward arrows pointing to their normalized counterparts.  The normalization appears to be a simple division by the sum of the weights (1+4=5).  Therefore, the weight 1 is transformed into 1/5 (displayed in cyan), and the weight 4 is transformed into 4/5 (displayed in orange).  The small numbers '0' and '1' above the arrows likely represent indices indicating the position of each weight within the original vector. The overall diagram visually demonstrates the process of converting a set of weights into their normalized probability distribution.

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:

Image represents a horizontal bar chart or diagram illustrating a concept likely related to data partitioning or resource allocation.  The chart is composed of five rectangular sections arranged contiguously from left to right, each numbered sequentially from 1 to 5 below. The first section is colored light blue, while the remaining four sections are a light peach or beige color.  The sections are all of equal width and enclosed within a single, thick black border. The color difference between the first section and the others suggests a distinction, possibly representing a different category, status, or allocation of a resource. The numbers beneath each section likely represent identifiers or indices for each part, indicating their position or order within the whole.  The overall visual suggests a simple representation of a dataset or resource divided into five parts, with the first part differentiated from the rest.

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:

Image represents a visual depiction of an array or list data structure.  The top section shows a horizontal rectangular block divided into five smaller, equal-width sections. The leftmost section is light blue and labeled 'index 0'. The remaining four sections are peach-colored and labeled 'index 1' (the second section), with the third, fourth, and fifth sections implicitly representing indices 2, 3, and 4 respectively, though not explicitly labeled. Below this top section, a second row displays the numbers 1, 2, 3, 4, and 5, each aligned vertically beneath its corresponding index in the top row. This arrangement visually maps each index to its associated value, illustrating how data is stored and accessed using indices in an array.  The overall structure suggests a simple, one-dimensional array with five elements.

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

Determining which numbers on the number line correspond to which indexes As mentioned before, to know which index to return, we need a way to tell which index our random number line number corresponds to. Consider a larger distribution of weights:

Image represents a visual depiction of weighted indexing.  At the top, a list named 'weights' is defined as an array containing the integers [3, 1, 2, 4]. Below this, a horizontal bar is segmented into four color-coded sections, each labeled 'index 0,' 'index 1,' 'index 2,' and 'index 3,' respectively.  The bar is further divided into numbered units from 1 to 10.  Colored arrows connect the weights to the bar; a cyan arrow from the weight '3' points to the end of the 'index 0' section (at position 3), a green arrow from the weight '1' points to the end of the 'index 1' section (at position 4), an orange arrow from the weight '2' points to the end of the 'index 2' section (at position 6), and a magenta arrow from the weight '4' points to the end of the 'index 3' section (at position 8).  Each arrow's length visually represents the corresponding weight value, indicating how many units each index occupies within the bar.  The numbers above the arrows (0, 1, 2, 3) correspond to the index of the weight in the `weights` array.

One strategy is to use a hash map. In this hash map, each number on the line is a key, and its corresponding index is the value:

Image represents a hash map, visualized as a table with two columns.  The left column, labeled 'number,' lists integers sequentially from 1 to 10. The right column, labeled 'index,' shows the corresponding index or bucket assigned to each number after a hashing operation.  The indices are color-coded for clarity: 0 is light blue, 1 is green, 2 is orange, and 3 is magenta.  Numbers 1, 2, and 3 hash to index 0; number 4 hashes to index 1; numbers 5 and 6 hash to index 2; and numbers 7, 8, 9, and 10 hash to index 3.  The table demonstrates how a hash function maps input numbers (keys) to different indices (values) in the hash map, potentially leading to collisions (multiple numbers mapping to the same index) as seen with indices 0, 2, and 3.

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.

Image represents a diagram illustrating a data structure, possibly an array or list.  The top section shows four color-coded blocks representing elements of the data structure. Each block is labeled with 'index' followed by a number (0, 1, 2, and 3), indicating its position within the structure.  The blocks are light blue, light green, light peach, and light pink respectively. Below this, a sequence of numbers (1 through 10) is displayed.  Numbers 3, 4, 6, and 10 are circled, suggesting a possible selection or highlighting of specific elements or indices.  There's an implied relationship between the top and bottom sections; the numbers below likely represent some form of access or operation on the elements, with the circled numbers potentially indicating specific actions or data points of interest within the data structure.  The overall arrangement suggests a visual representation of data access or manipulation using indices.

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 need to keep just n values, one for each endpoint. When storing these endpoints in an array, the array index of each endpoint is the same as its index value on the number line:

Image represents a visual depiction of an array, specifically a one-dimensional array or list.  The array is enclosed in square brackets `[` and `]`.  The array contains four elements: 3, 4, 6, and 10. These elements are displayed horizontally, separated by spaces. Below each element, in a lighter gray font, is an index indicating its position within the array. The indices start from 0 and increment sequentially: 0 for the element 3, 1 for the element 4, 2 for the element 6, and 3 for the element 10.  The arrangement clearly shows the mapping between each element's value and its corresponding index within the array's structure.

The question now is, how do we find these endpoints?

Obtaining the endpoints of each index’s segment on the line A key observation is that the endpoint of a segment is equal to the length of all previous segments, plus the length of the current segment. We can see how this works below:

Image represents a diagram illustrating a cumulative sum pattern.  The top section shows a horizontally divided rectangle representing an array or list, segmented into four color-coded sections labeled 'index 0,' 'index 1,' 'index 2,' and 'index 3.' Below this, a sequence of numbers 1 through 10 is presented.  Arrows descend from numbers 3, 4, 6, and 10, pointing to expressions representing a cumulative sum.  Specifically, the arrow from 3 points to '3,' the arrow from 4 points to '3+1,' the arrow from 6 points to '3+1+2,' and the arrow from 10 points to '3+1+2+4.'  This visually demonstrates how each subsequent cumulative sum incorporates the preceding values, suggesting a pattern of iteratively adding elements from the sequence (potentially an array) to a running total.  The color-coding of the top section might indicate different segments or partitions within the data structure.

This demonstrates that each endpoint is a cumulative sum, suggesting we can obtain the endpoint of each segment by obtaining the prefix sums of the array of weights:

Image represents a simple illustration of prefix sums.  The top line shows a list labeled 'weights' containing four integer values: 3, 1, 2, and 4, enclosed in square brackets, indicating an array or list data structure.  Below this, another list labeled 'prefix_sums' is shown, also enclosed in square brackets. This list contains the cumulative sums of the 'weights' list.  Specifically, the first element of 'prefix_sums' (3) is the same as the first element of 'weights'. The second element of 'prefix_sums' (4) is the sum of the first two elements of 'weights' (3 + 1). The third element (6) is the sum of the first three elements of 'weights' (3 + 1 + 2), and the final element (10) is the sum of all elements in 'weights' (3 + 1 + 2 + 4).  The arrangement visually demonstrates the direct relationship and calculation between the original 'weights' and their resulting 'prefix_sums'.

As we can see, the prefix sums array stores the endpoint of each segment.

Now, let's see how the prefix sums 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 the 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:

Image represents a visual explanation of finding the closest endpoint to the right of a target value within a data structure.  A horizontal rectangular bar is divided into segments, each representing an element in a sequence.  The segments are color-coded: light blue for the first two elements (labeled 'index 0' and 'index 1'), light green for the next element ('index 2'), and light pink for the remaining elements ('index 3').  Below the bar, numerical values (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) are aligned with the corresponding segments.  The target value, 'target = 5,' is specified above the bar.  The number 5 is highlighted within the bar in a peach color.  A circled number (6) is positioned below the segment containing the value 6, with an upward arrow pointing to it and the text 'closest endpoint to the right of 5' indicating that index 2 (containing the value 6) is the closest endpoint to the right of the target value 5.  The circled numbers (3, 4, 6, 10) seem to highlight specific indices or elements for illustrative purposes within the context of the algorithm being explained.

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:

Image represents a depiction of prefix sums, showing an array `prefix_sums` initialized with values [3, 4, 6, 10].  The array is presented horizontally. Each element's value is displayed numerically. Below each element, a colored vertical line indicates its status:  'F' (in red) represents 'False', and 'T' (in green) represents 'True'. The first two elements (3 and 4) are marked with 'F', while the last two elements (6 and 10) are marked with 'T'. This suggests a visual representation of a boolean condition applied to the prefix sums, where the last two elements satisfy the condition and the first two do not.  The arrangement visually links the numerical value of each element with its corresponding boolean status.

As we can see, 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.

Let’s see how this works over our example with a random target of 5. The search space should encompass all prefix sum values:

Image represents a visual depiction of a two-pointer approach to finding a subarray with a target sum.  Two orange rectangular boxes labeled 'left' and 'right' point downwards towards an array represented by `prefix_sums = [3, 4, 6, 10]`.  Beneath each element of the `prefix_sums` array, its index (0, 1, 2, 3) is shown in gray. The number 6 in the `prefix_sums` array is highlighted in light green. A separate line indicates `target = 5`. The arrows from 'left' and 'right' visually suggest the movement of pointers across the `prefix_sums` array during the algorithm's execution, aiming to find a subarray whose sum equals the `target` value.  The highlighted '6' likely represents a point where the algorithm might find a solution (or a relevant intermediate state), given that the difference between consecutive prefix sums could be used to determine the sum of subarrays.

Let’s begin narrowing the search space. Remember that 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:

Image represents a visual depiction of a binary search algorithm's step within the context of prefix sums.  The diagram shows four labeled boxes: 'left,' 'mid,' and 'right' representing index pointers, and a dashed box containing a conditional statement.  'left' and 'mid' point downwards to an array [3, 4, 6, 10] with indices 0, 1, 2, and 3 respectively displayed below each element.  'right' also points downwards to the same array. Below the array, 'target = 5' is specified. The dashed box displays the condition 'prefix_sums[mid] < target,' which evaluates whether the prefix sum at the 'mid' index is less than the target value (5).  If true, as indicated by the arrow, the 'left' pointer is updated to 'mid + 1,' effectively narrowing the search space in a binary search fashion.  The light blue box around 'mid' and the blue arrow visually emphasize the current 'mid' index and its role in the conditional statement.
Image represents a visual depiction of a binary search algorithm's step.  A sorted array `[3, 4, 6, 10]` is shown with indices 0, 1, 2, and 3 labeled below each element.  A light gray rectangle labeled 'mid' points to the element '4' at index 1. An orange rectangle labeled 'left' points to the element '6' at index 2. A dark gray rectangle labeled 'right' points to the element '10' at index 3.  A dashed orange line connects the 'mid' label to the element '3' at index 0, indicating a comparison. A solid orange arrow points from 'left' to '6', showing the selection of the left subarray. A solid dark gray arrow points from 'right' to '10', showing the selection of the right subarray. The arrangement visually demonstrates the partitioning of the array during a binary search iteration, with the 'mid' element acting as the pivot for comparison and subsequent subarray selection.

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 further to the left. So, let’s narrow the search space toward the left while including the midpoint:

Image represents a visual depiction of a binary search algorithm step within a coding pattern context.  The top shows three labeled boxes: 'left,' 'mid' (highlighted in cyan), and 'right.' Arrows point downwards from 'left' and 'right' to the array [3, 4, 6, 10], indicating index positions 0, 2, and 3 respectively.  The 'mid' box points to the element '6' at index 2. Below the array, 'target = 5' is specified. A dashed box to the right shows a conditional statement: 'prefix_sums[mid] ≥ target,' which evaluates whether the prefix sum at the midpoint (6 in this case) is greater than or equal to the target (5).  A right-pointing arrow from this condition leads to the assignment 'right = mid,' indicating that if the condition is true, the right boundary of the search space is updated to the midpoint.  The overall diagram illustrates a single iteration of a binary search, where the algorithm checks if the prefix sum at the midpoint meets or exceeds the target value to refine the search range.
The image represents a visual depiction of a data structure, possibly an array or list, undergoing a partitioning process, likely as part of a sorting algorithm like Quicksort.  A gray horizontal line shows an array segment with elements '3' and '4' at indices 0 and 1 respectively, enclosed in square brackets.  Below this, the indices 0 and 1 are labeled. Separately, the number '6' is shown, representing a pivot element.  Two rectangular boxes labeled 'left' (gray) and 'right' (orange) indicate pointers or indices. A solid gray arrow from 'left' points to '6', and an orange arrow from 'right' also points to '6'.  A dashed orange curved line connects '6' to an element '10' at index 3 (labeled below), which is also part of the array segment, enclosed in square brackets.  The arrangement suggests that the 'left' and 'right' pointers are converging towards the pivot '6' during the partitioning phase, with '10' being an element greater than the pivot that needs to be moved.

Now, the left and right pointers have met with the search space consisting of a single value which represents the lower bound. So, we can exit the binary search and return the index that corresponds to this prefix sum: left:

Image represents a diagram illustrating a coding pattern, possibly related to binary search or a similar algorithm.  At the top, two orange rectangular boxes labeled 'left' and 'right' are shown, with downward-pointing arrows indicating data flow. Below, a horizontally oriented array or list is depicted, showing the elements [3, 4, 6, 10] with their indices [0, 1, 2, 3] displayed underneath.  The 'left' and 'right' labels appear to represent pointers or indices into this array.  The arrows from 'left' and 'right' point to the element '6' in the array, suggesting that these pointers are converging on a specific element. To the right, a light gray, dashed-line rectangle contains the conditional statement 'left == right' followed by a right-pointing arrow and the action 'return left'. This indicates that if the left and right pointers are equal, the algorithm returns the value at the left pointer's index. The overall diagram visualizes a search or comparison process where two pointers move towards each other until they meet, at which point a result is returned.

Implementation

python
from typing import List
import random
    
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])
      
    def select(self) -> int:
        # Pick a random target between 1 and the largest endpoint on the number
        # line.
        target = random.randint(1, self.prefix_sums[-1])
        left, right = 0, len(self.prefix_sums) - 1
        # Perform lower-bound binary search to find which endpoint (i.e., prefix
        # sum value) corresponds to the target.
        while left < right:
            mid = (left + right) // 2
            if self.prefix_sums[mid] < target:
                left = mid + 1
            else:
                right = mid
        return left

Complexity Analysis

Time complexity: The time complexity of the constructor is O(n)O(n)O(n) because we iterate through each weight in the weights array once. The time complexity of select is O(log⁡(n))O(\log(n))O(log(n)) since we perform binary search over the prefix_sums array.

Space complexity: The space complexity of the constructor is O(n)O(n)O(n) due to the prefix_sums array. The space complexity of select is O(1)O(1)O(1).

Chapter 7

Stacks

~31 min read

Introduction to Stacks

Introduction to Stacks

Intuition

Imagine a stack of plates. You can only add a new plate to the top of the stack, and when you need a plate, you take the one from the top. It’s not possible to take a plate from the bottom or middle without first removing all the plates above it.

The image represents a visual analogy for adding and removing elements from a stack data structure.  The left side depicts the 'add plate' operation, showing a single rectangular plate (representing a data element) being added to the top of a stack of similar plates already present. A downward-pointing arrow connects the single plate to the stack, indicating the addition process. The text 'add plate' in orange is positioned next to the arrow, clarifying the action. The right side illustrates the 'remove plate' operation. Here, a single plate is being removed from the top of a stack of plates. An upward-pointing, curved arrow indicates the removal of the topmost plate, which is shown slightly separated from the rest of the stack. The text 'remove plate' in orange is placed near the arrow, explaining the operation.  Both sides use bold, black, rectangular shapes to represent the plates, clearly showing the stack's Last-In, First-Out (LIFO) nature.

This analogy encapsulates the essence of the stack data structure. Adding a plate to and taking a plate from the top of the stack, physically demonstrates the two main stack operations:

  • Push (adds an element to the top of the stack).
  • Pop (removes and returns the element at the top of the stack).
Image represents a visual depiction of stack data structure operations.  The image is arranged in a 2x4 grid, each cell showing a snapshot of a stack at a different stage.  The top row demonstrates the `push` operation, where elements (1, 4, 2) are added to the stack from right to left.  Each stack is represented as a container, with the numbers inside representing the elements, and the newest element added at the top.  A light-blue arrow labeled 'push(x)' indicates the addition of element 'x' to the stack. The bottom row illustrates the `pop` operation, where elements are removed from the top of the stack from left to right. A purple arrow labeled 'pop' shows the removal of the top element.  The numbers within the stack containers change accordingly to reflect the addition or removal of elements, demonstrating the Last-In, First-Out (LIFO) nature of a stack.  The label 'stack' is present below each container to clearly identify the data structure.

LIFO (Last‐In‐First‐Out) Stacks follow the LIFO principle, meaning the most recently added item is the first to be removed. This unique characteristic makes stacks particularly useful in various scenarios where the order of processing or removal is critical. Here are a few key applications:

  • Handling nested structures: Stacks are a good option for parsing or validating nested structures such as nested parentheses in a string (e.g., "(())()"). They allow us to process the innermost nested structures first due to the LIFO principle.
  • Reverse order: When elements are added (pushed) onto a stack and then removed (popped), they come out in the reverse order of how they were added. This property is useful for reversing sequences.
  • Substitute for recursion: Recursive algorithms use the recursive call stack to manage recursive calls. Ultimately, this recursive call stack is itself a stack. As such, we can often implement recursive functions iteratively using the stack data structure. • Monotonic stacks: These special‐purpose stacks maintain elements in a consistent, increasing or decreasing sorted order. Before adding a new element to the stack, any elements that break this order are removed from the top of the stack, ensuring the stack remains sorted.

Some examples of the above applications are explored in this chapter.

Below is a time complexity breakdown of common stack operations:

OperationWorst caseDescription
PushO(1)O(1)Adds an element to the top of the stack.
PopO(1)O(1)Removes and returns the element at the top of the stack.
PeekO(1)O(1)Returns the element at the top of the stack without removing it.
IsEmptyO(1)O(1)Checks if the stack is empty.

Real-world Example

Function call management: As hinted above, a common real‐world example of stacks is in function call management within operating systems or programming languages.

When a function is called, the program pushes the function’s state (including its parameters, local variables, and the return address) onto the call stack. As functions call other functions, their states are also pushed onto the stack. When a function completes, its state is popped off the stack, and the program returns to the calling function. This stack‐based approach ensures that functions return control in the correct order, managing nested or recursive function calls efficiently.

Chapter Outline

Image represents a hierarchical diagram illustrating various applications of stacks in coding patterns.  The topmost node is labeled 'Stacks,' from which four downward-pointing dashed lines extend to four rectangular boxes representing different coding concepts.  Two boxes branch from the left side of 'Stacks': 'Nested Structures,' containing the sub-problems 'Valid Parenthesis Expression' and 'Evaluate Expression,' and 'Queue,' which describes the problem of 'Implement a Queue Using Stacks.'  Two boxes branch from the right side of 'Stacks': 'Deque,' which includes the problem 'Maximums of Sliding Window,' and 'Monotonic Stack,' which describes finding the 'Next Largest Number to the Right.'  Finally, a single dashed line connects the bottom of both 'Deque' and 'Monotonic Stack' to a fifth box labeled 'Processing Duplicates,' which details the problem of 'Repeated Removal of Adjacent Duplicates.'  The dashed lines indicate a flow of information or application, showing how stacks are used to solve problems in each of the listed categories.

This chapter explores a variety of problems, offering detailed explanations for how to use stacks in problem solving. Additionally, we briefly introduce queues and deques, which are two data structures that share similarities with stacks, but operate on different principles.

Valid Parenthesis Expression

Valid Parenthesis Expression

Given a string representing an expression of parentheses containing the characters '(', ')', '[', ']', '{', or '}', determine if the expression forms a valid sequence of parentheses.

A sequence of parentheses is valid if every opening parenthesis has a corresponding closing parenthesis, and no closing parenthesis appears before its matching opening parenthesis.

Example 1:

python
Input: s = '([]{})'
Output: True

Example 2:

python
Input: s = '([]{)}'
Output: False

Explanation: The '(' parenthesis is closed before its nested '{' parenthesis is closed.

Intuition

An early observation is that for each type of parenthesis, the number of opening and closing parenthesis must be identical. However, to check if an expression is valid, this observation alone isn’t enough. For example, the string "())(" has the same number of opening and closing parentheses, but is still invalid. This means we need a way to account for the order of parentheses.

Consider the string “()”. The first parenthesis is opening, and we’re waiting for it to be closing. Upon reaching the second parenthesis, the first parenthesis gets closed.

Image represents a comparison of two scenarios related to parenthesis in code, likely illustrating a coding pattern or concept.  The left side depicts a state where an opening parenthesis '(' is 'waiting to be closed,' indicated by a downward arrow pointing from the text 'waiting to be closed' to a single opening parenthesis `(` below, labeled with '0' and '1' underneath, possibly representing a counter or index. The right side shows the state where a closing parenthesis ')' is introduced, represented by a downward arrow from 'closes '('' to a closing parenthesis `)` below, also labeled with '0' and '1' similarly.  The overall structure highlights the difference between an unclosed opening parenthesis and the subsequent closing of that parenthesis, emphasizing the importance of balanced parentheses in programming syntax.

Now, consider the string “[(])”. When we reach index 1, we have two opening parentheses waiting to be closed. In particular, we expect ‘(‘ to be closed before ‘[’. The first closing parenthesis we encounter is ‘]’, which does not close ‘(’. Therefore, this string is invalid.

Image represents a comparison of two scenarios illustrating parenthesis matching in a coding context.  The left side depicts a correctly matched sequence, showing the text 'most recent parenthesis waiting to be closed' above a gray downward-pointing arrow. This arrow points to a sequence of parentheses: `[(])`, indexed 0 to 3 from left to right.  The closing parenthesis at index 3 correctly matches the opening parenthesis at index 1, indicating proper nesting.  In contrast, the right side shows an incorrectly matched sequence.  The text 'does not close most recent parenthesis' (in red) is positioned above a red downward-pointing arrow, which points to a similar sequence `[(])`, also indexed 0 to 3. However, here the closing parenthesis at index 3 does *not* match the most recent opening parenthesis (at index 1), highlighting an error in parenthesis matching.  Both sides use the same parenthesis characters and indexing to facilitate a direct comparison of correct versus incorrect parenthesis closure.

The key observation here is that the most recent opening parenthesis we encounter should be the first parenthesis that gets closed. So, opening parentheses are processed from most recent to least recent, which is indicative of a last-in-first-out (LIFO) dynamic. This leads to the idea that a stack can be used to solve this problem.

Stack Here’s a high-level strategy:

  • Add each opening parenthesis we encounter to the stack. This way, the most recent parenthesis is always at the top of the stack.
  • When encountering a closing parenthesis, check if it can close the most recent opening parenthesis. If it can, close that pair of parenthesis by popping off the top of the stack. If not, the string is invalid.

Let’s see how this strategy works over an example:

Image represents a sequence of six distinct symbols, arranged horizontally from left to right with spaces between them.  The symbols are: an opening parenthesis `(`, an opening square bracket `[`, a closing square bracket `]`, an opening curly brace `{`, a closing parenthesis `)`, and a closing curly brace `}`.  There are no connections or information flow depicted between the symbols; they are simply presented as individual, independent elements.  No labels, text, URLs, or parameters are present within the image. The image likely illustrates the different types of grouping symbols used in programming languages or mathematical notation.

For each opening parenthesis we encounter, push it to the top of the stack:

Image represents a visual explanation of a stack data structure used for parenthesis matching.  On the left, a sequence of opening and closing parentheses, brackets, and braces – `( [ ] { } )` – is shown, with an orange square containing an 'i' and a downward arrow pointing to the sequence, suggesting input.  On the right, a diagram depicts a stack labeled 'stack' with a 'top' indicator.  A light-grey 'top:' label points to the top of the stack, which is currently empty. A light-blue arrow labeled 'push (' indicates that the opening parenthesis '(' is being pushed onto the stack, implying that the algorithm is processing the input sequence from left to right and using the stack to keep track of opening delimiters.  The overall image illustrates the initial step of a parenthesis matching algorithm using a stack.
Image represents a visual explanation of a stack data structure used in coding, specifically demonstrating a push operation.  On the left, a sequence of opening and closing parentheses, brackets, and braces: `( [ ] { ) }` is shown.  An orange square containing an 'i' symbol, suggesting input, points downwards with an arrow towards the '[' bracket, indicating the element being processed. On the right, a diagram depicts a stack labeled 'stack' with a 'top' pointer.  The stack currently contains an opening parenthesis '('. A light-blue arrow labeled 'push [' shows the '[' bracket being added to the top of the stack, illustrating the 'push' operation where a new element is added to the top of the stack.  The arrangement visually explains how an input element is added to the stack, modifying its contents and updating the top pointer.

Next, we encounter a closing parenthesis. Comparing it to the opening parenthesis at the top of the stack, we see that it correctly closes that opening parenthesis. So, we can pop off the opening parenthesis at the top of the stack:

Image represents a visual explanation of a stack data structure used for parenthesis matching.  On the left, a sequence of opening and closing parentheses, brackets, and braces — `(`, `[`, `]`, `{`, `)` — is shown. An orange square labeled 'i' points downwards, indicating an input stream of these characters.  To the right, a stack is depicted as a container labeled 'stack' with a 'top' pointer indicating the top element.  Inside the stack, an opening bracket '[' is present. A purple arrow labeled 'pop' extends from the top of the stack to a light gray dashed box containing the text ''[' is closed by ']':', illustrating that the top element '[' is being popped from the stack, implying a matching closing bracket ']' is expected in the input stream to maintain balanced parentheses.  The diagram demonstrates how a stack is used to verify the correct pairing of opening and closing delimiters in a given input string.

The next character is an opening parenthesis, which we just push to the top of the stack:

Image represents a visual explanation of a stack data structure and its 'push' operation.  The left side shows a sequence of opening and closing brackets and braces: '(', '[', ']', '{', ')', '}'.  A downward-pointing orange arrow with a small orange square containing an 'i' (likely indicating information or instruction) points to the '{' character. This suggests the '{' is the next element to be processed. The right side depicts a stack labeled 'stack' with its contents shown as '{' and '('; the label 'top:' indicates the top of the stack. A light-blue arrow labeled 'push { ' points from the right to the stack, illustrating the 'push' operation where the '{' character is being added to the top of the stack.  The overall diagram demonstrates how a character ('{') is pushed onto a stack, which is a Last-In, First-Out (LIFO) data structure.

The next character is a closing parenthesis, ‘)’, which does not close the opening parenthesis at the top of the stack, ‘{’. This means this parenthesis expression is invalid. As such, we return false.

Image represents a visual explanation of a stack-based approach to validating balanced parentheses in a code snippet.  On the left, a sequence of opening and closing parentheses, brackets, and braces (`(`, `[`, `{`, `)`, `]`, `}`) is shown. An orange arrow with an 'i' icon points downwards towards the curly brace '{' and closing parenthesis ')'. To the right, a diagram depicts a stack data structure labeled 'stack,' with the characters '{' and '(' currently inside it, indicating that these opening brackets have been pushed onto the stack. The label 'top:' points to the top of the stack, which is '{'.  A light gray dashed-line box contains pseudocode: `'{' is not closed by ')': return False`, illustrating a scenario where an opening curly brace '{' is encountered, but its corresponding closing brace '}' is not found before a closing parenthesis ')', resulting in a `False` return value, indicating unbalanced parentheses.  The overall image demonstrates how a stack can be used to track opening brackets and check for proper closing counterparts during code validation.

If we’ve iterated over the entire string without returning false, that means we’ve accounted for all closing parentheses in the string.

Edge case: extra opening parentheses We only check for invalidity at closing parenthesis, so need to perform a final check to ensure there aren’t any opening parentheses in the string left unclosed. This can be done by checking if the stack is empty after processing the whole input string, as a non-empty stack indicates opening parentheses remain in the stack.

Managing three types of parentheses In our algorithm, we need a way to ensure we compare the correct types of opening and closing parentheses. We can use a hash map for this, which maps each type of opening parenthesis to its corresponding closing parenthesis:

Image represents a simple diagram titled 'parenthesis_map' illustrating a mapping between opening and closing parentheses, brackets, and braces.  The diagram is rectangular and divided vertically into two equal sections labeled 'open' and 'closed' respectively.  The 'open' section displays an opening curly brace '{', an opening square bracket '[', and an opening parenthesis '(', stacked vertically.  The 'closed' section mirrors this, showing the corresponding closing curly brace '}', closing square bracket ']', and closing parenthesis ')', also stacked vertically in the same order.  The arrangement visually demonstrates a one-to-one correspondence between each opening symbol on the left and its respective closing symbol on the right, implying a data structure or algorithm that relies on pairing these symbols for validation or processing.

This hash map can also be used as a way to check if a parenthesis is an opening or a closing one: if the parenthesis exists in this hash map as a key, it’s an opening parenthesis.

Implementation

python
def valid_parenthesis_expression(s: str) -> bool:
    parentheses_map = {'(': ')', '{': '}', '[': ']'}
    stack = []
    for c in s:
        # If the current character is an opening parenthesis, push it onto the stack.
        if c in parentheses_map:
            stack.append(c)
        # If the current character is a closing parenthesis, check if it closes the
        # opening parenthesis at the top of the stack.
        else:
            if stack and parentheses_map[stack[-1]] == c:
                stack.pop()
            else:
                return False
    # If the stack is empty, all opening parentheses were successfully closed.
    return not stack

Complexity Analysis

Time complexity: The time complexity of valid_parenthesis_expression is O(n)O(n)O(n) because we traverse the entire string once. For each character, we perform a constant-time operation, either pushing an opening parenthesis onto the stack or popping it off for a matching closing parenthesis.

Space complexity: The space complexity is O(n)O(n)O(n) because the stack stores at most nnn characters, and the hash map takes up O(1)O(1)O(1) space.

Next Largest Number to the Right

Next Largest Number to the Right

Given an integer array nums, return an output array res where, for each value nums[i], res[i] is the first number to the right that's larger than nums[i]. If no larger number exists to the right of nums[i], set res[i] to ‐1.

Example:

The image represents a bar chart illustrating a data transformation or accumulation process.  The horizontal axis displays an unsorted sequence of numerical values (5, 2, 4, 6, 1), each represented by a black vertical bar whose height corresponds to its magnitude.  Below the chart, a list `res = [6 4 6 -1 -1]` shows the resulting data after some operation.  Dashed orange arrows connect the tops of the bars to corresponding values in the `res` list, indicating a mapping or transformation.  For instance, the bar representing '5' maps to '6' in the `res` list, the bar representing '2' maps to '4', and so on.  The vertical axis represents the magnitude of the values, ranging from 0 to 6.  The chart visually demonstrates how the input values are processed to produce the output values in the `res` list, suggesting a potential algorithm involving accumulation or modification of the input sequence.  The negative values in `res` suggest a possible subtraction or other negative transformation applied to some input values.
python
Input: nums = [5, 2, 4, 6, 1]
Output: [6, 4, 6, -1, -1]

Intuition

A brute-force solution to this problem involves iterating through each number in the array and, for each of these numbers, linearly searching to their right to find the first larger number. This approach takes O(n2)O(n^2)O(n2) time, where nnn denotes the length of the array. Can we think of something better?

Let’s approach this problem from a different perspective. Instead of finding the next largest number for each value, what if we check whether the value itself is the next largest number for any value(s) to its left? For example, can we figure out which values in the following example have 6 as their next largest number?:

Image represents a bar chart illustrating a concept related to finding the next largest number. The horizontal axis displays numerical values (5, 2, 4, 6, 1), each represented by a vertical bar whose height corresponds to its magnitude.  A taller bar signifies a larger number.  Dashed orange arrows connect the bars representing 5 and 4 to the bar representing 6. This visually indicates that 6 is identified as the next largest number after 5 and 4. The bar representing 6 is highlighted with a peach-colored background. Below the chart, an arrow points to the text '6 is the next largest number of 5 and 4,' explicitly stating the relationship shown graphically.  The vertical axis represents the magnitude of the numbers, ranging from 0 to 6.

With this shift in perspective, we should search the array from right to left: certain values we encounter from the right could potentially be the next largest number of values to their left. Let’s call these values “candidates.” But how do we determine which numbers qualify as candidates?

Consider the example below:

Image represents a bar chart illustrating a frequency distribution.  The horizontal axis displays a sequence of numbers: [1, 1, 2, 3, 2, 3, 2, 4], representing data points.  The vertical axis represents frequency, ranging from 0 to 4.  Above each number on the horizontal axis, a vertical bar extends upwards to a height corresponding to its frequency of occurrence within the sequence. For instance, the number '1' appears twice, thus its bar reaches the height of '1' on the vertical axis; the number '2' appears three times, resulting in its bar reaching the height of '2'; the number '3' appears twice, reaching the height of '2'; and the number '4' appears once, reaching the height of '4'.  The chart visually summarizes the frequency of each unique value in the given data set.

Let’s start at the rightmost index, where the only value we know initially is 4:

Image represents a Cartesian coordinate system illustrating a data structure or algorithm, possibly related to candidate selection or a similar process.  The horizontal axis depicts an array or list, labeled `[ ... 4 ]` at its ends, suggesting a sequence of elements where '4' is the last visible element, and ellipses (...) indicate unseen preceding elements. The vertical axis represents a numerical value or index, ranging from 0 to 4. A vertical line segment extends from the horizontal axis at the point labeled '4' to the top of the diagram, indicating a selection or operation focused on the element '4'.  To the right, the text `candidates = []` shows an empty array or list named 'candidates', suggesting this structure might store selected elements.  The text `res = []` similarly shows an empty array named 'res', likely representing the results or output of the process. The downward-pointing arrow above the vertical line suggests data flow or an operation being performed on the element '4'. The overall diagram likely visualizes a step in an algorithm where the element '4' is being processed or selected, with the 'candidates' and 'res' arrays potentially storing intermediate or final results.

Right now, we can say 4 is a candidate as it might be the next largest number of values to its left. No candidates have been encountered before 4 because it’s the rightmost element, so we should mark the result for 4 in res as -1:

Image represents a Cartesian coordinate system illustrating a step in a coding pattern, possibly related to backtracking or a similar algorithm.  The horizontal axis represents an index or iterator, with values implicitly ranging from an unspecified starting point to 4, indicated by the label `[... 4]`. The vertical axis represents a value or counter, ranging from 0 to 4. A vertical line segment extends from the horizontal axis at the point x=4, reaching a value of approximately 4 on the y-axis.  To the right of the coordinate system, the text `candidates = [4]` indicates a list or array named `candidates` containing the single element 4. Below this, `res = [-1]` shows a list or array named `res` containing the single element -1, highlighted in a peach-colored box.  The downward-pointing arrow above the vertical line suggests a process is occurring at index 4, potentially modifying the `res` array. The overall diagram likely visualizes a state within an iterative algorithm where the `candidates` list is being processed, and the `res` list is being updated.

Next, we encounter a 2. The next largest number of 2 is the most recently added candidate that’s larger than it. In this case, the rightmost candidate in the candidates list represents the most recently added candidate, which is 4 in this case.

Record 4 as the next largest number of 2 in res, and then add 2 to the candidates list because it could be the next largest number of elements to its left:

Image represents a Cartesian coordinate system illustrating a step in a coding algorithm, possibly related to backtracking or dynamic programming.  The horizontal axis shows a range of values, with labeled points at 2 and 4, implying a discrete domain.  The vertical axis represents a count or index, ranging from 0 to 4. Two vertical lines are drawn at x=2 and x=4, extending from the horizontal axis to y=2 and y=4 respectively. A dashed orange arrow points from the line at x=2 to the line at x=4, indicating a transition or step in the algorithm.  Below the horizontal axis, `res = [ ... 4 -1]` is shown, suggesting a result array or list where '4' (highlighted in peach) is a current value and '-1' might represent a placeholder or a special value. To the right, `candidates = [4 2]` is displayed, indicating a list of candidate values used by the algorithm. The overall diagram visualizes a process where the algorithm moves from a state represented by the line at x=2 to a state represented by the line at x=4, updating the `res` array in the process, using values from the `candidates` list.

The next number is 3. Notice that with the introduction of 3, number 2 should no longer be considered a candidate. This is because it’s now impossible for 2 to be the next largest number of any value to its left. Since 3 is both larger and further to the left, it will always be prioritized over 2 as the next largest number. So, let’s remove 2 from the candidates list, as well as any other candidate that’s less than or equal to 3:

Image represents a graphical illustration of a filtering process within a coding pattern, likely related to candidate selection or data pruning.  A horizontal axis displays a sequence of numbers, including 2, 3, and 4, representing candidate values.  Vertical lines of varying styles indicate the presence and status of these candidates.  A solid black vertical line at '3' and '4' signifies that these candidates are initially present. A dashed red vertical line at '2' shows it as a candidate that will be removed. A downward-pointing grey arrow above the '3' suggests a filtering operation is being applied. To the right, the text 'remove all candidates ≤ 3:' in red describes the filtering criterion, while 'candidates = [4 2]' shows the initial candidates, and 'candidates = [4]' shows the candidates after filtering. Below, 'res = [4 -1]' likely represents the result of a subsequent operation on the remaining candidates. The vertical axis likely represents a frequency or some other metric, but its scale is not explicitly defined. The overall diagram visually demonstrates the removal of candidates based on a specified condition, resulting in a filtered set of candidates.

Since we removed all candidates smaller than 3, the rightmost candidate is now 4. So, let’s record 4 as the next largest number of 3 in res and add 3 to the candidates list:

Image represents a graphical depiction of a step in an algorithm, possibly related to candidate selection or a similar process.  The core of the image is a Cartesian coordinate system with a horizontal x-axis and a vertical y-axis ranging from 0 to 4.  Along the x-axis, a partially visible array `[ ... 3 2 4]` is shown, indicating a sequence of numbers. Two vertical lines, one black and one light gray, represent elements within this array. The black line is positioned at x=3 and extends vertically to y=3, while the light gray line is at x=2 and extends to y=1.  A dashed orange arrow points horizontally from the top of the black line (at y=3) to a second black line positioned at x=4, extending vertically to y=4.  Below the x-axis, the text `candidates = [4 3]` is displayed, suggesting these are the current candidates being considered.  Additionally, `res = [ 4 4 -1]` is shown, likely representing the results or output of the algorithm so far, with the number 4 highlighted in a peach color. The downward-pointing gray arrow above the first black line indicates the current processing step. The overall diagram illustrates a transition or movement of data, possibly a value of 3, from one position in the array to another, updating the `candidates` and `res` arrays accordingly.

This provides a crucial insight:

Whenever we move to a new number, all candidates less than or equal to this number should be removed from the candidates list.

Another key observation is that the list of candidates always maintains a strictly decreasing order of values. This is because we always remove candidates less than or equal to each new value, ensuring values are added in decreasing order. We can see this more clearly in the final state of the candidates list of the previous example:

Image represents a bar graph combined with a line graph illustrating a coding pattern. The x-axis displays a sequence of numbers: [1, 1, 2, 3, 2, 3, 2, 4], representing an input array.  Above each x-axis value, a vertical bar is drawn; the height of the bar corresponds to the values in a second array, `res = [2, 2, 3, 4, 3, 4, 4, -1]`, shown below the x-axis.  The bars are black for positive values in `res` and light gray for the last value (-1). An orange line connects points representing a cumulative sum or another derived value from the `res` array. This line starts at (1,1) and increases with each subsequent point, indicating a trend.  Separately, a rectangular box displays the array `candidates = [4, 3, 2, 1]`, which is labeled as 'decreasing,' indicating the array's elements are in descending order. An orange arrow points from the `candidates` array to the right, visually connecting it to the graph, suggesting that the `candidates` array is the input or a related data structure used to generate the graph's data.

This indicates a stack is the ideal data structure for storing the candidates list, since stacks can be used to efficiently maintain a monotonic decreasing order of values, as mentioned in the introduction.

The top of the stack represents the most recent candidate to the right of each new number encountered. Given this, here’s how to use the stack to add and remove candidates at each value:

  1. Pop off all candidates from the top of the stack less than or equal to the current value.
  2. The top of the stack will then represent the next largest number of the current value.
  • Record the top of the stack as the answer for the current value.
  • If the stack is empty, there’s no next largest number for the current value. So, record -1.
  1. Add the current value as a new candidate by pushing it to the top of the stack.

Implementation

python
from typing import List
    
def next_largest_number_to_the_right(nums: List[int]) -> List[int]:
    res = [0] * len(nums)
    stack = []
    # Find the next largest number of each element, starting with the rightmost
    # element.
    for i in range(len(nums) - 1, -1, -1):
        # Pop values from the top of the stack until the current value's next largest
        # number is at the top.
        while stack and stack[-1] <= nums[i]:
            stack.pop()
        # Record the current value's next largest number, which is at the top of the
        # stack. If the stack is empty, record -1.
        res[i] = stack[-1] if stack else -1
        stack.append(nums[i])
    return res

Complexity Analysis

Time complexity: The time complexity of next_largest_number_to_the_right is O(n)O(n)O(n). This is because each value of nums is pushed and popped from the stack at most once.

Space complexity: The space complexity is O(n)O(n)O(n) because the stack can potentially store all nnn values.

Evaluate Expression

Evaluate Expression

Given a string representing a mathematical expression containing integers, parentheses, addition, and subtraction operators, evaluate and return the result of the expression.

Example:

python
Input: s = '18-(7+(2-4))'
Output: 13

Intuition

At first, it might seem overwhelming to deal with expressions that include a variety of elements like negative numbers, nested expressions inside parentheses, and numbers with multiple digits. The key to managing this complexity is to break down the problem into smaller, more manageable parts. Let's first focus on evaluating simple expressions that contain no parentheses.

Handling positive and negative signs Consider the following expression:

Image represents a simple arithmetic expression displayed linearly.  The expression consists of the number 28, a minus sign (-), the number 10, a plus sign (+), and the number 7. These elements are arranged sequentially from left to right, indicating the order of operations.  No explicit connections or information flow are depicted beyond the implied mathematical relationship between the numbers and operators. The expression suggests a calculation where 28 is subtracted by 10 and then added to 7.  There are no URLs or parameters present.

There's already some complexity in this expression with there being two signs to consider: plus and minus. An immediate simplification we can make is to treat all expressions as ones of pure addition. This is possible when we assign signs to each number (1 representing + and -1 representing -). This sign can be multiplied by the number to attain its correct value. This allows us to just focus on performing additions:

Image represents three separate instances demonstrating a concept likely related to signed numbers or data. Each instance consists of a pair of numbers and a sign indicator.  The first instance shows a plus sign `(+)` followed by the numbers `2` and `8`, with a grey curved arrow pointing upwards to the text 'sign = 1' above it, indicating a positive sign. The second instance displays a minus sign `(-)` followed by the numbers `1` and `0`, similarly linked by a grey curved arrow to 'sign = -1' above, representing a negative sign. The third instance mirrors the first, with a plus sign `(+)` followed by the number `7`, and an upward-pointing grey curved arrow connected to 'sign = 1', again indicating a positive sign.  The arrangement suggests a pattern where the sign value (`1` or `-1`) is associated with a pair of numbers, possibly representing a magnitude and an additional data point, or a single number with a sign.

Processing numbers with multiple digits Another complexity in this expression is that some numbers have multiple digits. We’ll need a way to build numbers digit by digit until we reach the end of the number. We can build a number using the variable curr_num, which is initially set to 0. Every time we encounter a new digit, we multiply curr_num by 10 and add the new digit to it, effectively shifting all digits to the left and appending the new digit.

We can see this process play out for the string “123” in the following illustration:

Image represents a diagram illustrating a digit-by-digit number conversion process.  A rectangular box labeled 'digit' points downwards with an arrow to a sequence of digits '1 2 3'. Below this, a dashed-line rectangle contains a formula: `curr_num = 10 * curr_num + digit`.  This formula shows how a running number (`curr_num`) is updated iteratively.  Below the formula, a step-by-step calculation is shown for the first digit (1): `curr_num = 0 + 1`, and the result `curr_num = 1`. This implies that the process starts with `curr_num` initialized to 0, and each digit is incorporated into `curr_num` by multiplying the current value of `curr_num` by 10 and adding the new digit.  The diagram visually demonstrates how the algorithm converts a sequence of digits into a numerical value.
Image represents a step-by-step calculation of converting a sequence of digits into a numerical value.  A rectangular box labeled 'digit' points downwards with an arrow towards the digits '1', '2', and '3' arranged horizontally. Below, a dashed-line rectangle shows the calculation process.  The formula 'curr_num = 10 * curr_num + digit' is displayed, illustrating how each digit is incorporated into a running total (curr_num).  The calculation is demonstrated with an example: initially, curr_num is implicitly 1 (from the first digit), then it becomes 10 * 1 + 2 = 12 after processing the second digit '2'. The final result, '12', is shown as the outcome of the calculation.  The diagram visually depicts a common algorithm for converting a string representation of a number into its integer equivalent.
Image represents a diagram illustrating a numerical processing algorithm.  A rectangular box labeled 'digit' sits above a sequence of digits '1 2 3'. A downward-pointing arrow connects 'digit' to the digit '3', indicating the input of a single digit into the process. Below, a dashed-line rectangle contains a series of equations demonstrating the algorithm's operation. The first equation, 'curr_num = 10 * curr_num + digit,' shows the core calculation: the current number (`curr_num`) is updated by multiplying it by 10 and adding the incoming digit.  The subsequent lines show the step-by-step calculation:  assuming an initial `curr_num` of 12 (implied but not explicitly shown), the equation becomes '120 + 3,' resulting in a final `curr_num` of 123. This demonstrates how the algorithm constructs a number digit by digit, effectively converting a sequence of digits into a single numerical value.

We can stop building this number once we encounter a non-digit character, indicating the end of the number.

Evaluating an expression without parentheses With the information from the section above, let's evaluate the following expression, which contains no parentheses. We’ll start off with a sign of 1:

Image represents a snapshot of a program's state during execution, likely illustrating a coding pattern related to numerical processing or string manipulation.  The image shows a sequence of numbers '2 8 - 1 0 + 7,' suggesting a numerical expression being processed.  This is followed by two variable assignments: 'sign = 1,' indicating a positive sign, and 'curr_num = 0,' representing a current numerical value initialized to zero. The arrangement suggests that the numerical sequence is input data, and the variables 'sign' and 'curr_num' are internal program variables storing intermediate results.  There's no explicit connection shown between the numerical sequence and the variables, implying an implicit processing step where the program iterates through the sequence, updating 'sign' and 'curr_num' based on the operators (+, -, etc.) encountered.  The comma after '7' might indicate the end of the input sequence or a delimiter.  The overall structure suggests a step-by-step processing of a numerical expression, possibly part of a larger algorithm.

Upon reaching the ‘-’ operator, we’ve reached the end of the first number (28). So, let’s:

  1. Multiply the current number (28) by its sign (1).
  2. Add the resulting product (28) to the result.
  3. Update the sign to -1 since the current operator is a minus sign.
  4. Reset curr_num to 0 before building the next number.
Image represents a visual explanation of a coding pattern, likely related to parsing or processing numerical strings.  The left side shows a sequence of numbers (28) with operators (+, -, +, etc.) interspersed.  A dashed box encloses '28', indicating it's treated as a single unit. An orange downward-pointing arrow from a box labeled 'i' points to the '28', suggesting an iterator or index.  Above the '28' is a plus sign within a dashed circle, connected by an upward-pointing arrow to 'sign = 1', indicating a positive sign. Below the '28' is 'curr_num = 28', showing the current numerical value. The right side shows a code snippet illustrating the calculation: `res += sign * curr_num`, where `res` is a result variable, `sign` holds the sign (+1 or -1), and `curr_num` holds the current number.  The calculation steps show `+= 28` (adding 28 to `res`), resulting in `= 28`. Below, it shows the updated values: `sign = -1, curr_num = 0`, indicating the sign has changed and the current number is reset to 0 after processing '28'. The overall diagram demonstrates how a numerical string is processed iteratively, updating the sign and current number in each step.

Once we reach the second operator, we multiply the current number (10) by its sign of -1 before adding the resulting product (-10) to the result. This effectively subtracts 10 from the result:

Image represents a step-by-step illustration of a coding pattern, likely related to number processing or parsing.  The left side shows a sequence of numbers (+2, 8, -1, 0, +7) with arrows indicating data flow.  A dashed orange box encloses '-1' and '0', representing a current number ('curr_num') which is initially 10. An orange downward-pointing arrow labeled 'i' points from above to the '0', suggesting an iterator or index.  The variable 'sign' is initialized to -1 and is connected to the '-1' in the sequence via an upward-pointing arrow. The right side displays a calculation box showing the update of a result variable ('res'). The calculation `res += sign * curr_num` is shown, with the initial values resulting in `res += -1 * 10 = -10`.  The final value of 'res' is shown as 18, implying a previous value of 28.  Below the calculation, the updated values of 'sign' (now 1) and 'curr_num' (now 0) are displayed, indicating a change in state after processing the current number.  The overall diagram visualizes the iterative processing of a number sequence, updating a result and internal variables in each step.

Finally, once we’ve reached the end of the string, we just add the final number (7) to the result after multiplying it by its sign of 1:

Image represents a diagram illustrating a coding pattern, possibly related to arithmetic operations or string manipulation.  The left side shows a sequence of arithmetic operators (+, -, etc.) followed by numbers (2, 8, 1, 0).  To the right of these, a central component displays variables `sign` (initialized to 1) and `curr_num` (initialized to 7), connected by a plus symbol (+) indicating an addition operation.  A downward-pointing orange arrow, originating from a small orange square labeled 'i', points to the '7' within the central component, suggesting an input or update to `curr_num`.  A dashed orange circle surrounds the '7' and the '+', highlighting their role in the calculation.  The rightmost section is a light gray box with dashed borders, showing a calculation: `res += sign * curr_num`, which is then broken down into steps: `+= 7` (representing the addition of 7 to a variable `res`), and finally `= 25` (the result). Below this, the final values of `sign` (1) and `curr_num` (0) are displayed, indicating that the operation has updated these variables. The overall diagram visually depicts a step-by-step calculation, likely part of a larger algorithm or function.

Evaluating expressions containing parentheses Now that we can solve simple expressions, it's time to bring parentheses into the discussion. Moving forward, we define a nested expression as one that’s inside a pair of parentheses.

One challenge is that we need to evaluate the results of nested expressions before we can calculate the original expression. Once all nested expressions are evaluated, we can evaluate the original expression.

Image represents a mathematical expression structured to illustrate order of operations. The expression is '18 - (7 + (2 - 4))'.  The numbers 1 and 8 are positioned to the left, followed by a minus sign.  Then, an opening parenthesis introduces a nested expression: 7 plus an inner parenthesized expression (highlighted in peach) containing '2 - 4'. This inner expression is underlined with an orange line, indicating it's a sub-problem to be solved first. A downward-pointing orange arrow extends from this underline to the word 'solve' written in orange below, explicitly showing the flow of computation. The entire expression is closed with a closing parenthesis. The arrangement visually emphasizes the nested structure and the sequential solving of the inner expression before the outer subtraction.
Image represents a simple arithmetic expression depicted as a data flow diagram.  The expression is '18 - (7 - 2)'.  The numbers '1' and '8' are displayed to the left, followed by a minus sign.  To the right of the minus sign is a light peach-colored, rounded rectangle containing the subexpression '(7 - 2)'.  A thick orange line underlines the subexpression, from which a downward-pointing orange arrow extends, labeled 'solve' in orange text. This arrow visually indicates that the subexpression '(7 - 2)' is evaluated first. The overall structure suggests a hierarchical approach to solving the expression, prioritizing the inner parentheses before performing the main subtraction.
Image represents a simple arithmetic expression depicted as a data flow diagram.  A peach-colored, horizontally-oriented rectangle contains the numbers '1', '8', a hyphen ('-'), and '5', representing the mathematical expression '18 - 5'.  A horizontal, orange line extends beneath this rectangle, acting as a connector.  From the midpoint of this line, a downward-pointing orange arrow points to the word 'solve' written in orange text, indicating that the expression '18 - 5' is to be processed or solved.  The overall structure suggests a data input ('18 - 5') flowing into a processing step ('solve'), implying a computational process where the input is used to generate an output (the result of the subtraction).

Consider another problem in this chapter that also contains parentheses: Valid Parenthesis Expression. In that problem, we used a stack to process nested parentheses in the right order. This suggests a stack might also help us evaluate nested expressions in the right order. Let’s explore this idea further.

Similar to Valid Parenthesis Expression, an opening parenthesis ‘(‘ indicates the start of a new nested expression, whereas a closing parenthesis ‘)’ indicates the end of one. Understanding this, let’s try to use a stack to solve the following expression.

Image represents a visual explanation of arithmetic expression evaluation using a stack data structure.  The left side shows the arithmetic expression '1 8 - ( 7 + ( 2 - 4 ) )', composed of numbers (1, 8, 7, 2, 4), operators (-, +, -), and parentheses. The right side depicts a stack, labeled 'stack,' which is a last-in, first-out (LIFO) data structure.  The expression is evaluated from left to right, with operands and operators being pushed onto the stack according to operator precedence and parentheses.  Innermost parentheses are evaluated first, with results being pushed back onto the stack.  The stack's role is to temporarily store operands and operators until they are needed for calculation, enabling the correct order of operations to be maintained.  No information flows explicitly between the expression and the stack in the image; the image is a conceptual illustration of how the stack would be used during the evaluation process, not a step-by-step trace of the evaluation.

We already know how to evaluate expressions without parentheses, so let’s just focus on what to do when we encounter a parenthesis.

At the first opening parenthesis, we know a nested expression has started. Before we evaluate the nested expression, we’ll need to save the running result (res) of the current expression, as well as the sign of this upcoming nested expression. This way, once we’re done evaluating the nested expression, we can resume where we were in the current expression.

Here are the steps for when we encounter an opening parenthesis:

  1. stack.push(res): Save the running result on the stack.
  2. stack.push(sign): Save the sign of the upcoming nested expression on the stack.
  3. res = 0, sign = 1: Reset these variables because we’re about to begin calculating a new expression.
Image represents a diagram illustrating a coding pattern, likely for evaluating arithmetic expressions using a stack.  The left side shows an arithmetic expression `(7 + (2 - 4))` being processed.  Variables `res` (initially 18) and `sign` (initially -1) are shown, with an arrow indicating that `res` is updated.  A dashed box encloses the numbers 1 and 8, suggesting these are processed first.  An orange arrow points from a small orange square labeled 'i' to the expression, indicating an input or starting point. The middle section depicts a stack data structure, visually represented as an empty container labeled 'stack'. The right side shows a gray box with dashed borders outlining the actions performed on the stack: `stack.push(res)` pushes the value of `res` onto the stack, `stack.push(sign)` pushes the value of `sign`, and finally, 'reset res and sign' indicates that these variables are reset after the stack operations.  The overall flow suggests that the expression is evaluated by pushing intermediate results and signs onto the stack, then processing the stack to obtain the final result, with the `res` and `sign` variables tracking the current result and sign during the evaluation.
Image represents a blank, light gray square.  There are no visible components, shapes, lines, text, URLs, or any other elements present within the square.  No information is flowing or being represented. The image is entirely uniform in color and lacks any discernible structure or pattern.
Image represents a visual explanation of postfix notation evaluation using a stack.  The left side shows the arithmetic expression '1 8 - ( 7 + ( 2 - 4 ) )' with a small orange square containing an 'i' pointing down to the expression, suggesting an input or instruction. The expression is written in postfix (reverse Polish) notation, where operators follow their operands.  The right side depicts a stack data structure labeled 'stack' at the bottom.  Two horizontal, light-blue arrows labeled 'push' indicate the process of pushing values onto the stack. The top arrow shows -1 being pushed onto the stack, and the bottom arrow shows 18 being pushed onto the stack.  The numbers on the stack represent intermediate results during the evaluation of the postfix expression. The overall diagram illustrates how the postfix expression is processed step-by-step, with values and operators pushed onto and popped from the stack to compute the final result.

The next parenthesis we encounter is an opening parenthesis. Again, this indicates the start of a new nested expression. Let’s save the current result and sign on the stack, before resetting them to evaluate the upcoming nested expression:

Image represents a step-by-step illustration of evaluating an arithmetic expression using a stack.  The left side shows the expression '1 8 - ( 2 - 4 )' being processed.  The number 7, enclosed in a dashed circle, represents an intermediate result, with an upward arrow indicating it's assigned to the variable 'res = 7'. A plus sign, also in a dashed circle, shows the current operation. A downward arrow from the plus sign points to 'sign = 1', indicating the sign of the result.  A small orange square labeled 'i' points downward to the expression, suggesting an iterative process. The middle section depicts a stack, initially empty, with -1 and 18 shown as values pushed onto it. The right side displays a gray box outlining the stack operations: 'stack.push(res)' pushes the value of 'res' onto the stack, 'stack.push(sign)' pushes the sign onto the stack, and 'reset res and sign' indicates that the 'res' and 'sign' variables are reset for the next iteration. The overall diagram illustrates how an arithmetic expression is evaluated using a stack, pushing intermediate results and signs onto the stack before processing the next part of the expression.
Image represents a blank, light gray square.  There are no visible components, shapes, lines, text, URLs, or any other elements present within the square.  No information is flowing or being represented. The image is entirely uniform in color and lacks any discernible structure or pattern.
Image represents a visual explanation of postfix notation evaluation using a stack.  The left side shows the arithmetic expression '1 8 - ( 7 + ( 2 - 4 ) )' in postfix notation (reverse Polish notation). A downward-pointing orange arrow labeled 'i' indicates the current processing point within the expression. On the right, a stack is depicted, labeled 'stack' at its base.  The stack initially contains the numbers 18 and -1.  Two horizontal, light-blue arrows labeled 'push' show the numbers 7 and 1 being pushed onto the stack from the expression, with the arrowheads pointing towards the stack indicating the direction of data flow. The numbers in the stack are arranged vertically, with the topmost element being the most recently added.  The overall diagram illustrates a step in the process of evaluating the postfix expression using a stack, where operands are pushed onto the stack and operators pop operands for calculation.

The next parenthesis we encounter is a closing parenthesis. This means the current nested expression just ended, and we need to merge its result with the outer expression. Here’s how we do this:

  1. res *= stack.pop(): Apply the sign of the current nested expression to its result.
  2. res += stack.pop(): Add the result of the outer expression to the result of the current nested expression.
Image represents a visual explanation of postfix expression evaluation using a stack.  The left side shows the postfix expression '1 8 - ( 7 + ( 2 - 4 ) )', with the subexpression '( 2 - 4 )' highlighted in orange dashed lines and its result, -2, indicated with an orange upward-pointing arrow.  A small orange box labeled 'i' points downward to this -2, suggesting an intermediate calculation.  The middle section depicts a stack data structure, initially containing the numbers 18, -1, 7, and 1.  Purple arrows labeled 'pop' show the elements being popped from the stack.  The rightmost section demonstrates the calculation steps:  first, `res *= stack.pop()` calculates `res` (initially 0, implied) as 1 * 1 = 1, then `res` becomes -2 after `*= 1`.  Next, `res += stack.pop()` adds 7 to `res`, resulting in 5.  The overall process illustrates how the stack is used to evaluate the postfix expression, with operands pushed onto the stack and operators popping operands to perform calculations, ultimately yielding the final result.

After applying those operations, the value of res will be 5, representing the result of the highlighted part of the expression below:

The image represents a visual depiction of postfix expression evaluation using a stack.  The expression '1 8 - ( 7 + ( 2 - 4 ) )' is shown, with the numbers and operators arranged in postfix notation (reverse Polish notation).  A dashed orange rectangle highlights the subexpression '( 7 + ( 2 - 4 ) )' which is being evaluated first.  An orange arrow labeled 'i' points to this subexpression, indicating the current point of evaluation.  The innermost parentheses '( 2 - 4 )' are evaluated first, resulting in -2. This result is then added to 7, yielding 5.  An upward orange arrow points from the subexpression to 'res = 5', showing the result of the subexpression evaluation.  To the right, a rectangular box labeled 'stack' shows the stack's contents during evaluation: -1 and 18 are currently on the stack, representing intermediate results or operands.  The overall process illustrates how the stack is used to store intermediate results and operands while evaluating the postfix expression from left to right.

At the final closing parenthesis, we can apply the same steps:

Image represents a visual explanation of evaluating an arithmetic expression using a stack.  The expression '1 8 - (7 + (2 - 4))' is shown, with the innermost parentheses ' (2 - 4)' highlighted in an orange dashed box. An orange arrow points to a closing parenthesis, indicating the current processing point.  A stack is depicted as a container, initially empty.  The numbers from the expression are pushed onto the stack as they are encountered.  A purple arrow shows the value '1' being popped from the stack, followed by '18' also being popped.  A separate box on the right details the calculation steps: first, the top of the stack (-1) is multiplied by the result (initially 0), resulting in -5. Then, 18 is added to the result, yielding a final result of 13.  The entire process demonstrates how a stack can be used to evaluate expressions with nested parentheses, following a post-fix evaluation approach.

Finally, the value of res will be 13, representing the result of the entire expression:

Image represents a visual depiction of expression evaluation using a stack.  A mathematical expression '1 8 - ( 7 + ( 2 - 4 ) )' is shown enclosed within a dashed orange rectangle. An upward orange arrow points from the expression to 'res = 13,' indicating the final result of the calculation.  A separate orange box labeled 'i' points downward towards the expression, suggesting an input or instruction pointer. To the right, a simple representation of a stack is drawn, labeled 'stack,' implying that the expression is being evaluated using a stack-based approach. The arrangement shows the expression as the input, the stack as the processing mechanism, and the final result 'res = 13' as the output.  The image illustrates a step in the process of evaluating the arithmetic expression using a stack data structure.

Now that we’ve reached the end of the string, we can return res.

Implementation

python
def evaluate_expression(s: str) -> int:
    stack = []
    curr_num, sign, res = 0, 1, 0
    for c in s:
        if c.isdigit():
            curr_num = curr_num * 10 + int(c)
        # If the current character is an operator, add 'curr_num' to the result
        # after multiplying it by its sign.
        elif c == '+' or c == '-':
            res += curr_num * sign
            # Update the sign and reset 'curr_num'.
            sign = -1 if c == '-' else 1
            curr_num = 0
        # If the current character is an opening parenthesis, a new nested expression
        # is starting.
        elif c == '(':
            # Save the current 'res' and 'sign' values by pushing them onto
            # the stack, then reset their values to start calculating the new nested
            # expression.
            stack.append(res)
            stack.append(sign)
            res, sign = 0, 1
        # If the current character is a closing parenthesis, a nested expression has
        # ended.
        elif c == ')':
            # Finalize the result of the current nested expression.
            res += sign * curr_num
            # Apply the sign of the current nested expression’s result before adding
            # this result to the result of the outer expression.
            res *= stack.pop()
            res += stack.pop()
            curr_num = 0
    # Finalize the result of the overall expression.
    return res + curr_num * sign

Complexity Analysis

Time complexity: The time complexity of evaluate_expression is O(n)O(n)O(n) because we traverse each character of the expression once, processing nested expressions using the stack, where each stack push or pop operation takes O(1)O(1)O(1) time.

Space complexity: The space complexity is O(n)O(n)O(n) because the stack can grow proportionally to the length of the expression.

Repeated Removal of Adjacent Duplicates

Repeated Removal of Adjacent Duplicates

Given a string, continually perform the following operation: remove a pair of adjacent duplicates from the string. Continue performing this operation until the string no longer contains pairs of adjacent duplicates. Return the final string.

Example 1:

Image represents a sequence of data transformations or operations.  The sequence begins with two 'a's connected by a red horizontal line, followed by a 'c'. This is then followed by an 'a' and two 'b's connected by a red horizontal line, followed by another 'a'. A grey arrow indicates a transformation from this first segment to a second segment. The second segment starts with a 'c', followed by two 'a's connected by a red horizontal line, and finally another 'c'. A grey arrow indicates the flow from the first segment to the second, suggesting a transformation or mapping from the input (the first segment) to the output (the second segment). The red lines visually highlight groups of elements undergoing a specific operation or transformation, while the grey arrows represent the overall transformation process between the input and output.  The letters 'a', 'b', and 'c' likely represent data elements or variables.
python
Input: s = 'aacabba'
Output: 'c'

Example 2:

Image represents a simple data flow diagram illustrating a data transformation or processing step.  The diagram shows two instances of the variable 'a' connected by a thick red horizontal line, suggesting a direct, possibly in-place, modification or operation on 'a'. This is followed by a grey arrow pointing to another instance of 'a', indicating that the modified 'a' (from the red line connection) is then passed on or transformed into a new, potentially different, 'a'. The absence of labels on the connections or the 'a' variables themselves prevents a more precise description of the specific operation or transformation involved, but the visual structure clearly shows a sequential process where data ('a') undergoes a transformation and is then passed along.
python
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.

Consider the following string:

Image represents a sequence of lowercase letters arranged horizontally.  The sequence begins with two instances of the letter 'a', followed by a single 'c', then another 'a', and then two 'b's, concluding with a final 'a'.  There are no connections or arrows depicted between the letters; they are simply presented in a linear, left-to-right order. No URLs, parameters, or other additional information is present beyond the letters themselves.  The letters are spaced evenly apart, suggesting an ordered list or a simple data stream.
Image represents a visual depiction of an algorithm, likely for string manipulation.  The top section shows an input string 'a a c a b b a' preceded by a downward-pointing arrow originating from a square box labeled 'i,' suggesting an input indicator.  This input string is then processed, resulting in a 'new string: a' displayed to the right.  The bottom section mirrors the top, again showing the input string 'a a c a b b a' with the same input indicator 'i.'  However, the resulting 'new string' is shown as 'a [a]' with a red line striking through the second 'a', indicating removal.  A dashed-line box to the right of the bottom section labels this action as 'remove adjacent duplicate,' clarifying the algorithmic step performed.  The overall diagram illustrates a process where adjacent duplicate characters are identified and removed from the input string.

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:

Image represents a step-by-step illustration of an algorithm to remove adjacent duplicate characters from a string.  The initial string 'a a c a b b a' is displayed at the top.  A downward-pointing arrow labeled 'i' indicates an iterative process.  Each subsequent line shows the string after one iteration, with a new character being added to a 'new string' on the right.  The 'new string' starts empty and progressively accumulates characters from the original string.  Crucially, adjacent duplicate characters in the original string are skipped in the 'new string' construction.  The process continues until the index 'i' reaches the end of the original string.  Two rectangular boxes labeled 'remove adjacent duplicate' are placed to the right, visually associating this action with the iterative removal of adjacent duplicates.  The final 'new string' is 'c a', representing the string with adjacent duplicates removed.  The boxed characters in the 'new string' highlight the character added in each iteration.

Once the smoke clears, the resulting string we were building ends up just 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.

As we push characters onto the stack, the top of the stack will represent the previous/most recently added character. Given this, to mimic the process of building the “new string” as shown in the example, we:

  • 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

python
def repeated_removal_of_adjacent_duplicates(s: str) -> str:
    stack = []
    for c in s:
        # If the current character is the same as the top character on the stack,
        # a pair of adjacent duplicates has been formed. So, pop the top character
        # from the stack.
        if stack and c == stack[-1]:
            stack.pop()
        # Otherwise, push the current character onto the stack.
        else:
            stack.append(c)
    # Return the remaining characters as a string.
    return ''.join(stack)

Complexity Analysis

Time complexity: The time complexity of the repeated_removal_of_adjacent_duplicates function is O(n)O(n)O(n) where nnn denotes the length of the string. This is because we traverse the entire string, and we perform a join operation of up to nnn characters in the stack. The stack push and pop operations contribute O(1)O(1)O(1) time.

Space complexity: The space complexity is O(n)O(n)O(n) because the stack can store at most nnn characters.

Implement a Queue using Stacks

Implement a Queue using Stacks

Implement a queue using the stack data structure. Include the following functions:

  • enqueue(x: int) -> None: adds x to the end of the queue.
  • dequeue() -> int: removes and returns the element from the front of the queue.
  • peek() -> int: returns the front element of the queue.

You may not use any other data structures to implement the queue.

Example

python
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:

Image represents a comparison of queue and stack data structures.  On the left, a queue is depicted as a vertically stacked series of light-grey boxes, each containing a number (4, 3, 2, 1) from top to bottom.  The bottom box is labeled 'front:' and the top box is labeled 'end:'. A curved cyan arrow labeled 'enqueue' points from the left to the top box, indicating the addition of elements. A curved magenta arrow labeled 'dequeue' points from the bottom box to the right, showing the removal of elements.  The text 'first-in-first-out' describes the queue's behavior.  A label ': peek' is present next to the bottom box, indicating the ability to view the front element without removal. On the right, a stack is shown similarly, with identical numbered boxes but arranged identically.  The top box is labeled 'top:', and a curved cyan arrow labeled 'push' points from the left to the top box, showing element addition. A curved magenta arrow labeled 'pop' points from the top box to the right, illustrating element removal. The text 'first-in-last-out' describes the stack's behavior. A label ': peek' is also present next to the top box, indicating the ability to view the top element without removal.  Both diagrams visually demonstrate the difference in how elements are added and removed in queues (FIFO) and stacks (LIFO).

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.

Consider the following stack where we push values 1, 2, and 3 to it after receiving enqueue(1), enqueue(2), and enqueue(3):

Image represents a visual demonstration of implementing a queue data structure using a stack.  The image is divided into three sections, each labeled 'enqueue(n):' where 'n' represents the enqueue operation number (1, 2, and 3). Each section shows a stack represented as a vertical rectangle with a rounded bottom, labeled 'stack' at the bottom.  The top element of each stack is indicated by 'top:'.  A light gray 'top:' label precedes the top element's value.  In each section, a light blue arrow labeled 'push(n)' shows the element 'n' being pushed onto the stack.  The first section shows an empty stack initially, then the number 1 is pushed onto the stack after the enqueue(1) operation. The second section shows the stack after enqueue(2), with 1 at the bottom and 2 at the top.  The third section shows the stack after enqueue(3), with 1 at the bottom, 2 in the middle, and 3 at the top.  The arrangement demonstrates how elements are added to the stack (using push operations) to simulate the enqueue operation of a queue, where elements are added at the rear and removed from the front.

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:

Image represents a step-by-step visualization of a stack data structure's pop operation.  Three diagrams show the stack's state at different points.  Each diagram features a stack represented as a container with numbered elements (1, 2, 3) stacked vertically, with the top element indicated by 'top:'.  A 'temp' array below each stack shows the elements popped so far.  In the first diagram, a purple arrow labeled 'pop' shows the top element (3) being removed from the stack and added to the 'temp' array, which now contains [3]. The second diagram shows the next pop operation, with the top element (2) being removed and added to 'temp', resulting in [3, 2]. The third diagram shows the final pop operation, where the top element (1) is removed and the operation is labeled 'pop and return' in green, indicating the value is returned from the function. The 'temp' array remains [3, 2] as the stack is now empty.

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:

Image represents a step-by-step visualization of a stack data structure's behavior during a `push` operation.  The image shows two stages.  The leftmost stage depicts a stack labeled 'stack' containing the element '2', with 'top' indicating its position. A light-blue arrow labeled `push(2)` points to the stack, illustrating the addition of '2'. Below the stack, a dashed-line box displays a temporary variable `temp` holding the array `[3, 2]`. The next stage shows the stack after pushing the element '3' onto it. The element '3' is now at the top, with '2' below it. A light-blue arrow labeled `push(3)` shows the addition of '3'. The `temp` variable now holds `[3]`, reflecting the removal of '2' from the temporary storage.  The overall image demonstrates how the `push` operation adds elements to the top of the stack and how a temporary variable might be used to manage data during this process.

We know that if we were to use a data structure such as temp, it’d have to be a stack, since 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 around this, let’s have a closer look at the state of our two stacks right after we’ve moved the stack values to temp:

Image represents a step-by-step illustration of a stack manipulation process.  Initially, a stack labeled 'stack' contains elements 1, 2, and 3, with 3 at the top (indicated by 'top:').  Two purple arrows labeled 'pop' show the top two elements (3 and 2) being removed from the stack and transferred to a second stack labeled 'temp'.  The element 3 is moved to the bottom of the 'temp' stack, and the element 2 is moved to the top. A large black arrow indicates a transition to the next step. In this next step, the 'stack' now only contains element 1, labeled 'top:'. A green arrow labeled 'pop and ruturn' shows element 1 being popped from the stack. Finally, the 'temp' stack now contains elements 2 and 3, with 2 at the top (indicated by 'top:').  The entire process demonstrates a sequence of pop operations from one stack to another, reversing the order of the elements.

In our original 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 next value we expect to return. 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 moving 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.

Image represents a visual depiction of enqueue operation using two stacks.  The left side shows an 'enqueue_stack' represented as a simple container with the number '1' inside, labeled 'top: 1'. A light-blue arrow labeled 'push(1)' points to the '1', indicating that the value '1' is being pushed onto the enqueue_stack. Above this stack, the text 'enqueue(1):' indicates that this is the visual representation of an enqueue operation with the value 1. The right side shows an empty 'dequeue_stack', also represented as a similar container, illustrating the state of the dequeue stack before any operation. The overall image demonstrates the step of adding an element (1) to the enqueue stack during a queue implementation using two stacks.
Image represents a visual depiction of an enqueue operation using two stacks.  The left side shows an 'enqueue_stack' containing the elements '1' and '2', with '2' at the top, indicated by the label 'top:'. A light-blue arrow labeled 'push(2)' points from the right to the left, illustrating the addition of the element '2' to the top of the `enqueue_stack`. The right side displays an empty 'dequeue_stack'. The overall image demonstrates a step in implementing a queue data structure using two stacks, where the `enqueue` operation involves pushing the new element onto the `enqueue_stack`.  The text 'enqueue(2):' above the left stack clarifies that this diagram shows the result of enqueuing the value 2.

Now, let's try processing a dequeue call. The first step is to pop off each element from the enqueue stack and push them onto the dequeue stack:

Image represents a visualization of a dequeue operation implemented using two stacks, labeled 'enqueue_stack' and 'dequeue_stack'.  The top-left diagram shows 'enqueue_stack' containing elements '1' and '2', with '2' at the top. A purple arrow labeled 'pop' indicates the removal of '2' from the top of 'enqueue_stack'. The bottom-left diagram shows the resulting 'enqueue_stack' after the pop operation, now containing only '1'. The top-right diagram shows 'dequeue_stack' initially empty, with a light-blue arrow labeled 'push(2)' illustrating the addition of '2' to the top of 'dequeue_stack'. Finally, the bottom-right diagram shows 'dequeue_stack' after a 'push(1)' operation (indicated by a light-blue arrow), resulting in a stack with '2' at the bottom and '1' at the top.  The overall image demonstrates the process of dequeuing an element using two stacks: elements are added to the 'enqueue_stack' and then transferred to the 'dequeue_stack' to simulate the FIFO (First-In, First-Out) behavior of a queue.  The 'top:' label consistently indicates the top element of each stack.

Then, we just return the top value from the dequeue stack:

The image represents a visual depiction of a dequeue operation on a stack data structure.  The left side shows an empty 'enqueue_stack,' represented as a simple, empty rectangular container with rounded bottom corners and labeled accordingly. The right side depicts a 'dequeue_stack' containing two elements, '1' and '2', with '1' highlighted in a light green circle and labeled as 'top:'. A green arrow originates from the highlighted '1' and points to the text 'pop and return,' indicating that the top element ('1') is being removed (popped) from the stack and returned as the result of the dequeue operation.  The arrangement clearly shows the before and after states of the stack, illustrating the Last-In, First-Out (LIFO) nature of the stack data structure during a dequeue operation.

Let’s enqueue one more value:

Image represents a visual depiction of a queue implementation using two stacks, labeled 'enqueue_stack' and 'dequeue_stack'.  The left stack, 'enqueue_stack,' is initially empty. The operation `enqueue(3)` is shown, indicating the addition of the element '3' to the queue. This is visually represented by the number '3' appearing at the bottom of the 'enqueue_stack' with a label 'top:' indicating its position. A cyan-colored arrow labeled `push(3)` points from the right side of the 'enqueue_stack' to the number '3', illustrating that the element '3' is pushed onto the 'enqueue_stack' using a `push()` operation. The right stack, 'dequeue_stack,' contains the element '2' at its bottom, labeled with 'top:', showing the current top element of this stack.  The two stacks are distinct and independent, illustrating a common technique for implementing a queue data structure using stacks.

If we call dequeue again, we return the value from the top of the dequeue stack:

Image represents a visual depiction of the `dequeue()` operation on a stack data structure.  The image shows two diagrams side-by-side. The left diagram, labeled 'enqueue_stack,' depicts a stack containing the integer value '3' at its top.  The right diagram, labeled 'dequeue_stack,' shows the same stack after the `dequeue()` operation has been performed.  In the 'dequeue_stack' diagram, the value '2' is highlighted with a light green circle at the top of the stack. A green arrow originates from this highlighted '2' and points to the right, with the text 'pop and return' indicating that the top element ('2') is popped from the stack and returned as the result of the `dequeue()` function call.  The label 'top:' is present in both diagrams, indicating the top element of the stack.  The overall image illustrates the process of removing and returning the top element from a stack, a fundamental operation in stack-based data structures.

Now, what happens when we call dequeue and the dequeue stack is empty? We need to repopulate it by popping all the values from the enqueue stack and pushing them into the dequeue stack. Once this is done, we return the top of the dequeue stack as usual:

Image represents a visualization of a dequeue operation using two stacks, labeled 'enqueue_stack' and 'dequeue_stack'.  The top-left shows an 'enqueue_stack' with the value '3' at its top, indicated by a 'top:' label. A purple arrow labeled 'pop' shows the value '3' being moved from the 'enqueue_stack' to the 'dequeue_stack' in the top-right. The top-right shows the 'dequeue_stack' receiving the value '3' via a cyan arrow labeled 'push(3)', with 'top:' indicating its position. The bottom-left shows the 'enqueue_stack' after the pop operation, now empty. Finally, the bottom-right depicts the 'dequeue_stack' with '3' at its top (indicated by 'top:'), and a green arrow labeled 'pop and return' shows this value being popped and returned as the result of the dequeue operation.  The overall diagram illustrates the process of dequeuing an element using two stacks: first, elements are pushed onto the 'enqueue_stack', and then, when a dequeue is requested, elements are moved from the 'enqueue_stack' to the 'dequeue_stack' one by one, and finally popped from the 'dequeue_stack' to be returned.

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 common logic between these functions for transferring values from the enqueue stack to the dequeue stack has been extracted into a separate function, transfer_enqueue_to_dequeue.

python
class Queue:
    def __init__(self):
        self.enqueue_stack = []
        self.dequeue_stack = []

    def enqueue(self, x: int) -> None:
        self.enqueue_stack.append(x)

    def transfer_enqueue_to_dequeue(self) -> None:
        # If the dequeue stack is empty, push all elements from the enqueue stack
        # onto the dequeue stack. This ensures the top of the dequeue stack
        # contains the most recent value.
        if not self.dequeue_stack:
            while self.enqueue_stack:
                self.dequeue_stack.append(self.enqueue_stack.pop())

    def dequeue(self) -> int:
        self.transfer_enqueue_to_dequeue()
        # Pop and return the value at the top of the dequeue stack.
        return self.dequeue_stack.pop() if self.dequeue_stack else None

    def peek(self) -> int:
        self.transfer_enqueue_to_dequeue()
        return self.dequeue_stack[-1] if self.dequeue_stack else None

Complexity Analysis

Time complexity: The time complexity of:

  • enqueue is O(1)O(1)O(1) because we add one element to the enqueue stack in constant time.
  • dequeue is amortized O(1)O(1)O(1). In the worst case, all elements from the enqueue stack are moved to the dequeue stack. This takes O(n)O(n)O(n) time, where nnn denotes the number of elements in the enqueue queue. However, each element is only ever moved once during its lifetime. So, over nnn dequeue calls, at most nnn elements are moved between stacks, averaging the cost to O(1)O(1)O(1) time per dequeue operation.
  • peek is amortized O(1)O(1)O(1) for the same reasons as dequeue.

Space complexity: The space complexity is O(n)O(n)O(n) since we maintain two stacks that collectively store all elements of the queue at any given time.

Maximums of Sliding Window

Maximums of Sliding Window

There's a sliding window of size k that slides through an integer array from left to right. Create a new array that records the largest number found in each window as it slides through.

Example:

Image represents a step-by-step illustration of finding the maximum value within an array of integers.  Four rows depict the array `[3, 2, 4, 1, 2, 1, 1]`. In each row, a light-blue rectangular highlight progressively moves across the array, indicating the current element being considered as the potential maximum.  To the right of each array, 'max = ' is followed by the maximum value found so far during that step. The first three rows show the highlight moving from the leftmost element (3) to the element 4, correctly identifying 4 as the maximum. The final row shows the highlight moving to the last element (1) after 4 has already been identified as the maximum, resulting in a final maximum value of 4.  The image visually demonstrates a linear scan algorithm for finding the maximum element in an array.
python
Input: nums = [3, 2, 4, 1, 2, 1, 1], k = 4
Output: [4, 4, 4, 2]

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 O(n⋅k)O(n\cdot k)O(n⋅k) time because we traverse kkk elements for up to nnn 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.

Image represents two nearly identical arrays of integers, each enclosed in square brackets.  The top array is `[3, 2, 4, 1, 2, 1, 1]` and the bottom array is `[3, 2, 4, 1, 2, 1, 1]`. A light-blue rectangular box highlights the sub-array `[2, 4, 1]` in both arrays, indicating that these elements have the same values in both arrays. The text 'same values' is positioned within the central area of the box, explicitly labeling the highlighted section.  The arrays are horizontally aligned, with the highlighted sections overlapping vertically.  No URLs or parameters are present.

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 only maintain a record of values that have the potential to become the maximum of a future window. Let's call these values candidates, where all the 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:

Image represents a visual depiction of data partitioning or splitting.  Two rectangular boxes labeled 'left' and 'right' are positioned above a horizontal array of numbers: [3, 2, 4, 1, 2, 1, 1].  Arrows descend from 'left' and 'right' pointing to the number array. The numbers 3, 2, 4, and 1 are highlighted with a light blue background, indicating that this section of the array is associated with the 'left' box. The remaining numbers (2, 1, 1) are not highlighted and are implicitly associated with the 'right' box.  The diagram illustrates how a data set (the array of numbers) is divided into two subsets, 'left' and 'right,' based on some unspecified criteria.

To identify the candidates for the next window, let's look at each number individually.

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:

Image represents a sliding window algorithm visualization.  The initial state shows a numerical array `nums = [2 5 4 3 2 1 3]` where a light-blue rectangle highlights a window encompassing the elements [2 5 4 3 2 1]. A thick grey arrow labeled 'slide window' indicates a rightward shift of this window.  The resulting state shows a new window [2 5 4 3 2 1 3] where the element '2' is no longer in the window (indicated by text 'no longer in window' and a downward arrow pointing to the new window), and a new element '3' has entered the window (indicated by text 'new candidate' and a downward arrow pointing to the '3' within the new window). The '3' in the new window is highlighted with a light grey circle to emphasize its addition.  The overall diagram illustrates a single iteration of a sliding window technique, showing how the window moves across the array, adding and removing elements.
Image represents a step-by-step process demonstrating a coding pattern, likely related to candidate selection or filtering.  It begins with an initial list of candidates: `[5 4 3 2 1]`.  Step 1, labeled 'remove values ≤ new candidate (3)', shows the removal of values 3, 2, and 1 from the list, resulting in `[5 4]`.  A red line visually connects the removed elements. Step 2, 'add the new candidate', inserts the value '3' (in orange) into the list, producing `[5 4 3]`. Finally, step 3, 'remove outdated values', removes the value 5 (indicated by a red line), leaving the final list as `[4 3]`.  The process illustrates a sequence of operations involving removing elements based on a condition, adding a new element, and then potentially removing further elements based on another criteria, possibly to maintain a specific list size or order.

An important observation is that candidate values always maintain a decreasing order. This is because each new candidate we encounter removes smaller and equal candidates to its left, ensuring the list of candidates is kept in a decreasing order.

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 O(1)O(1)O(1) time.

Despite its name, it's easier to think of a deque as a double-ended stack:

Image represents a comparison of stack and deque data structures.  The left side depicts a stack, labeled 'stack:', showing a linear array represented by `[• • • • • •]`.  A cyan-colored arrow labeled 'push' indicates that new elements are added to the right end.  Conversely, a purple arrow labeled 'pop' shows that elements are removed from the right end (LIFO - Last In, First Out). The right side illustrates a deque, labeled 'deque:', also represented by a linear array `[• • • • • •]`.  However, unlike the stack, the deque has two cyan arrows indicating 'push to left' and 'push to right,' signifying that elements can be added to either end.  Similarly, two purple arrows, 'pop from left' and 'pop from right,' demonstrate that elements can be removed from both the left and right ends (FIFO - First In, First Out from one end, and LIFO from the other).  The overall image visually contrasts the unidirectional nature of stack operations with the bidirectional capabilities of deque operations.

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.

Image represents a depiction of a queue data structure, showing its initial state and an empty dequeued queue.  A numerical array `[3, 2, 4, 1, 2, 1, 1]` is presented, representing the elements of the queue.  Beneath each element, a numerical index (0 to 6) indicates its position within the queue.  A comma separates the queue array from the representation of the dequeued queue, denoted as `dq = []`, which is an empty array, signifying that no elements have been removed from the original queue yet.  The arrangement visually displays the queue's contents and its index-based organization, with the `dq` variable clearly showing the absence of dequeued elements.

Start by expanding the window until it's of length k. For each candidate we encounter, we need to ensure the values in the deque maintain a monotonic decreasing order before pushing it in:

Image represents a visual explanation of a coding pattern, likely involving a deque (double-ended queue) data structure.  At the top, two rectangular boxes labeled 'left' and 'right' point downwards with arrows towards an array `[3, 2, 4, 1, 2, 1, 1]`.  The number 3 at index 0 is highlighted in light blue, indicating a focus on this element.  To the right, two separate, light-grey, dashed-bordered boxes describe operations: the top box states '1) pop candidates ≤ 3', suggesting elements from the array are popped (removed) until a condition (candidates ≤ 3) is met. The bottom box describes '2) push 3 with its index', indicating that the value 3 (and potentially its index 0) is added to another data structure.  Finally, an equation `dq = [(3, 0)]` is shown, where `dq` likely represents the deque, and `[(3, 0)]` shows the deque's content after the operations, containing the value 3 paired with its original index 0.  The `val` and `index` labels below clarify the tuple's structure within the deque.  The entire diagram illustrates a step-by-step process of manipulating an array and updating a deque based on a specific condition.
Image represents a visual explanation of a coding pattern, likely involving a deque (double-ended queue) data structure.  The top-left shows two labeled boxes, 'left' and 'right,' pointing downwards to an array `[3, 2, 4, 1, 2, 1, 1]`.  The numbers 0 through 6 underneath the array represent their indices.  The numbers 3 and 2 are highlighted in light blue, indicating they are initial candidates. Two rectangular boxes on the right describe a two-step process: 1) 'pop candidates ≤ 2,' suggesting elements with values less than or equal to 2 are removed from the array; and 2) 'push 2 with its index,' indicating that the value 2 and its index (1) are added to a new data structure.  The result of this process is shown as `dq = [(3, 0), (2, 1)]`, where `dq` represents the deque, and each tuple contains a value and its original index from the input array.  An arrow labeled 'decreasing values' points to the right, indicating that the deque is ordered by decreasing values.

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.

Image represents a visual explanation of a coding pattern, likely involving a deque (dq) data structure and a sorting or searching algorithm.  The top left shows an array `[3, 2, 4, 1, 2, 1, 1]` with indices 0-6.  Arrows labeled 'left' and 'right' point to the elements 3 and 4 respectively, suggesting these are initial boundary points.  A dashed box to the right describes a 'pop' operation (step 1) where elements are removed if their value is less than or equal to 4.  To the right of this, the deque `dq` is initialized as `[(3, 0), (2, 1)]`, representing value-index pairs.  Below, another dashed box describes a 'push' operation (step 2) where the element 4 (at index 2) is added to the deque.  The subsequent `dq` becomes `[(4, 2)]`.  Red arrows and inequalities (4 > 2, 4 > 3) illustrate comparisons between the current element being considered (4) and elements already in the deque, indicating a process of maintaining a sorted or prioritized subset within the deque.  The labels 'val' and 'index' clearly denote the components of each pair within the deque.

The next expansion of the window will set it at the expected fixed size of k:

Image represents a visualization of a coding pattern, likely involving a deque (double-ended queue) data structure.  The top-left shows an array `[3, 2, 4, 1, 2, 1, 1]` with a pointer `k` positioned at index 2, indicating a potential split point.  Above the array, boxes labeled 'left' and 'right' suggest the array is being processed from both ends. A light-blue highlight spans the array from index 0 to index 3, possibly representing a processed section.  The right side displays two labeled boxes describing steps in an algorithm: 1) 'pop candidates ≤ 1' implying elements less than or equal to 1 are removed, and 2) 'push 1 with its index' suggesting that the value 1 and its index are added to a data structure.  The result of this algorithm is shown as `dq = [(4, 2), (1, 3)]`, a deque containing pairs where the first element is the value and the second is its original index, arranged in decreasing order of values, as indicated by the arrow labeled 'decreasing values'.  The `dq` represents the output after processing the array according to the described steps.

Now that the window is of length k, it's time to begin recording the maximum value of each window. As mentioned earlier, the maximum value is the first value in the candidate list (i.e., the leftmost value of the deque.) The maximum value of this window is 4 since it's the leftmost candidate value.

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:

Image represents a visualization of a coding pattern, likely involving a deque (dq) data structure and a sliding window technique.  The top-left shows an array `[3, 2, 4, 1, 2, 1]` with indices 0-6. Arrows labeled 'left' and 'right' point to elements 2 and 2 respectively, indicating a window's boundaries. Three numbered, dashed-bordered boxes describe the algorithm's steps: 1) 'pop candidates ≤ 2' which, alongside the right-hand side, shows an initial deque `dq = [(4, 2), (1, 3)]` where each pair represents (value, index) and the element (1,3) is removed because its value is less than or equal to 2; 2) 'push 2 with its index' resulting in `dq = [(4, 2), (2, 4)]`; and 3) 'remove outdated candidates,' which is not explicitly shown in the deque but implies further processing. The right-hand side illustrates the deque's evolution, with a red arrow highlighting the removal of (1,3) and the final deque showing pairs ordered by decreasing values, indicating the algorithm maintains a sorted deque of candidates within the sliding 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.

Image represents a step-by-step illustration of a coding pattern, likely involving a deque (double-ended queue) data structure.  The top shows an array `[3, 2, 4, 1, 2, 1]` with indices 0-6. Arrows labeled 'left' and 'right' point to the element '4' and '1' respectively, indicating a focus on a sub-array. Below, three numbered steps describe operations on a deque (`dq`). Step 1) 'pop candidates ≤ 1' suggests removing elements from the deque with values less than or equal to 1. Step 2) 'push 1 with its index' indicates adding the element '1' along with its index (in this case, index 5) to the deque.  The result of step 2 is shown as `dq = [(4, 2), (2, 4), (1, 5)]`, where each tuple represents (value, index) pairs, ordered by decreasing values as indicated by the arrow. Finally, step 3) 'remove outdated candidates' implies removing elements from the deque that are no longer relevant based on the algorithm's logic. The entire diagram visualizes the process of maintaining a deque of candidates, ordered by decreasing values, within a specific algorithm.

The maximum value of the above window after the three operations are performed is also 4, as it's the leftmost candidate value.

Image represents a step-by-step illustration of a coding pattern, likely involving a deque (dq) data structure and a sliding window or similar algorithm.  The top shows an array `[3, 2, 4, 1, 2, 1, 1]` with indices 0-6, and arrows pointing from labels 'left' and 'right' to the array's element at index 3 and 6 respectively.  Below, three numbered steps are shown, each accompanied by a description and the evolution of the deque `dq`. Step 1, 'pop candidates ≤ 1', shows `dq` initially containing pairs (value, index) representing elements from the array: `[(4, 2), (2, 4), (1, 5)]`.  A red arrow indicates the removal of `(1, 5)` because its value (1) meets the condition. Step 2, 'push 1 with its index', adds `(1, 6)` to `dq`, resulting in `[(4, 2), (2, 4), (1, 6)]`.  The arrow indicates the deque is ordered by decreasing values. Step 3, 'remove outdated candidates', removes `(4,2)` from `dq` because its index (2) is less than the current `left` pointer (indicated by a red arrow pointing to `(4,2)` and the inequality `2 < left`). The final `dq` is `[(4, 2), (2, 4), (1, 6)]` after this step.  The overall process demonstrates how the deque maintains a subset of the array's elements based on value and index, dynamically adapting to the movement of the `left` and `right` pointers.

Before recording the maximum value of this window, we ensured 4 was removed from the deque because its index (index 2) occurs before the left pointer (index 3), indicating it's no longer within the current window. The maximum value for this window after this removal is revealed to be 2.

Implementation

python
from typing import List
from collections import deque
    
def maximums_of_sliding_window(nums: List[int], k: int) -> List[int]:
    res = []
    dq = deque()
    left = right = 0
    while right < len(nums):
        # 1) Ensure the values of the deque maintain a monotonic decreasing order
        # by removing candidates <= the current candidate.
        while dq and dq[-1][0] <= nums[right]:
            dq.pop()
        # 2) Add the current candidate.
        dq.append((nums[right], right))
        # If the window is of length 'k', record the maximum of the window.
        if right - left + 1 == k:
            # 3) Remove values whose indexes occur outside the window.
            if dq and dq[0][1] < left:
                dq.popleft()
            # The maximum value of this window is the leftmost value in the
            # deque.
            res.append(dq[0][0])
            # Slide the window by advancing both 'left' and 'right'. The right
            # pointer always gets advanced so we just need to advance 'left'
            left += 1
        right += 1
    return res

Complexity Analysis

Time complexity: The time complexity of maximums_of_sliding_window is O(n)O(n)O(n) 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.

Space complexity: The space complexity is O(k)O(k)O(k) because the deque can store up to kkk elements.

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.

Chapter 8

Heaps

~9 min read

Introduction to Heaps

Introduction to Heaps

Intuition

A heap is a data structure that organizes elements based on priority, ensuring the highest-priority element is always at the top of the heap. This allows for efficient access to the highest-priority element at any time. There are two main types of heaps:

  • Min-heap: prioritizes the smallest element by keeping it at the top of the heap.
  • Max-heap: prioritizes the largest element by keeping it at the top of the heap.
Image represents a comparison of a min-heap and a max-heap data structures.

Efficient prioritization is possible due to how heaps are structured. A heap is essentially a binary tree. In the case of a min-heap, for example, each node's value is less than or equal to that of its children. This guarantees the root of this tree (the top of the heap) is always the smallest element:

Image represents a min-heap data structure as a binary tree.

Here's a time complexity breakdown of common heap operations:

...heap operations table...

This chapter discusses the practical uses of heaps. For a deeper understanding of how a heap works, we recommend diving into the details behind its internal implementation, and how its binary tree structure is consistently maintained during various operations [2].

Priority queue: A priority queue is a special type of heap that follows the structure of min-heaps or max-heaps but allows customization in how elements are prioritized.

Real-world Example

Managing tasks in operating systems: Operating systems often use a priority queue to manage the execution of tasks, and a heap is commonly used to implement this priority queue efficiently.

For example, when multiple processes are running on a computer, each process might be assigned a priority level. The operating system needs to schedule the processes so that higher-priority tasks are executed before lower-priority ones.

Chapter Outline

Hierarchical diagram illustrating the applications of Heaps data structure.

K Most Frequent Strings

K Most Frequent Strings

Find the k most frequently occurring strings in an array, and return them sorted by frequency in descending order. If two strings have the same frequency, sort them in lexicographical order.

Example:

python
Input: strs = ['go', 'coding', 'byte', 'byte', 'go', 'interview', 'go'], k = 2
Output: ['go', 'byte']

Explanation: The strings "go" and "byte" appear the most frequently, with frequencies of 3 and 2, respectively.

Constraints:

  • k ≤ n, where n denotes the length of the array.

Intuition - Max-Heap

The two main challenges to this problem are:

  1. Identifying the k most frequent strings.
  2. Sorting those strings first by frequency and then lexicographically.

First, we need a way to keep track of the frequencies of each string. We can use a hash map for this, where the keys represent the strings and the values represent frequencies.

Data transformation: list of strings converted into a frequency table.

The most straightforward approach is to obtain an array containing the strings from the hash map, sorted by frequency in descending order. The k most frequent strings would be the first k strings in this array.

Data processing flow demonstrating the selection of the k most frequent elements.

The main inefficiency of this solution is that it involves sorting all n strings, even though we only need the top k frequent ones to be sorted.

Something useful to consider: if we remove the most frequent string, the new most frequent string after this removal represents the second-most frequent overall. By repeatedly identifying and removing the most frequent string k times, we efficiently obtain our answer.

To implement this idea, we need a data structure that allows efficient access to the most frequent string at any time. A max-heap is perfect for this.

Max-heap populated with all string-frequency pairs.

One way to populate the heap is to push all n strings into it one by one, which will take O(nlog(n)) time. Instead, we can perform the heapify operation on an array containing all the string-frequency pairs to create the max-heap in O(n) time.

To collect the k most frequent strings, pop off the top element from the heap k times and store the corresponding strings in the output array res:

Step-by-step visualization of a top-k frequent element algorithm with k=2.

Now, we just need to ensure that when two strings have the same frequency, the one that comes first lexicographically has a higher priority in the heap. To do this, we can define a custom comparator for the heap.

Implementation - Max-Heap

We create a Pair class for string-frequency pairs, enabling us to customize priority using a custom comparator.

python
from typing import List
from collections import Counter
import heapq
    
class Pair:
   def __init__(self, str, freq):
       self.str = str
       self.freq = freq
    
   def __lt__(self, other):
       if self.freq == other.freq:
           return self.str < other.str
       return self.freq > other.freq
    
def k_most_frequent_strings_max_heap(strs: List[str], k: int) -> List[str]:
   freqs = Counter(strs)
   max_heap = [Pair(str, freq) for str, freq in freqs.items()]
   heapq.heapify(max_heap)
   return [heapq.heappop(max_heap).str for _ in range(k)]

Complexity Analysis

Time complexity: O(n + k log(n)). Space complexity: O(n).

Intuition - Min-Heap

As a follow up, your interviewer may ask you to modify your solution to reduce the space used by the heap.

An important observation is that when our heap exceeds size k, we can discard the lowest frequency strings until the heap's size is reduced to k again. We can do this because those discarded strings definitely won't be among the k most frequent strings.

However, we can't implement this strategy with a max-heap because we won't have access to the lowest frequency string. Instead, we need to use a min-heap.

Min-heap approach: push each pair, pop when heap size exceeds k.
python
from typing import List
from collections import Counter
import heapq
    
class Pair:
    def __init__(self, str, freq):
        self.str = str
        self.freq = freq
    def __lt__(self, other):
        if self.freq == other.freq:
            return self.str > other.str
        return self.freq < other.freq
    
def k_most_frequent_strings_min_heap(strs: List[str], k: int) -> List[str]:
    freqs = Counter(strs)
    min_heap = []
    for str, freq in freqs.items():
        heapq.heappush(min_heap, Pair(str, freq))
        if len(min_heap) > k:
            heapq.heappop(min_heap)
    return [heapq.heappop(min_heap).str for _ in range(k)][::-1]

Complexity Analysis

Time complexity: O(n log(k)). Space complexity: O(n) for hash map, O(k) for heap.

Combine Sorted Linked Lists

Combine Sorted Linked Lists

Given k singly linked lists, each sorted in ascending order, combine them into one sorted linked list.

Intuition

A good place to start with this problem is by figuring out how to merge just two sorted linked lists. We can do this by initiating a pointer at the start of both linked lists. Comparing the nodes at these pointers, add the smaller one to the output linked list and advance the corresponding pointer.

But what if we have more than two linked lists? Combining two linked lists involves comparing two nodes at each iteration, but combining k linked lists would require k comparisons per iteration.

The reason we need to make so many comparisons is that we don't know which node has the smallest value at any point in the iteration, requiring us to search for it. A min-heap is perfect for this.

To start, populate the heap with the head nodes of all the linked lists, so they're ready for comparison.

Heap populated with head nodes of all k linked lists.

Then, let's implement our strategy of adding the smallest-valued node to the output linked list, using the heap to identify it. We'll use a dummy node to help build the output linked list. After a node is popped off, the subsequent node from its linked list is added to the heap.

Once the heap is empty, we can return dummy.next, which is the head of the combined linked list.

Implementation

python
from typing import List
from ds import ListNode
import heapq
    
def combine_sorted_linked_lists(lists: List[ListNode]) -> ListNode:
    ListNode.__lt__ = lambda self, other: self.val < other.val
    heap = []
    for head in lists:
        if head:
            heapq.heappush(heap, head)
    dummy = ListNode(-1)
    curr = dummy
    while heap:
        smallest_node = heapq.heappop(heap)
        curr.next = smallest_node
        curr = curr.next
        if smallest_node.next:
            heapq.heappush(heap, smallest_node.next)
    return dummy.next

Complexity Analysis

Time complexity: O(n log(k)), where n is total number of nodes. Space complexity: O(k).

Median of an Integer Stream

Median of an Integer Stream

Design a data structure that supports adding integers from a data stream and retrieving the median of all elements received at any point.

  • add(num: int) -> None: adds an integer to the data structure.
  • get_median() -> float: returns the median of all integers so far.

Example:

python
Input: [add(3), add(6), get_median(), add(1), get_median()]
Output: [4.5, 3.0]

Intuition

The median is always found in the middle of a sorted list of values. The challenge with this problem is that it's unclear how to keep all the values sorted as new values arrive.

We can use a combination of a min-heap and a max-heap:

  • A max-heap manages the left half, where the top value represents the first median value.
  • A min-heap manages the right half, where the top value represents the second median value.

If the total number of elements is odd, there's only one median — we designate the max-heap to store this median.

Two rules for managing the heaps:

  1. The maximum value of the max-heap (left half) must be less than or equal to the minimum value of the min-heap (right half).
  2. The heaps should be of equal size, but the max-heap can have one more element than the min-heap.

Implementation

python
import heapq
    
class MedianOfAnIntegerStream:
    def __init__(self):
        self.left_half = []   # max-heap (negated)
        self.right_half = []  # min-heap
        
    def add(self, num: int) -> None:
        if not self.left_half or num <= -self.left_half[0]:
            heapq.heappush(self.left_half, -num)
            if len(self.left_half) > len(self.right_half) + 1:
                heapq.heappush(self.right_half, -heapq.heappop(self.left_half))
        else:
            heapq.heappush(self.right_half, num)
            if len(self.right_half) > len(self.left_half):
                heapq.heappush(self.left_half, -heapq.heappop(self.right_half))
    
    def get_median(self) -> float:
        if len(self.left_half) == len(self.right_half):
            return (-self.left_half[0] + self.right_half[0]) / 2
        return float(-self.left_half[0])

Complexity Analysis

Time complexity of add: O(log(n)). Time complexity of get_median: O(1). Space complexity: O(n).

Sort a K-Sorted Array

Sort a K-Sorted Array

Given an integer array where each element is at most k positions away from its sorted position, sort the array in a non-decreasing order.

Example:

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

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.

For any index i, the element that belongs at index i in the sorted array is located within the range [i - k, i + k]. The value needed at index 0 is the smallest number within the range [0, 0 + k].

To improve this approach, we need a min-heap to efficiently access the minimum value at each range [i, i + k].

Min-heap approach for sorting k-sorted array.

Implementation

python
from typing import List
import heapq
        
def sort_a_k_sorted_array(nums: List[int], k: int) -> List[int]:
    min_heap = nums[:k+1]
    heapq.heapify(min_heap)
    insert_index = 0
    for i in range(k + 1, len(nums)):
        nums[insert_index] = heapq.heappop(min_heap)
        insert_index += 1
        heapq.heappush(min_heap, nums[i])
    while min_heap:
        nums[insert_index] = heapq.heappop(min_heap)
        insert_index += 1
    return nums

Complexity Analysis

Time complexity: O(n log(k)). Space complexity: O(k).

Chapter 9

Intervals

~10 min read

Introduction to Intervals

Introduction to Intervals

Intuition

An interval consists of two values: a start point and an end point. It represents a continuous segment on the number line that includes all values between these two points. It is often used to represent a line, time period, or a continuous range of values.

  • An interval's start point indicates where the interval begins.
  • An interval's end point indicates where the interval ends.
Image represents a simple horizontal timeline diagram illustrating a process or sequence.

Intervals can be closed, open, or half-open, based on whether their start or end points are included in the interval.

  • Closed intervals: Both the start and end points are included in the interval.
Image represents a line segment with thick, black endpoints labeled 3 and 8.
  • Open intervals: The start and end points are not included in the interval.
Image represents a simple line graph illustrating relationship between two data points 3 and 8.
  • Half-open intervals: Either the start or the end point is included, while the other is not.
Image represents two identical line segments showing range from 3 to 8.

When presented with an interval problem in an interview, It's important to clarify whether the intervals are open, closed, or half-open, as this can change the nature of how intervals overlap.

Overlapping intervals Two intervals overlap if they share at least one common value.

Image represents a visual depiction of an overlap between two intervals on a number line.

The central challenge in most interval problems involves managing overlapping intervals effectively. Whether identifying or merging overlapping intervals, it's important to determine how the overlap between intervals influences the desired outcome of the problem. The problems in this chapter involve handling overlapping intervals in varying situations.

Sorting intervals In most interval problems, sorting the intervals before solving the problem is quite helpful since it allows them to be processed in a certain order.

We usually sort intervals by their start point so they can be traversed in chronological order. When two or more intervals have the same start point, we might also need to consider each interval's end points during sorting.

Separating start and end points In certain scenarios, it might be beneficial to process the start and end points of intervals separately. This usually involves creating two sorted arrays: one containing all start points and another containing all end points. For example, this is needed in the sweeping line algorithm, which is explored in the Largest Overlap of Intervals problem.

Image represents a data structure illustrating the concept of intervals with start and end points.

Interval class definition For the problems in this chapter, intervals are represented using the class below.

python
class Interval:
   def __init__(self, start, end):
       self.start = start
       self.end = end

Real-world Example

Scheduling systems: Intervals are widely used in scheduling systems. For instance, in a conference room booking system, each booking is represented as an interval. The interval representation is used if the system requires functionality, such as determining the maximum number of overlapping bookings to ensure sufficient room availability. By analyzing these intervals, the system can efficiently allocate resources and prevent double bookings.

Chapter Outline

Image represents a hierarchical diagram illustrating two common coding patterns related to interval management.

Merge Overlapping Intervals

Merge Overlapping Intervals

Merge an array of intervals so there are no overlapping intervals, and return the resultant merged intervals.

Example:

Image represents a Gantt chart illustrating task scheduling.
python
Input: intervals = [[3, 4], [7, 8], [2, 5], [6, 7], [1, 4]]
Output: [[1, 5], [6, 8]]

Constraints:

  • The input contains at least one interval.
  • For every index i in the array, intervals[i].start ≤ intervals[i].end.

Intuition

There are two main challenges to this problem:

  1. Identifying which intervals overlap each other.
  2. Merging those intervals.

Let's start by tackling the first challenge. Consider two intervals, A and B, where interval A starts before B. Below, we visualize the case when these two intervals don't overlap:

Image represents a visualization of non-overlapping intervals on a number line.

The dashed line above shows that interval A ends before interval B starts, which eliminates the possibility of B overlapping A. This indicates that intervals A and B will never overlap when A.end < B.start.

Now, consider a couple of cases where these two intervals do overlap:

Image represents a diagram illustrating overlapping intervals (case 1).
Image represents a diagram illustrating overlapping intervals (case 2).

In these cases, we see that B starts before (or when) A ends (A.end ≥ B.start). In other words, some portion of B overlaps A since interval A hasn't ended before interval B starts. Therefore, intervals A and B overlap when A.end ≥ B.start.

We have now established the two cases that cover all overlapping and non-overlapping scenarios for two intervals, given interval A starts before interval B:

  • If A.end < B.start, the intervals don't overlap.
  • If A.end ≥ B.start, the intervals overlap.

To apply these conditions to any two intervals in the input, it's useful to have a way to identify which interval starts first. One idea is to sort the intervals by their start value, which will make it clear which one of each two adjacent intervals starts first.

Merging intervals With the above logic in mind, let's tackle an example. Consider the following intervals:

Image represents a sequence of five distinct arrays.

The first step is to sort these intervals by start value:

Image represents a diagram illustrating a sorting operation on a list of sub-lists.

To aid the explanation, let's represent the intervals visually:

Image represents a visualization of intervals on a number line.

Let's add/merge each interval into a new array called merged, starting with the first one, which we can add to the merged array straight away as it's the first interval:

Image represents a visual illustration of merging overlapping intervals - step 1.

After the first interval is added, we start the process of merging. Let's define A as the last interval in the merged array and B as the current interval in the input.

We notice B starts before A ends, indicating an overlap. So, let's merge them:

Image represents a visual explanation of interval merging - overlap condition.

When merging A and B, we use the leftmost start value and the rightmost end value between them.

Image represents a visual explanation of merging overlapping intervals - merge formula.

We can apply the same logic to the next interval:

Image represents overlap check for next interval.
Image represents merge result after processing interval.

When we reach the fourth interval, we notice B starts after A ends, indicating there is no overlap.

Image represents no overlap case - add B to merged list.

So, we just add it as a new interval to the merged array:

Image represents adding new interval to merged array.

The next interval, B, overlaps the last interval in the merged array, A, since B starts when A ends (A.end == B.start).

Image represents overlap when A.end == B.start.

So, let's merge A with B:

Image represents final merge result.

After processing the last interval, we've successfully merged all intervals.

Implementation

python
from typing import List
from ds import Interval
    
def merge_overlapping_intervals(intervals: List[Interval]) -> List[Interval]:
    intervals.sort(key=lambda x: x.start)
    merged = [intervals[0]]
    for B in intervals[1:]:
        A = merged[-1]
        if A.end < B.start:
            merged.append(B)
        else:
            merged[-1] = Interval(A.start, max(A.end, B.end))
    return merged

Complexity Analysis

Time complexity: O(nlog(n)) due to sorting. Merging itself takes O(n).

Space complexity: O(n) for Python's Tim sort.

Interview Tip

Tip: Visualize intervals to uncover logic and edge cases. Drawing examples helps your interviewer follow along with your reasoning.

Identify All Interval Overlaps

Identify All Interval Overlaps

Return an array of all overlaps between two arrays of intervals; intervals1 and intervals2. Each individual interval array is sorted by start value, and contains no overlapping intervals within itself.

Example:

Image represents a visualization of interval intersection.
python
Input: intervals1 = [[1, 4], [5, 6], [9, 10]],
       intervals2 = [[2, 7], [8, 9]]
Output: [[2, 4], [5, 6], [9, 9]]

Constraints:

  • For every index i in intervals1, intervals1[i].start < intervals1[i].end.
  • For every index j in intervals2, intervals2[j].start < intervals2[j].end.

Intuition

We're given two arrays of intervals, each containing non-overlapping intervals. This implies an overlap can only occur between an interval from the first array and an interval from the second array.

Identifying the overlap between two overlapping intervals We know from the Merge Overlapping Intervals problem that two intervals, A and B, overlap when A.end >= B.start, assuming A starts before B.

Image represents two Gantt chart-like diagrams illustrating the temporal relationship between two processes A and B.

To extract the overlap between these two overlapping intervals, we need to identify when it starts and ends.

  • The overlap starts at the furthest start point, which is always B.start.
  • The overlap ends at the earliest end point (min(A.end, B.end)).
Image represents two diagrams illustrating overlap calculation.

Therefore, when two intervals overlap, their overlap is defined by the range [B.start, min(A.end, B.end)].

Identifying all overlaps Now, let's return to the two arrays of intervals.

Image represents a visualization of two sets of intervals on a numerical scale.

Let's start by considering the first interval from each array:

Image represents a visualization of two sets of intervals with pointers i and j.

To check if these intervals overlap, we identify which interval starts first, assigning it as A and the other as B:

python
if intervals1[i].start <= intervals2[j].start:
    A, B = intervals1[i], intervals2[j]
else:
    A, B = intervals2[j], intervals1[i]
Image represents intervals1[i] starting first, labelled A.

A and B overlap when A.end >= B.start, which is true here. Let's record the overlap: [B.start, min(A.end, B.end)]:

Image represents a visualization of interval intersection result.

After identifying the overlap, advance the pointer of whichever interval ends first.

Image represents advancing pointer i after intervals1[i] ends before intervals2[j].

We've now identified a process that allows us to identify and record all overlaps while traversing the arrays of intervals:

  1. Set A as the interval that starts first, and B as the other interval.
  2. Check if A.end >= B.start to see if these intervals overlap. If they do, record the overlap as [B.start, min(A.end, B.end)].
  3. Whichever interval ends first, advance its corresponding pointer to move to the next interval.

Continue until either i or j have passed the end of their array.

Implementation

python
from typing import List
from ds import Interval
   
def identify_all_interval_overlaps(intervals1: List[Interval], intervals2: List[Interval]) -> List[Interval]:
   overlaps = []
   i = j = 0
   while i < len(intervals1) and j < len(intervals2):
       if intervals1[i].start <= intervals2[j].start:
           A, B = intervals1[i], intervals2[j]
       else:
           A, B = intervals2[j], intervals1[i]
       if A.end >= B.start:
           overlaps.append(Interval(B.start, min(A.end, B.end)))
       if intervals1[i].end < intervals2[j].end:
           i += 1
       else:
           j += 1
   return overlaps

Complexity Analysis

Time complexity: O(n+m) where n and m are the lengths of intervals1 and intervals2.

Space complexity: O(1).

Largest Overlap of Intervals

Largest Overlap of Intervals

Given an array of intervals, determine the maximum number of intervals that overlap at any point. Each interval is half-open, meaning it includes the start point but excludes the end point.

Example:

Image represents a horizontal number line illustrating overlapping intervals.
python
Input: intervals = [[1, 3], [5, 7], [2, 6], [4, 8]]
Output: 3

Constraints:

  • The input will contain at least one interval.
  • For every index i in the list, intervals[i].start < intervals[i].end.

Intuition

Think about what it means when x intervals overlap at a certain point in time. This means at this point, there are x 'active' intervals, where an interval is active if it has started but not ended.

In the example below, we see three active intervals at time 5:

Image represents a horizontal timeline showing three active intervals at time 5.

To find the number of active intervals at any point in time, we need to identify when an interval has started and when an interval has ended. The start point indicates the start of a new active interval, whereas an end point represents an active interval finishing.

Processing start points and end points Let's step through each point in chronological order and determine the number of active intervals at each point.

The first point is a start point, incrementing our counter:

Image represents sweeping line algorithm step 1.

The next point is another start point, incrementing the counter to 2:

Image represents sweeping line algorithm step 2 - active_intervals = 2.

The next point is an end point, decrementing the counter to 1:

Image represents sweeping line algorithm step 3 - active_intervals = 1.

We've rationalized what to do at each type of point:

  • If we encounter a start point: increment active_intervals.
  • If we encounter an end point: decrement active_intervals.

The final answer is obtained by recording the largest value of active_intervals encountered.

Edge case: processing concurrent start and end points When a start and end point occur simultaneously, we should process end points before start points to avoid counting an interval as active when it has just ended.

Image represents wrong order - processing start before end at same time.

If we process the end point first, we won't encounter this issue:

Image represents correct order - processing end before start at same time.

Therefore, for start and end points that occur simultaneously, process end points before start points.

Iterating over interval points in order Combine all start and end points into a single array, sort it, ensuring end points are prioritized before start points for equal values.

Image represents transformation of intervals into sorted points list with S/E labels.

The algorithm we used to solve this problem is known as a 'sweeping line algorithm.' It works by processing start and end points of intervals in order, as if a vertical line was sweeping across them.

Implementation

python
from typing import List
from ds import Interval
    
def largest_overlap_of_intervals(intervals: List[Interval]) -> int:
   points = []
   for interval in intervals:
       points.append((interval.start, 'S'))
       points.append((interval.end, 'E'))
   points.sort(key=lambda x: (x[0], x[1]))
   active_intervals = 0
   max_overlaps = 0
   for time, point_type in points:
       if point_type == 'S':
           active_intervals += 1
       else:
           active_intervals -= 1
       max_overlaps = max(max_overlaps, active_intervals)
   return max_overlaps

Complexity Analysis

Time complexity: O(n*log(n)) due to sorting the points array of size 2n.

Space complexity: O(n) due to the points array.

Chapter 10

Prefix Sums

~5 min read

Introduction to Prefix Sums

Introduction to Prefix Sums

Intuition

Imagine keeping track of how much money you spend on takeout meals each day over a period of days.

Image represents spendings list = [10, 15, 20, 10, 5] aligned with days Mon-Fri.

A prefix sum array maintains the running sum of values up to each index in the array. The total spent up until Wednesday is $45 ($10+$15+$20).

Image represents spendings [10,15,20,10,5] and prefix_sums [10,25,45,55,60] arrays aligned with Mon-Fri.

To obtain the prefix sum at each index, add the current number from the input array to the prefix sum from the previous index.

Image represents step-by-step calculation of prefix sums for array [10,15,20,10,5].

In code, the above process looks like this:

python
def compute_prefix_sums(nums):
    prefix_sum = [nums[0]]
    for i in range(1, len(nums)):
        prefix_sum.append(prefix_sum[-1] + nums[i])

Building a prefix sum array takes O(n) time and O(n) space, where n denotes the length of the array.

Applications of prefix sums Aside from constant-time access to running sums, prefix sums are commonly used to efficiently determine the sum of subarrays. Another interesting variant is prefix products, which populates an array with a running product instead of a running sum.

Real-world Example

Financial analysis: A real-world use of prefix sums is for financial analysis, particularly in calculating cumulative earnings or expenses over time. By precomputing the prefix sums, a company can instantly determine the revenue for any given period without summing each day's revenue individually.

Chapter Outline

Image represents a hierarchical diagram showing Prefix Sums branching into Subarray Sums (Sum Between Range, K-Sum Subarrays) and Product Sums (Product Array Without Current Element).

Sum Between Range

Sum Between Range

Given an integer array, write a function which returns the sum of values between two indexes.

Example:

Image represents three examples of sum_range function on [3, -7, 6, 0, -2, 5].
python
Input: nums = [3, -7, 6, 0, -2, 5],
       [sum_range(0, 3), sum_range(2, 4), sum_range(2, 2)]
Output: [2, 4, 6]

Constraints:

  • nums contains at least one element.
  • Each sum_range operation will query a valid range of the input array.

Intuition

We need to code a function sum_range(i, j). A naive solution iteratively sums from index i to j in linear time per call. Using prefix sums can improve efficiency.

Consider the integer array and its prefix sums:

Image represents nums [3,-7,6,0,-2,5] and prefix_sum [3,-4,2,2,0,5] arrays.

The prefix sum up to any index j gives the answer to sum_range(0, j). For example, the sum of range [0, 3] is prefix_sum[3].

Image represents prefix sum at index j = sum_range(0, j).

Therefore, when i == 0:

sum_range(0, j) = prefix_sum[j]

For range [2, 4], the sum equals prefix_sum[4] - prefix_sum[1]:

Image represents subarray [6, 0, -2] within [3,-7,6,0,-2,5].
Image represents sum[0:4] highlighted in the array.

The sum of range [2, 4] = prefix_sum[4] - prefix_sum[1] by subtracting the sum of [0, 1] from the sum of [0, 4].

Image represents sum[2:4] = sum[0:4] - sum[0:1] visualization.

Therefore, when i > 0:

sum_range(i, j) = prefix_sum[j] - prefix_sum[i - 1]

Implementation

python
from typing import List
    
class SumBetweenRange:
    def __init__(self, nums: List[int]):
        self.prefix_sum = [nums[0]]
        for i in range(1, len(nums)):
            self.prefix_sum.append(self.prefix_sum[-1] + nums[i])
    
    def sum_range(self, i: int, j: int) -> int:
        if i == 0:
            return self.prefix_sum[j]
        return self.prefix_sum[j] - self.prefix_sum[i - 1]

Complexity Analysis

Time complexity: Constructor is O(n). sum_range is O(1).

Space complexity: O(n) for the prefix_sum array.

K-Sum Subarrays

K-Sum Subarrays

Find the number of subarrays in an integer array that sum to k.

Example:

Image represents three subarrays of [1,2,-1,1,2] that sum to 3.
python
Input: nums = [1, 2, -1, 1, 2], k = 3
Output: 3

Intuition

The brute force solution iterates every possible subarray in O(n^3) time. Using prefix sums reduces this.

The sum of a subarray [i,j] = prefix_sum[j] - prefix_sum[i-1] (when i>0), or just prefix_sum[j] when i=0.

Image represents prefix sum formula for subarray sum.
Image represents prefix_sum[j] = sum[0:j].

We unify both cases by prepending 0 to the prefix sums array so prefix_sum[i-1]=0 when i=0 is valid.

Image represents prefix_sums [0,1,3,2,3,5] with leading 0 prepended.
python
from typing import List
    
def k_sum_subarrays(nums: List[int], k: int) -> int:
    n = len(nums)
    count = 0
    prefix_sum = [0]
    for i in range(0, n):
        prefix_sum.append(prefix_sum[-1] + nums[i])
    for j in range(1, n + 1):
        for i in range(1, j + 1):
            if prefix_sum[j] - prefix_sum[i - 1] == k:
                count += 1
    return count

This O(n^2) solution can be optimized using a hash map. For each prefix sum (curr_prefix_sum), find how many times curr_prefix_sum - k appeared previously.

Image represents initialization of prefix_sum_map = {0: 1} for k=3.

For each element, check if curr_prefix_sum - k is in the hash map. If found, add its frequency to count.

Image represents hash map lookup: curr_prefix_sum=1, k=3, complement=-2 not found.
Image represents curr_prefix_sum=3, complement=0 found, count+=1.

Strategy: 1) Update curr_prefix_sum. 2) If curr_prefix_sum-k in map, add frequency to count. 3) Add curr_prefix_sum to map.

Image represents step curr_prefix_sum=2, complement=-1 not found.
Image represents step curr_prefix_sum=3, complement=0 found count+=1.
Image represents step curr_prefix_sum=5, complement=2 found count+=1.

Implementation

python
from typing import List
    
def k_sum_subarrays_optimized(nums: List[int], k: int) -> int:
    count = 0
    prefix_sum_map = {0: 1}
    curr_prefix_sum = 0
    for num in nums:
        curr_prefix_sum += num
        if curr_prefix_sum - k in prefix_sum_map:
            count += prefix_sum_map[curr_prefix_sum - k]
        prefix_sum_map[curr_prefix_sum] = prefix_sum_map.get(curr_prefix_sum, 0) + 1
    return count

Complexity Analysis

Time complexity: O(n) - iterate through nums once.

Space complexity: O(n) for the hash map.

Product Array Without Current Element

Product Array Without Current Element

Given an array of integers, return an array res so that res[i] is equal to the product of all elements except nums[i] itself.

Example:

python
Input: nums = [2, 3, 1, 4, 5]
Output: [60, 40, 120, 30, 24]

Explanation: The output value at index 0 is the product of all numbers except nums[0] (3*1*4*5 = 60).

Intuition

The straightforward solution divides total product by each element (total=120). But what if division is not allowed?

Image represents total product 120 divided by each element to produce res=[60,40,120,30,24].

Without division, the output for any index = product of all numbers to its left * product of all numbers to its right.

Image represents res[i] = left_product * right_product visualization.

We need two arrays:

  • left_products: left_products[i] is the product of all values to the left of i.
  • right_products: right_products[i] is the product of all values to the right of i.
Image represents left_products [1,2,6,6,24] and right_products [60,20,20,5,1] calculated from nums.

Prefix products Prefix products use cumulative multiplication (initialized with 1 instead of 0).

  1. Instead of cumulative addition, we use cumulative multiplication.
  2. We initialize the prefix product array with 1 instead of 0.
Image represents left_products initialized with [1].
Image represents left_products = [1,2] after first multiplication.
Image represents left_products = [1,2,6] after second multiplication.
Image represents left_products = [1,2,6,6,1] partially filled.
Image represents left_products = [1,2,6,6,24] complete.

right_products is built similarly from right to left. Once both arrays are ready, res[i] = left_products[i] * right_products[i].

Image represents right_products initialization.
Image represents right_products first step [5,1].
Image represents right_products = [20,5,1] partially filled.
Image represents right_products = [20,20,5,1] partially filled.
Image represents right_products = [60,20,20,5,1] complete.

Reducing space: populate res with left products first, then multiply by running right_product in-place.

Image represents res populated with left products [1,2,6,6,24].
Image represents res multiplied by right_product from right to left.
Image represents res[i] = 6 * 20 = 120 at index 2.
Image represents res[i] = 2 * 20 = 40 at index 1.
Image represents final res = [60,40,120,30,24].

Implementation

python
from typing import List
    
def product_array_without_current_element(nums: List[int]) -> List[int]:
    n = len(nums)
    res = [1] * n
    for i in range(1, n):
        res[i] = res[i - 1] * nums[i - 1]
    right_product = 1
    for i in range(n - 1, -1, -1):
        res[i] *= right_product
        right_product *= nums[i]
    return res

Complexity Analysis

Time complexity: O(n) - iterate over nums twice.

Space complexity: O(1). The res array is not included in the space complexity analysis.

Chapter 11

Trees

~9 min read

Introduction to Trees

Introduction to Trees

Intuition

A tree is a hierarchical data structure composed of nodes, where each node connects to one or more child nodes. Each node in a tree contains the data it stores (val) and references to its child nodes. The most common type of tree is a binary tree, in which each node connects to up to two children: a left child and a right child.

Image represents a simple binary tree data structure, showing a single parent node labeled 'node' containing a value 'val,' and two child nodes, also labeled 'val,' connected to the parent.  The parent node is connected to its left child node, labeled 'node.left,' and its right child node, labeled 'node.right,' via downward-pointing arrows indicating a hierarchical relationship.  Each node contains a value, represented by 'val,' suggesting that the tree stores data within its nodes. The structure visually demonstrates the fundamental concept of a binary tree where each node can have at most two children, a left child and a right child.

Below is the implementation of the TreeNode class:

python
class TreeNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None

Terminology:

  • Parent: a node with one or more children.
  • Child: a node that has a parent.
  • Subtree: a tree formed by a node and its descendants.
  • Path: a single, continuous sequence of nodes connected by edges.
  • Depth: the number of edges from the root to a given node.

Attributes of a tree:

  • Root: the topmost node of the tree and the only node without a parent.
  • Intermediate node: a node with a parent node and at least one child.
  • Leaf: a node with no children.
  • Edge: the connection between two nodes. Trees usually have directed edges, meaning the edges only point from parent to child.
  • Height: the length of the longest path from the root to a leaf.1
Image represents a diagram illustrating tree data structures and their properties.

In the following discussions, we primarily focus on binary trees.

Tree Traversals

Depth-first search (DFS) DFS is a method for exploring all nodes of a tree by starting at the root and moving as far down a branch as possible, before backtracking to explore other branches.

Image represents a diagram illustrating Depth-First Search (DFS) traversal of a binary tree.

DFS is typically implemented recursively, following a structure similar to the code snippet below:

python
def dfs(node: TreeNode):
    if node is None:
        return
    process(node)       # Process the current node.
    dfs(node.left)      # Traverse the left subtree.
    dfs(node.right)     # Traverse the right subtree.

The above recursive implementation follows the order of preorder traversal. Two other common DFS traversal techniques include inorder traversal and postorder traversal. Here's how they differ:

Preorder traversalInorder traversalPostorder traversal
process(node)dfs(node.left)dfs(node.left)
dfs(node.left)process(node)dfs(node.right)
dfs(node.right)dfs(node.right)process(node)

DFS has many use cases and is the most common choice for tree traversal.

  • Preorder traversal is the most common type of DFS traversal. It's also used when we need to process the root node of each subtree before its children.
  • Inorder traversal is used when we want to process the nodes of a tree from left to right.
  • Postorder traversal is the least frequently used traversal method, but is important when each node's subtrees must be processed before their root.

The problems in this chapter explore the use cases of DFS in more detail, providing practical examples of when and how to apply these traversal methods.

Breadth-first search (BFS) BFS traverses the nodes of a tree level by level. It processes the nodes at the present level before moving on to nodes at the next depth level.

Image represents a Breadth-First Search (BFS) traversal of a tree-like graph.
python
def bfs(root: TreeNode):
    if root is None:
        return
    queue = deque([root])
    while queue:
        node = queue.popleft()
        process(node)  # Process the current node.
        if node.left:
            queue.append(node.left)  # Add the left child to the queue.
        if node.right:
            queue.append(node.right)  # Add the right child to the queue.

BFS is commonly used to find the shortest path to a specific destination in a tree, or to process the tree level by level. When it's important to know the specific level of each node during traversal, we use a variant of BFS called level-order traversal, which is discussed in detail in this chapter.

Complexity breakdown Below, n denotes the number of nodes in the tree, and h denotes the height of the tree.

[BLOCKED: Cookie/query string data]

Real-world Example

File systems: In many operating systems, the file system is organized as a hierarchical tree structure. The root directory is the root of the tree, and every file or folder in the system is a node. Folders can have subfolders or files as child nodes, and this structure allows for efficient organization, navigation, and retrieval of files. When you browse through folders on a computer, you're essentially navigating a tree structure.

Chapter Outline

Image represents a hierarchical diagram illustrating coding patterns related to trees.

Note that some of the problems listed under DFS can be solved using BFS or other traversal algorithms, too. The same applies to the problems under BFS.

Footnotes

  1. Some sources may define the height of a tree differently. In this book, we use a definition that makes designing recursive algorithms more intuitive. Here, the height of a tree with just one node is considered 1. ↩

Invert Binary Tree

Invert Binary Tree

Invert a binary tree and return its root. When a binary tree is inverted, it becomes the mirror image of itself.

Example:

Image represents a transformation of a binary tree.

Intuition - Recursive

To invert a binary tree is to essentially "flip" the tree along a vertical axis.

python
from ds import TreeNode
    
def invert_binary_tree_recursive(root: TreeNode) -> TreeNode:
    if not root:
        return None
    root.left, root.right = root.right, root.left
    invert_binary_tree_recursive(root.left)
    invert_binary_tree_recursive(root.right)
    return root

Implementation - Iterative

python
from ds import TreeNode
    
def invert_binary_tree_iterative(root: TreeNode) -> TreeNode:
    if not root:
        return None
    stack = [root]
    while stack:
        node = stack.pop()
        node.left, node.right = node.right, node.left
        if node.left:
            stack.append(node.left)
        if node.right:
            stack.append(node.right)
    return root

Balanced Binary Tree Validation

Balanced Binary Tree Validation

Determine if a binary tree is height-balanced, meaning no node's left subtree and right subtree have a height difference greater than 1.

python
from ds import TreeNode
    
def balanced_binary_tree_validation(root: TreeNode) -> bool:
    return get_height_imbalance(root) != -1
    
def get_height_imbalance(node: TreeNode) -> int:
    if not node:
        return 0
    left_height = get_height_imbalance(node.left)
    right_height = get_height_imbalance(node.right)
    if left_height == -1 or right_height == -1:
        return -1
    if abs(left_height - right_height) > 1:
        return -1
    return 1 + max(left_height, right_height)

Rightmost Nodes of a Binary Tree

Rightmost Nodes of a Binary Tree

Return an array containing the values of the rightmost nodes at each level of a binary tree.

python
from ds import TreeNode
    
def rightmost_nodes_of_a_binary_tree(root: TreeNode) -> List[int]:
    if not root:
        return []
    res = []
    queue = deque([root])
    while queue:
        level_size = len(queue)
        for i in range(level_size):
            node = queue.popleft()
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
            if i == level_size - 1:
                res.append(node.val)
    return res

Widest Binary Tree Level

Widest Binary Tree Level

Return the width of the widest level in a binary tree, where the width of a level is defined as the distance between its leftmost and rightmost non-null nodes.

python
from ds import TreeNode
    
def widest_binary_tree_level(root: TreeNode) -> int:
    if not root:
        return 0
    max_width = 0
    queue = deque([(root, 0)])
    while queue:
        level_size = len(queue)
        leftmost_index = queue[0][1]
        rightmost_index = leftmost_index
        for _ in range(level_size):
            node, i = queue.popleft()
            if node.left:
                queue.append((node.left, 2*i + 1))
            if node.right:
                queue.append((node.right, 2*i + 2))
            rightmost_index = i
        max_width = max(max_width, rightmost_index - leftmost_index + 1)
    return max_width

Binary Search Tree Validation

Binary Search Tree Validation

Verify whether a binary tree is a valid binary search tree (BST).

python
from ds import TreeNode
    
def binary_search_tree_validation(root: TreeNode) -> bool:
    return is_within_bounds(root, float('-inf'), float('inf'))
    
def is_within_bounds(node: TreeNode, lower_bound: int, upper_bound: int) -> bool:
    if not node:
        return True
    if not lower_bound < node.val < upper_bound:
        return False
    if not is_within_bounds(node.left, lower_bound, node.val):
        return False
    return is_within_bounds(node.right, node.val, upper_bound)

Lowest Common Ancestor

Lowest Common Ancestor

Return the lowest common ancestor (LCA) of two nodes, p and q, in a binary tree.

python
from ds import TreeNode
    
def lowest_common_ancestor(root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
    dfs(root, p, q)
    return lca
    
def dfs(node: TreeNode, p: TreeNode, q: TreeNode) -> bool:
    global lca
    if not node:
        return False
    node_is_p_or_q = node == p or node == q
    left_contains_p_or_q = dfs(node.left, p, q)
    right_contains_p_or_q = dfs(node.right, p, q)
    if node_is_p_or_q + left_contains_p_or_q + right_contains_p_or_q == 2:
        lca = node
    return node_is_p_or_q or left_contains_p_or_q or right_contains_p_or_q

Build Binary Tree From Preorder and Inorder Traversals

Build Binary Tree From Preorder and Inorder Traversals

Construct a binary tree using arrays of values obtained after a preorder traversal and an inorder traversal of the tree.

python
from ds import TreeNode
from typing import List
    
preorder_index = 0
inorder_indexes_map = {}
    
def build_binary_tree(preorder: List[int], inorder: List[int]) -> TreeNode:
    global inorder_indexes_map
    for i, val in enumerate(inorder):
        inorder_indexes_map[val] = i
    return build_subtree(0, len(inorder) - 1, preorder, inorder)
    
def build_subtree(left, right, preorder, inorder):
    global preorder_index, inorder_indexes_map
    if left > right:
        return None
    val = preorder[preorder_index]
    inorder_index = inorder_indexes_map[val]
    node = TreeNode(val)
    preorder_index += 1
    node.left = build_subtree(left, inorder_index - 1, preorder, inorder)
    node.right = build_subtree(inorder_index + 1, right, preorder, inorder)
    return node

Maximum Sum of a Continuous Path in a Binary Tree

Maximum Sum of a Continuous Path in a Binary Tree

Return the maximum sum of a continuous path in a binary tree.

python
from ds import TreeNode
    
max_sum = float('-inf')
    
def max_path_sum(root: TreeNode) -> int:
   global max_sum
   max_path_sum_helper(root)
   return max_sum
    
def max_path_sum_helper(node: TreeNode) -> int:
   global max_sum
   if not node:
       return 0
   left_sum = max(max_path_sum_helper(node.left), 0)
   right_sum = max(max_path_sum_helper(node.right), 0)
   max_sum = max(max_sum, node.val + left_sum + right_sum)
   return node.val + max(left_sum, right_sum)

Binary Tree Symmetry

Binary Tree Symmetry

Determine if a binary tree is vertically symmetric.

python
from ds import TreeNode
    
def binary_tree_symmetry(root: TreeNode) -> bool:
    if not root:
        return True
    return compare_trees(root.left, root.right)
    
def compare_trees(node1: TreeNode, node2: TreeNode) -> bool:
    if not node1 and not node2:
        return True
    if not node1 or not node2:
        return False
    if node1.val != node2.val:
        return False
    if not compare_trees(node1.left, node2.right):
        return False
    return compare_trees(node1.right, node2.left)

Binary Tree Columns

Binary Tree Columns

Given the root of a binary tree, return a list of arrays where each array represents a vertical column of the tree.

python
from ds import TreeNode
from typing import List
from collections import defaultdict, deque
    
def binary_tree_columns(root: TreeNode) -> List[List[int]]:
    if not root:
        return []
    column_map = defaultdict(list)
    leftmost_column = rightmost_column = 0
    queue = deque([(root, 0)])
    while queue:
        node, column = queue.popleft()
        if node:
            column_map[column].append(node.val)
            leftmost_column = min(leftmost_column, column)
            rightmost_column = max(rightmost_column, column)
            queue.append((node.left, column - 1))
            queue.append((node.right, column + 1))
    return [column_map[i] for i in range(leftmost_column, rightmost_column + 1)]

Kth Smallest Number in a Binary Search Tree

Kth Smallest Number in a Binary Search Tree

Given the root of a binary search tree (BST) and an integer k, find the kth smallest node value.

python
def kth_smallest_number_in_BST_iterative(root: TreeNode, k: int) -> int:
    stack = []
    node = root
    while stack or node:
        while node:
            stack.append(node)
            node = node.left
        node = stack.pop()
        k -= 1
        if k == 0:
            return node.val
        node = node.right

Serialize and Deserialize a Binary Tree

Serialize and Deserialize a Binary Tree

Write a function to serialize a binary tree into a string, and another function to deserialize that string back into the original binary tree structure.

python
from ds import TreeNode
    
def serialize(root: TreeNode) -> str:
    serialized_list = []
    preorder_serialize(root, serialized_list)
    return ','.join(serialized_list)
    
def preorder_serialize(node, serialized_list) -> None:
    if node is None:
        serialized_list.append('#')
        return
    serialized_list.append(str(node.val))
    preorder_serialize(node.left, serialized_list)
    preorder_serialize(node.right, serialized_list)
    
def deserialize(data: str) -> TreeNode:
    node_values = iter(data.split(','))
    return build_tree(node_values)
    
def build_tree(values):
    val = next(values)
    if val == '#':
        return None
    node = TreeNode(int(val))
    node.left = build_tree(values)
    node.right = build_tree(values)
    return node
Chapter 12

Tries

~3 min read

Introduction to Tries

Introduction to Tries

Intuition

Tries, also known as prefix trees, are specialized tree-like data structures that efficiently store strings by taking advantage of shared prefixes.

Directed graph illustrating a sequence 'internet'

TrieNode There are two attributes each TrieNode should have.

1. Children: Each TrieNode has a data structure to store references to its child nodes. A hash map is typically used for this, with a character as the key and its corresponding child node as the value.

2. End of word indicator: Each TrieNode needs an attribute to indicate whether it marks the end of a word.

  • A boolean attribute (is_word) to confirm if the node is the end of a word.
  • A string variable (word) that stores the word itself at the node.
Trie data structure with words: internet, interface, byte, bytes, dog, dig
[BLOCKED: Cookie/query string data]

When to use tries The primary use of a trie is to handle efficient prefix searches.

Real-world Example

Autocomplete: When you start typing a word, some systems use a trie to quickly suggest possible completions.

Chapter Outline

Design a Trie

Design a Trie

Design and implement a trie data structure that supports the following operations:

  • insert(word: str) -> None: Inserts a word into the trie.
  • search(word: str) -> bool: Returns true if a word exists in the trie, and false if not.
  • has_prefix(prefix: str) -> bool: Returns true if the trie contains a word with the given prefix, and false if not.

Implementation

python
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_word = False
python
class Trie:
   def __init__(self):
       self.root = TrieNode()
    
   def insert(self, word: str) -> None:
       node = self.root
       for c in word:
           if c not in node.children:
               node.children[c] = TrieNode()
           node = node.children[c]
       node.is_word = True
    
   def search(self, word: str) -> bool:
       node = self.root
       for c in word:
           if c not in node.children:
               return False
           node = node.children[c]
       return node.is_word
    
   def has_prefix(self, prefix: str) -> bool:
       node = self.root
       for c in prefix:
           if c not in node.children:
               return False
           node = node.children[c]
       return True

Insert and Search Words with Wildcards

Insert and Search Words with Wildcards

Design and implement a data structure that supports insert and wildcard search operations.

  • insert(word: str) -> None: Inserts a word into the data structure.
  • search(word: str) -> bool: Returns true if a word exists in the data structure and false if not. The word may contain wildcards ('.') that can represent any letter.

Implementation

python
class InsertAndSearchWordsWithWildcards:
   def __init__(self):
       self.root = TrieNode()
    
   def insert(self, word: str) -> None:
       node = self.root
       for c in word:
           if c not in node.children:
               node.children[c] = TrieNode()
           node = node.children[c]
       node.is_word = True
    
   def search(self, word: str) -> bool:
       return self.search_helper(0, word, self.root)
    
   def search_helper(self, word_index: int, word: str, node: TrieNode) -> bool:
       for i in range(word_index, len(word)):
           c = word[i]
           if c == '.':
               for child in node.children.values():
                   if self.search_helper(i + 1, word, child):
                       return True
               return False
           else:
               if c not in node.children:
                   return False
               node = node.children[c]
       return node.is_word

Find All Words on a Board

Find All Words on a Board

Given a 2D board of characters and an array of words, find all the words in the array that can be formed by tracing a path through adjacent cells in the board.

python
Input: board = [['b', 'y', 's'], ['r', 't', 'e'], ['a', 'i', 'n']],
       words = ['byte', 'bytes', 'rat', 'rain', 'trait', 'train']
Output: ['byte', 'bytes', 'rain', 'train']

Implementation

python
class TrieNode:
    def __init__(self):
        self.children = {}
        self.word = None
python
from typing import List
    
def find_all_words_on_a_board(board: List[List[str]], words: List[str]) -> List[str]:
    root = TrieNode()
    for word in words:
        node = root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.word = word
    res = []
    for r in range(len(board)):
        for c in range(len(board[0])):
            if board[r][c] in root.children:
                dfs(board, r, c, root.children[board[r][c]], res)
    return res
    
def dfs(board, r, c, node, res):
    if node.word:
        res.append(node.word)
        node.word = None
    temp = board[r][c]
    board[r][c] = '#'
    directions = [(0,1),(0,-1),(1,0),(-1,0)]
    for dr, dc in directions:
        nr, nc = r + dr, c + dc
        if 0 <= nr < len(board) and 0 <= nc < len(board[0]) and board[nr][nc] in node.children:
            dfs(board, nr, nc, node.children[board[nr][nc]], res)
    board[r][c] = temp
Chapter 13

Graphs

~11 min read

Introduction to Graphs

Introduction to Graphs

Intuition

A graph is a data structure composed of nodes (vertices) connected by edges. Graphs are used to model relationships, where the edges define the relationships.

Below is the implementation of the GraphNode class:

python
class GraphNode:
    def __init__(self, val):
        self.val = val
        self.neighbors = []

Terminology:

  • Adjacent node/neighbor: two nodes are adjacent if there's an edge connecting them.
  • Degree: the number of edges connected to a node.
  • Path: a sequence of nodes connected by edges.
graph terminology diagram

Attributes:

  • Directed vs. undirected: in a directed graph, edges have a direction associated with them.
  • Weighted vs. unweighted: in a weighted graph, edges have a weight associated with them, such as distance or cost.
  • Cyclic vs. acyclic: A cyclic graph contains at least one cycle, which is a path that starts and ends at the same node.
graph attributes diagram

Representations In some problems, you might not be given the graph directly. In these situations, it's usually necessary to create your own representation of the graph. The two most common representations to choose from are an adjacency list and an adjacency matrix.

In an adjacency list, the neighbors of each node are stored as a list.

In an adjacency matrix, the graph is represented as a 2D matrix where matrix[i][j] indicates an edge between nodes i and j.

adjacency list vs matrix diagram

Adjacency lists are the most common choice for most implementations. They are preferred when representing sparse graphs and when we need to iterate over all the neighbors of a node efficiently.

Adjacency matrices are preferred when representing dense graphs, and frequent checks are needed for the existence of specific edges.

Traversals The primary graph traversal techniques are DFS and BFS.

python
def dfs(node: GraphNode, visited: Set[GraphNode]):
    visited.add(node)
    process(node)
    for neighbor in node.neighbors:
        if neighbor not in visited:
            dfs(neighbor, visited)
python
def bfs(node: GraphNode):
    visited = set()
    queue = deque([node])
    while queue:
        node = queue.popleft()
        if node not in visited:
            visited.add(node)
            process(node)
            for neighbor in node.neighbors:
                queue.append(neighbor)

Both DFS and BFS have a time complexity of O(n+e), where n denotes the number of nodes and e denotes the number of edges.

Real-world Example

Social networks: Users are represented as nodes, and connections between users are edges.

Chapter Outline

chapter outline diagram

Graph Deep Copy

Graph Deep Copy

Given a reference to a node within an undirected graph, create a deep copy (clone) of the graph. The copied graph must be completely independent of the original one.

Example:

original vs cloned graph

Constraints:

  • The value of each node is unique.
  • Every node in the graph is reachable from the given node.

Intuition

Our strategy for this problem is to traverse the original graph and create the deep copy during the traversal. We use DFS.

python
dfs(node):
    cloned_node = new GraphNode(node)
    for neighbor in node.neighbors:
        cloned_neighbor = dfs(neighbor)
        cloned_node.neighbors.add(cloned_neighbor)
    return cloned_node

We use a hash map to avoid cloning nodes that have already been cloned.

Implementation

python
from ds import GraphNode
    
def graph_deep_copy(node: GraphNode) -> GraphNode:
    if not node:
        return None
    return dfs(node)
    
def dfs(node: GraphNode, clone_map = {}) -> GraphNode:
    if node in clone_map:
        return clone_map[node]
    cloned_node = GraphNode(node.val)
    clone_map[node] = cloned_node
    for neighbor in node.neighbors:
        cloned_neighbor = dfs(neighbor, clone_map)
        cloned_node.neighbors.append(cloned_neighbor)
    return cloned_node

Complexity Analysis

Time complexity: O(n+e). Space complexity: O(n).

Count Islands

Count Islands

Given a binary matrix representing 1s as land and 0s as water, return the number of islands.

An island is formed by connecting adjacent lands 4-directionally (up, down, left, and right).

Example:

islands example
python
Output: 2

Intuition

We iterate through the matrix and when we find a land cell, we explore its island using DFS, marking visited cells as -1.

Implementation

python
from typing import List
    
def count_islands(matrix: List[List[int]]) -> int:
    if not matrix:
        return 0
    count = 0
    for r in range(len(matrix)):
        for c in range(len(matrix[0])):
            if matrix[r][c] == 1:
                dfs(r, c, matrix)
                count += 1
    return count
    
def dfs(r: int, c: int, matrix: List[List[int]]) -> None:
    matrix[r][c] = -1
    dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    for d in dirs:
        next_r, next_c = r + d[0], c + d[1]
        if is_within_bounds(next_r, next_c, matrix) and matrix[next_r][next_c] == 1:
            dfs(next_r, next_c, matrix)

Complexity Analysis

Time complexity: O(m*n). Space complexity: O(m*n).

Matrix Infection

Matrix Infection

You are given a matrix where each cell is either 0 (Empty), 1 (Uninfected), or 2 (Infected). Each second, every infected cell infects its 4-directionally adjacent uninfected cells. Determine the number of seconds required for all uninfected cells to become infected. If impossible, return -1.

Example:

matrix infection example
python
Input: matrix = [[1, 1, 1, 0], [0, 0, 2, 1], [0, 1, 1, 0]]
Output: 3

Intuition

Use multi-source BFS (level-order traversal). Add all initially infected cells to the queue. Each level traversed = 1 second. Count uninfected cells and decrement as they get infected; return -1 if any remain.

Implementation

python
from typing import List
from collections import deque
    
def matrix_infection(matrix: List[List[int]]) -> int:
   dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
   queue = deque()
   ones = seconds = 0
   for r in range(len(matrix)):
       for c in range(len(matrix[0])):
           if matrix[r][c] == 1:
               ones += 1
           elif matrix[r][c] == 2:
               queue.append((r, c))
   while queue and ones > 0:
       seconds += 1
       for _ in range(len(queue)):
           r, c = queue.popleft()
           for d in dirs:
               next_r, next_c = r + d[0], c + d[1]
               if (0 <= next_r < len(matrix) and 0 <= next_c < len(matrix[0])
                       and matrix[next_r][next_c] == 1):
                   matrix[next_r][next_c] = 2
                   ones -= 1
                   queue.append((next_r, next_c))
   return -1 if ones > 0 else seconds

Complexity Analysis

Time complexity: O(m*n). Space complexity: O(m*n).

Bipartite Graph Validation

Bipartite Graph Validation

Given an undirected graph, determine if it's bipartite. A graph is bipartite if the nodes can be colored in one of two colors, so that no two adjacent nodes are the same color.

Example:

bipartite graph example
python
Input: graph = [[1, 4], [0, 2], [1], [4], [0, 3]]
Output: True

Intuition

Use graph coloring with DFS: color adjacent nodes alternating colors (1 and -1). If two adjacent nodes share the same color, it's not bipartite. Handle multiple components by calling DFS on every uncolored node.

Implementation

python
def bipartite_graph_validation(graph: List[List[int]]) -> bool:
    colors = [0] * len(graph)
    for i in range(len(graph)):
        if colors[i] == 0 and not dfs(i, 1, graph, colors):
           return False
    return True
    
def dfs(node: int, color: int, graph: List[List[int]], colors: List[int]) -> bool:
    colors[node] = color
    for neighbor in graph[node]:
        if colors[neighbor] == color:
            return False
        if colors[neighbor] == 0 and not dfs(neighbor, -color, graph, colors):
            return False
    return True

Complexity Analysis

Time complexity: O(n+e). Space complexity: O(n).

Longest Increasing Path

Longest Increasing Path

Find the longest strictly increasing path in a matrix of positive integers. A path is a sequence of cells where each one is 4-directionally adjacent to the previous one.

Example:

longest increasing path example
python
Output: 5

Intuition

Model as DAG (directed, acyclic). DFS from each cell to find longest path starting there. Use memoization to avoid redundant DFS calls.

Implementation

python
from typing import List
    
def longest_increasing_path(matrix: List[List[int]]) -> int:
    if not matrix:
        return 0
    res = 0
    m, n = len(matrix), len(matrix[0])
    memo = [[0] * n for _ in range(m)]
    for r in range(m):
        for c in range(n):
            res = max(res, dfs(r, c, matrix, memo))
    return res
    
def dfs(r: int, c: int, matrix: List[List[int]], memo: List[List[int]]):
    if memo[r][c] != 0:
        return memo[r][c]
    max_path = 1
    dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    for d in dirs:
        next_r, next_c = r + d[0], c + d[1]
        if (0 <= next_r < len(matrix) and 0 <= next_c < len(matrix[0])
                and matrix[next_r][next_c] > matrix[r][c]):
            max_path = max(max_path, 1 + dfs(next_r, next_c, matrix, memo))
    memo[r][c] = max_path
    return max_path

Complexity Analysis

Time complexity: O(m*n). Space complexity: O(m*n).

Shortest Transformation Sequence

Shortest Transformation Sequence

Given two words, start and end, and a dictionary, return the length of the shortest transformation sequence to transform start to end. Each word differs from the preceding word by exactly one letter. If no such sequence exists, return 0.

Example:

python
Input: start = 'red', end = 'hit',
       dictionary = ['red', 'bed', 'hat', 'rod', 'rad', 'rat', 'hit', 'bad', 'bat']
Output: 5

Intuition

Model as graph: words are nodes, edges exist between words differing by one letter. Use BFS (level-order traversal) from start to find shortest path to end. Generate neighbors by trying all alphabet substitutions.

Implementation

python
def shortest_transformation_sequence(start: str, end: str, dictionary: List[str]) -> int:
    dictionary_set = set(dictionary)
    if start not in dictionary_set or end not in dictionary_set:
        return 0
    if start == end:
        return 1
    lower_case_alphabet = 'abcdefghijklmnopqrstuvwxyz'
    queue = deque([start])
    visited = set([start])
    dist = 0
    while queue:
        for _ in range(len(queue)):
            curr_word = queue.popleft()
            if curr_word == end:
                return dist + 1
            for i in range(len(curr_word)):
                for c in lower_case_alphabet:
                    next_word = curr_word[:i] + c + curr_word[i+1:]
                    if next_word in dictionary_set and next_word not in visited:
                        visited.add(next_word)
                        queue.append(next_word)
        dist += 1
    return 0

Complexity Analysis

Time complexity: O(n*L^2). Space complexity: O(n*L).

Merging Communities

Merging Communities

There are n people numbered from 0 to n-1, each initially in separate communities. When two people connect, their communities merge. Implement connect(x, y) and get_community_size(x).

Intuition

Use Union-Find (Disjoint Set Union) data structure. Union operation merges two communities. Find operation returns a community's representative. Optimize with union by size and path compression.

Implementation

python
class UnionFind:
    def __init__(self, size: int):
        self.parent = [i for i in range(size)]
        self.size = [1] * size
   
    def union(self, x: int, y: int) -> None:
        rep_x, rep_y = self.find(x), self.find(y)
        if rep_x != rep_y:
            if self.size[rep_x] > self.size[rep_y]:
                self.parent[rep_y] = rep_x
                self.size[rep_x] += self.size[rep_y]
            else:
                self.parent[rep_x] = rep_y
                self.size[rep_y] += self.size[rep_x]
   
    def find(self, x: int) -> int:
        if x == self.parent[x]:
            return x
        self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
   
    def get_size(self, x: int) -> int:
        return self.size[self.find(x)]

class MergingCommunities:
    def __init__(self, n: int):
        self.uf = UnionFind(n)
   
    def connect(self, x: int, y: int) -> None:
        self.uf.union(x, y)
   
    def get_community_size(self, x: int) -> int:
        return self.uf.get_size(x)

Complexity Analysis

connect and get_community_size: amortized O(1). Constructor: O(n). Space complexity: O(n).

Prerequisites

Prerequisites

Given n courses labeled 0 to n-1 and prerequisite pairs, determine if it's possible to enroll in all courses.

Example:

python
Input: n = 3, prerequisites = [[0, 1], [1, 2], [2, 1]]
Output: False

Intuition

Use Kahn's algorithm (topological sort). A cycle means enrollment is impossible. Process nodes with in-degree 0 first; if all n nodes are processed, enrollment is possible.

Implementation

python
from typing import List
from collections import defaultdict, deque
    
def prerequisites(n: int, prerequisites: List[List[int]]) -> bool:
    graph = defaultdict(list)
    in_degrees = [0] * n
    for prerequisite, course in prerequisites:
        graph[prerequisite].append(course)
        in_degrees[course] += 1
    queue = deque()
    for i in range(n):
        if in_degrees[i] == 0:
            queue.append(i)
    enrolled_courses = 0
    while queue:
        node = queue.popleft()
        enrolled_courses += 1
        for neighbor in graph[node]:
            in_degrees[neighbor] -= 1
            if in_degrees[neighbor] == 0:
                queue.append(neighbor)
    return enrolled_courses == n

Complexity Analysis

Time complexity: O(n+e). Space complexity: O(n+e).

Shortest Path

Shortest Path

Given n nodes, non-negative weighted edges, and a start node, return an array of shortest path lengths from start to each node. Unreachable nodes get -1.

Example:

shortest path example
python
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

Use Dijkstra's algorithm (greedy). Always process the unvisited node with shortest known distance using a min-heap. Works for non-negative weights.

Implementation

python
from typing import List
from collections import defaultdict
import heapq
    
def shortest_path(n: int, edges: List[int], start: int) -> List[int]:
    graph = defaultdict(list)
    distances = [float('inf')] * n
    distances[start] = 0
    for u, v, w in edges:
        graph[u].append((v, w))
        graph[v].append((u, w))
    min_heap = [(0, start)]
    while min_heap:
        curr_dist, curr_node = heapq.heappop(min_heap)
        if curr_dist > distances[curr_node]:
            continue
        for neighbor, weight in graph[curr_node]:
            neighbor_dist = curr_dist + weight
            if neighbor_dist < distances[neighbor]:
                distances[neighbor] = neighbor_dist
                heapq.heappush(min_heap, (neighbor_dist, neighbor))
    return [-1 if d == float('inf') else d for d in distances]

Complexity Analysis

Time complexity: O((n+e)log(n)). Space complexity: O(n+e).

Connect the Dots

Connect the Dots

Given a set of points on a plane, determine the minimum cost to connect all these points. Cost = Manhattan distance between two points.

Example:

connect the dots example
python
Input: points = [[1, 1], [2, 6], [3, 2], [4, 3], [7, 1]]
Output: 15

Intuition

Minimum Spanning Tree (MST) problem. Use Kruskal's algorithm: sort all edges by cost, use Union-Find to add lowest-cost edges that don't create cycles. Stop when n-1 edges added.

Implementation

python
from typing import List
    
def connect_the_dots(points: List[List[int]]) -> int:
    n = len(points)
    edges = []
    for i in range(n):
        for j in range(i + 1, n):
            cost = (abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]))
            edges.append((cost, i, j))
    edges.sort()
    uf = UnionFind(n)
    total_cost = edges_added = 0
    for cost, p1, p2 in edges:
        if uf.union(p1, p2):
            total_cost += cost
            edges_added += 1
            if edges_added == n - 1:
                break
    return total_cost

class UnionFind:
    def __init__(self, size):
        self.parent = [i for i in range(size)]
        self.size = [1] * size

    def union(self, x, y) -> bool:
        rep_x, rep_y = self.find(x), self.find(y)
        if rep_x != rep_y:
            if self.size[rep_x] > self.size[rep_y]:
                self.parent[rep_y] = rep_x
                self.size[rep_x] += self.size[rep_y]
            else:
                self.parent[rep_x] = rep_y
                self.size[rep_y] += self.size[rep_x]
            return True
        return False

    def find(self, x) -> int:
        if x == self.parent[x]:
            return x
        self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

Complexity Analysis

Time complexity: O(n^2 log n). Space complexity: O(n^2).

Chapter 14

Backtracking

~17 min read

Introduction to Backtracking

Introduction to Backtracking

Intuition

Imagine you're stuck at an intersection point in a maze, and you know one of the three routes ahead leads to the exit:

Flowchart illustrating backtracking decision paths

However, you're not sure which route to take. To find the exit, you decide to try each option one by one, starting with option A. As you walk through passage A, you encounter a new intersection containing two more routes, D and E:

Backtracking flowchart showing intersection 2

You try option D, but it leads to a dead end. So, you backtrack to the second intersection point:

Backtracking step diagram

Next, you try option E, but it also leads to a dead end, so you backtrack to the second junction point. After concluding that neither path D nor E works, you backtrack again to the first intersection point:

Backtracking to first intersection

Having determined that path A doesn't lead to the exit, you move on to path B and discover that it leads to the exit:

Path B leads to exit

This brute force process of testing all possible paths and backtracking upon failure is called 'backtracking.'

State space tree In backtracking, the state space tree, also known as the decision tree, is a conceptual tree constructed by considering every possible decision that can be made at each point in a process.

For example, here's how we would represent the state space tree for the maze scenario:

State space tree for maze scenario

Here's a simplified explanation of a state space tree:

  • Edges: Each edge represents a possible decision, move, or action.
  • Root node: The root node represents the initial state or position before any decisions are made.
  • Intermediate nodes: Nodes representing partially completed states or intermediate positions.
  • Leaf nodes: The leaf nodes represent complete or invalid solutions.
  • Path: A path from the root to any leaf node represents a sequence of decisions that lead to a complete or invalid solution.

Drawing out the state space tree for a problem helps to visualize the entire solution space, and all possible decisions. In addition, it's a great way to understand how the algorithm works. By figuring out how to traverse this tree, we essentially create the backtracking algorithm.

Backtracking algorithm Traversing the state space tree is typically done using recursive DFS. Let's discuss how it's implemented at a high level.

Termination condition: Define the condition that specifies when a path should end. This condition should define when we've found a valid and/or invalid solution.

Iterate through decisions: Iterate through every possible decision at the current node, which contains the current state of the problem. For each decision:

  1. Make that decision and update the current state accordingly.
  2. Recursively explore all paths that branch from this updated state by calling the DFS function on this state.
  3. Backtrack by undoing the decision we made and reverting the state.

Below is a crude template for backtracking:

python
def dfs(state):
    # Termination condition.
    if meets_termination_condition(state):
        process_solution(state)
        return
    # Explore each possible decision that can be made at the current state.
    for decision in possible_decisions(state):
        make_decision(state, decision)
        dfs(state)
        undo_decision(state, decision)  # Backtrack.

Analyzing time complexity

Analyzing the time complexity of backtracking algorithms involves understanding the branching factor and the depth of the state space tree:

  • Branching factor: The number of children each node has. It typically represents the maximum number of decisions that can be made for a given state.
  • Depth: The length of the deepest path in the state space tree. It corresponds to the number of decisions or steps required to reach a complete solution.

The time complexity is often estimated as O(b^d), where b denotes the branching factor and d denotes the depth.

When to use backtracking Backtracking is useful when we need to explore all possible solutions to a problem. For example, if we need to find all possible ways to arrange items, or generate all possible subsets, permutations, or combinations, backtracking can help to identify every possible solution.

Real-world Example

AI algorithms for games: backtracking is used in AI algorithms for games like chess and Go to explore possible moves and strategies. The programs examine each potential move, simulate the game's progression, and evaluate the outcome. If a move leads to an unfavorable position, the program will backtrack to the previous move and try alternative options, systematically exploring the game tree until it finds the optimal strategy.

Chapter Outline

Chapter outline diagram for Backtracking

Find All Permutations

Find All Permutations

Return all possible permutations of a given array of unique integers. They can be returned in any order.

Example:

python
Input: nums = [4, 5, 6]
Output: [[4, 5, 6], [4, 6, 5], [5, 4, 6], [5, 6, 4], [6, 4, 5], [6, 5, 4]]

Intuition

Our task in this problem is quite straightforward: find all permutations of a given array. The key word here is "all". To achieve this, we need an algorithm that generates each possible permutation one at a time. The technique that naturally fits this requirement is backtracking. As with any backtracking solution, it's useful to first visualize the state space tree.

State space tree Let's figure out how to build just one permutation. Consider the array [4, 5, 6]. We can start by picking one number from this array for the first position of this permutation.

State space tree for permutations step 1

Now that we've found one permutation, let's backtrack to find others. Start by removing the most recently added number, 6, bringing us back to [4, 5]:

Backtracking permutations step 2

Are there any other numbers we can append to [4, 5]? Well, 6 is the only option at this point, which we already explored. So, let's backtrack again by removing 5, bringing us back to [4]:

Backtracking permutations step 3

Are there any numbers other than 5 we can add to [4] at this point? Yes, we can use 6, so let's add it and continue searching:

Backtracking permutations step 4

The only number we can use at this point is 5, so let's add it to [4, 6], giving us another permutation:

Backtracking permutations step 5

Following this backtracking process until we've explored all branches allows us to generate all permutations:

Full state space tree for permutations

Every time we reach a permutation (i.e., when the permutation we're building reaches a size of n, where n denotes the length of the input array), add it to our output.

Traversing the state space tree Generating all permutations can be achieved by traversing the state space tree.

Each node in this tree, except leaf nodes, represents a permutation candidate: a partially completed permutation that we're building. The root node represents an empty permutation, and an element is added to each permutation candidate as we progress deeper into the tree. The leaf nodes represent completed permutations.

Starting from the root node, we can traverse this tree using backtracking:

  1. Pick an unused number and add it to the current permutation candidate. Mark this number as used by adding it to the used hash set.
  2. Make a recursive call with this updated permutation candidate to explore its branches.
  3. Backtrack: remove the last number we added to the current candidate array, and the used hash set.

Whenever a permutation candidate reaches the length of n, add it to our output.

Implementation

python
from typing import List, Set
    
def find_all_permutations(nums: List[int]) -> List[List[int]]:
    res = []
    backtrack(nums, [], set(), res)
    return res
    
def backtrack(nums: List[int], candidate: List[int], used: Set[int], res: List[List[int]]) -> None:
    # If the current candidate is a complete permutation, add it to the result.
    if len(candidate) == len(nums):
        res.append(candidate[:])
        return
    for num in nums:
        if num not in used:
            # Add 'num' to the current permutation and mark it as used.
            candidate.append(num)
            used.add(num)
            # Recursively explore all branches using the updated permutation
            # candidate.
            backtrack(nums, candidate, used, res)
            # Backtrack by reversing the changes made.
            candidate.pop()
            used.remove(num)

Complexity Analysis

Time complexity: The time complexity of find_all_permutations is O(n * n!). Starting from the root, we recursively explore n candidates. For each of these n candidates, we explore n-1 more candidates, then n-2, etc. For each of the n! permutations, we make a copy of it and add it to the output, which takes O(n) time.

Space complexity: The space complexity is O(n) because the maximum depth of the recursion tree is n. The algorithm also maintains the candidate and used data structures, both of which also contribute O(n) space.

Find All Subsets

Find All Subsets

Return all possible subsets of a given set of unique integers. Each subset can be ordered in any way, and the subsets can be returned in any order.

Example:

python
Input: nums = [4, 5, 6]
Output: [[], [4], [4, 5], [4, 5, 6], [4, 6], [5], [5, 6], [6]]

Intuition

The key intuition for solving this problem lies in understanding that each subset is formed by making a specific decision for every number in the input array: to include the number, or exclude it. For example, from the array [4, 5, 6], the subset [4, 6] is created by including 4, excluding 5, and including 6.

State space tree Consider the input array [4, 5, 6]. Let's start with the root node of the tree, which is an empty subset:

Empty root node for subsets tree

To figure out how to branch out from here, let's consider our decision of whether to include or exclude an element. Let's make this decision with the first element of the input array, 4:

Include/exclude decision for element 4

For each of these subsets, we repeat the process, branching out again based on the same choice for the second element: include or exclude it:

Include/exclude decision for element 5

Finally, for the third element, we continue branching out for each existing subset based on whether we include or exclude this element:

Complete subsets tree with all 8 subsets

One important thing missing from this state space tree is a way to tell which element of the input array we're making a decision on at each node of the tree. We can use an index, i, for this:

Subsets tree with index i

As shown, the final level of the tree (i.e., when i == n, where n denotes the length of the input array) contains all the subsets of the input array. We can add each of these subsets to our output. To get to these subsets, we need to traverse the tree, and backtracking is great for this.

Implementation

python
from typing import List
    
def find_all_subsets(nums: List[int]) -> List[List[int]]:
    res = []
    backtrack(0, [], nums, res)
    return res
    
def backtrack(i: int, curr_subset: List[int], nums: List[int], res: List[List[int]]) -> None:
    # Base case: if all elements have been considered, add the current subset to the
    # output.
    if i == len(nums):
        res.append(curr_subset[:])
        return
    # Include the current element and recursively explore all paths that branch from
    # this subset.
    curr_subset.append(nums[i])
    backtrack(i + 1, curr_subset, nums, res)
    # Exclude the current element and recursively explore all paths that branch from
    # this subset.
    curr_subset.pop()
    backtrack(i + 1, curr_subset, nums, res)

Complexity Analysis

Time complexity: O(2^n * n). The state space tree has a depth of n and a branching factor of 2. For each of the 2^n subsets created, we make a copy of them and add the copy to the output, which takes O(n) time.

Space complexity: O(n) because the maximum depth of the recursion tree is n.

N Queens

N Queens

There is a chessboard of size n x n. Your goal is to place n queens on the board such that no two queens attack each other. Return the number of distinct configurations where this is possible.

Example:

N-Queens example with 4x4 board showing 2 solutions
python
Input: n = 4
Output: 2

Intuition

Queens can move vertically, horizontally, and diagonally:

Queen movement directions

So, it's only possible to place a queen on a square of the board when:

  • No other queen occupies the same row of that square.
  • No other queen occupies the same column of that square.
  • No other queen occupies either diagonal of that square.

Placing the queens - backtracking A straightforward strategy is to place one queen on the board at a time, making sure each new queen is placed on a safe square where it can't be attacked. If we can no longer safely place a queen, it means one or more of the previously placed queens need to be repositioned.

To make backtracking more efficient, we can place each queen on a new row. This way, we don't have to worry about conflicts between queens on the same row, and only need to check for an opposing queen on the same column and along the diagonals of the square where the new queen is placed.

Backtracking illustration for N-Queens placement

A partial state space tree for this backtracking process is visualized below for n = 4:

Partial state space tree for N=4 queens

Detecting opposing queens One challenge in this problem is determining if a square is attacked by another queen. We can use hash sets to efficiently check for this.

Note that we don't need a hash set for rows because we always place each queen on a different row. For columns, whenever we place a new queen on a square (r, c), we can add that square's column id (c) to a column hash set.

For diagonals: the diagonal can be identified using the id r - c, and the anti-diagonal is identified using the id r + c.

Diagonal and anti-diagonal identification matrix

The key observation here is that, for any square (r, c), its diagonal can be identified using the id r - c, and its anti-diagonal is identified using the id r + c.

Placing and removing a queen Now that we have a way to identify opposing queens, we know the action of "placing" a queen means adding its column, diagonal, and anti-diagonal ids to their respective hash sets. Inversely, to remove a queen, we just remove those exact ids from the hash sets.

Implementation

python
from typing import Set
    
res = 0
    
def n_queens(n: int) -> int:
   dfs(0, set(), set(), set(), n)
   return res
    
def dfs(r: int, diagonals_set: Set[int], anti_diagonals_set: Set[int], cols_set: Set[int], n: int) -> None:
    global res
    # Termination condition: If we have reached the end of the rows, we've placed all
    # 'n' queens.
    if r == n:
        res += 1
        return
    for c in range(n):
        curr_diagonal = r - c
        curr_anti_diagonal = r + c
        # If there are queens on the current column, diagonal or anti-diagonal, skip
        # this square.
        if (c in cols_set or curr_diagonal in diagonals_set or curr_anti_diagonal in anti_diagonals_set):
            continue
        # Place the queen by marking the current column, diagonal, and anti-diagonal
        # as occupied.
        cols_set.add(c)
        diagonals_set.add(curr_diagonal)
        anti_diagonals_set.add(curr_anti_diagonal)
        # Recursively move to the next row to continue placing queens.
        dfs(r + 1, diagonals_set, anti_diagonals_set, cols_set, n)
        # Backtrack by removing the queen's marks.
        cols_set.remove(c)
        diagonals_set.remove(curr_diagonal)
        anti_diagonals_set.remove(curr_anti_diagonal)

Complexity Analysis

Time complexity: O(n!). For the first queen, there are n choices. For the second queen, there are n-a choices, where a denotes the number of squares on the second row attacked by the first queen. This trend approximately results in a factorial growth of the search space.

Space complexity: O(n) because the maximum depth of the recursion tree is n. The hash sets also contribute to this space complexity because they each store up to n values.

Combinations of a Sum

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:

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

To ensure a universal representation to avoid duplicates, we can represent combinations so that the integers appear in the same order as in the original array. We use a start_index to enforce this.

State space tree Consider the input array [1, 2, 3] and a target of 4.

Empty root for combinations tree
First level decisions: choose 1, 2, or 3
Combinations tree with sum checks

One issue with this approach is that it resulted in duplicate combinations. To avoid these duplicates, we use start_index:

Duplicate combinations highlighted
Combinations tree with start_index

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.

Implementation

We repurpose our target value - when we choose a number to add to a combination, we reduce the target value by that number. When we reach a target of 0, we've found a valid combination. If the target becomes negative, we terminate the current branch.

Decision tree showing target reduction
Full combinations tree with target tracking
python
from typing import List
    
def combinations_of_sum_k(nums: List[int], target: int) -> List[List[int]]:
    res = []
    dfs([], 0, nums, target, res)
    return res
    
def dfs(combination: List[int], start_index: int, nums: List[int], target: int,
        res: List[List[int]]) -> None:
    # Termination condition: If the target is equal to 0, we found a combination
    # that sums to 'k'.
    if target == 0:
        res.append(combination[:])
        return
    # Termination condition: If the target is less than 0, no more valid
    # combinations can be created.
    if target < 0:
        return
    # Starting from start_index, explore all combinations after adding nums[i].
    for i in range(start_index, len(nums)):
        # Add the current number to create a new combination.
        combination.append(nums[i])
        # Recursively explore all paths that branch from this new combination.
        dfs(combination, i, nums, target - nums[i], res)
        # Backtrack.
        combination.pop()

Complexity Analysis

Time complexity: O(n^(target/m)), where n denotes the length of the array, and m denotes the smallest candidate. The recursion tree will branch down until the sum of the smallest candidates reaches or exceeds the target, resulting in a tree depth of target/m.

Space complexity: O(target/m), which includes the recursive call stack depth and the combination list.

Phone Keypad Combinations

Phone Keypad Combinations

You are given a string containing digits from 2 to 9 inclusive. Each digit maps to a set of letters as on a traditional phone keypad:

12 abc3 def
4 ghi5 jkl6 mno
7 pqrs8 tuv9 wxyz

Return all possible letter combinations the input digits could represent.

Example:

python
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?

State space tree Consider the input string "69". Let's start with the root node of the tree, which is an empty string:

Empty root node for keypad combinations

At the first digit, 6, we have the choice of starting our combination with 'm', 'n', or 'o':

First digit decision tree for digit 6

For each of these combinations we've created, we now have a new decision to make: which letter of digit 9 ('w', 'x', 'y', 'z') should we choose?

Full decision tree for digits 69

One important thing missing from this state space tree is information on which digit we're making a decision on at each node. We can use an index i to determine which digit we're considering at each node:

Decision tree with index i for digit tracking

Mapping digits to letters The final thing we need to figure out is a way to determine which letters correspond to which digits. A hash map is great for this purpose.

keypad_map hash table

Implementation

python
def phone_keypad_combinations(digits: str) -> List[str]:
    keypad_map = {
        '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
        '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
    }
    res = []
    backtrack(0, [], digits, keypad_map, res)
    return res

def backtrack(i: int, curr_combination: List[str], digits: str,
             keypad_map: Dict[str, str], res: List[str]) -> None:
    # Termination condition: if all digits have been considered, add the
    # current combination to the output list.
    if len(curr_combination) == len(digits):
        res.append("".join(curr_combination))
        return
    for letter in keypad_map[digits[i]]:
       # Add the current letter.
        curr_combination.append(letter)
        # Recursively explore all paths that branch from this combination.
        backtrack(i + 1, curr_combination, digits, keypad_map, res)
        # Backtrack by removing the letter we just added.
        curr_combination.pop()

Complexity Analysis

Time complexity: O(n * 4^n). The state space tree will branch down until a decision is made for all n elements, with a branching factor of up to 4. For each of the 4^n combinations created, we convert it into a string and add it to the output list, which takes O(n) time.

Space complexity: O(n) due to the recursive call stack, which can grow up to a maximum depth of n.

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.

Chapter 15

Dynamic Programming

~14 min read

Introduction to Dynamic Programming

Introduction to Dynamic Programming

Dynamic programming (DP) may seem daunting at first, but we'll break it down into manageable concepts and techniques. First, let's get an idea of what DP aims to do by considering the bigger picture.

Some problems can be broken down into subproblems, where each subproblem is a smaller version of the main problem. These subproblems may themselves be broken down into more subproblems as well. Recursion is often used to solve problems like these, where we make recursive calls to solve each subproblem.

However, in the recursive process, it's possible to generate and solve the same subproblem multiple times, which can be unnecessarily expensive.

Recursive subproblem tree diagram

DP is the antidote to this. It's a technique that stores solutions to each subproblem, so they can be reused when they're needed again. In other words, it's an efficient tool that ensures each subproblem is solved at most one time.

  • Optimal substructure: the optimal solution to a problem can be constructed from the optimal solutions to its subproblems.
  • Overlapping subproblems: if the same subproblems are solved repeatedly during the problem-solving process.
  • Recurrence relation: a formula that expresses the solution to the problem in terms of the solutions to its subproblems.
  • Base cases: the simplest instances of the problem where the solution is already known, without needing to be decomposed into more subproblems.

The first two terms are essential attributes that a problem must have to be solvable using DP. The last two terms are essential components in every DP solution.

Real-world Example

Word segmentation: Search engines use DP in a process called "word segmentation." When users enter a search query without spaces, DP is employed to determine if white spaces can be added to form valid words.

Chapter Outline

Chapter outline diagram showing 1D-DP and 2D-DP categories

The nature of each DP problem in this chapter is quite unique, but for simplicity, we've grouped them into two categories: one-dimensional DP (1D-DP), and two-dimensional DP (2D-DP).

Climbing Stairs

Climbing Stairs

Determine the number of distinct ways to climb a staircase of n steps by taking either 1 or 2 steps at a time.

Example:

Staircase climbing example diagram
python
Input: n = 4
Output: 5

Intuition - Top-Down

A brute force solution is to go through all possible combinations of moving 1 or 2 steps. To reach step i, we need to reach it from either step i-1, or step i-2.

Staircase with step i
Reaching step i from i-1 and i-2

climbing_stairs(n) = climbing_stairs(n - 1) + climbing_stairs(n - 2)

Base cases: If n equals 1, return 1. If n equals 2, return 2.

Recursion tree for climbing_stairs(6)

This solution is considered a top-down solution as it starts from the main problem, and recursively breaks it down into smaller subproblems.

You may have noticed in the recursion tree that we do some repeated work by calling the same subproblem multiple times (e.g., climbing_stairs(4) is called twice). This highlights overlapping subproblems. This is where memoization comes into play.

Memoized recursion tree

We use a hash map for memoization to store the results of subproblems for constant-time access.

Implementation - Top-Down

python
memo = {}
        
def climbing_stairs_top_down(n: int) -> int:
    if n <= 2:
        return n
    if n in memo:
        return memo[n]
    memo[n] = climbing_stairs_top_down(n - 1) + climbing_stairs_top_down(n - 2)
    return memo[n]

Complexity Analysis

Time complexity: Without memoization O(2^n). With memoization O(n). Space complexity: O(n) due to recursive call stack and memoization array.

Intuition - Bottom-Up

Generally, any problem solvable using top-down memoization can also be solved using a bottom-up DP approach by translating the memoization array to a DP array.

dp[n] = dp[n - 1] + dp[n - 2]

Base cases: dp[1] = 1, dp[2] = 2. Return dp[n].

Implementation - Bottom-Up

python
def climbing_stairs_bottom_up(n: int) -> int:
    if n <= 2:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

Optimization - Bottom Up

We only ever need to access the previous two values of the DP array. Use two variables: one_step_before and two_steps_before.

python
def climbing_stairs_bottom_up_optimized(n: int) -> int:
    if n <= 2:
        return n
    one_step_before, two_steps_before = 2, 1
    for i in range(3, n + 1):
        current = one_step_before + two_steps_before
        two_steps_before = one_step_before
        one_step_before = current
    return one_step_before

Interview Tip

If you're having trouble coming up with the bottom-up solution, try starting with the top-down solution. Designing a top-down solution first is often easier because we can first identify the recurrence relation, and then apply memoization to optimize it.

Minimum Coin Combination

Minimum Coin Combination

You are given an array of coin values and a target amount of money. Return the minimum number of coins needed to total the target amount. If this isn't possible, return -1. You may assume there's an unlimited supply of each coin.

Example 1:

python
Input: coins = [1, 2, 3], target = 5
Output: 2

Explanation: Use one 2-dollar coin and one 3-dollar coin to make 5 dollars.

Example 2:

python
Input: coins = [2, 4], target = 5
Output: -1

Intuition - Top-Down

In this problem, there's no restriction on the number of coins we can use, which makes a brute force approach impossible. Using a coin creates a new subproblem with a reduced target.

Coin combination example
Target reduced from 5 to 2 after using 3-dollar coin
Three subproblems from target=5
Full decision tree

min_coin_combination(target) = 1 + min(min_coin_combination(target - coin_i) | coin_i in coins)

Base case: when target equals 0, return 0. Memoization stores solutions to avoid redundant calculations.

Overlapping subproblems example
Memoized tree

Implementation - Top-Down

Complexity Analysis

Time: Without memoization O(n^(target/m)), with memoization O(target * n). Space: O(target).

Intuition - Bottom-Up

Using the same technique from Climbing Stairs, we convert our top-down solution to a bottom-up one.

python
for t in range(1, target + 1):
    for coin in coins:
        if coin <= t:
            dp[t] = min(dp[t], 1 + dp[t - coin])

Implementation - Bottom-Up

python
def min_coin_combination_bottom_up(coins, target):
    dp = [float('inf')] * (target + 1)
    dp[0] = 0
    for t in range(1, target + 1):
        for coin in coins:
            if coin <= t:
                dp[t] = min(dp[t], 1 + dp[t - coin])
    return dp[target] if dp[target] != float('inf') else -1

Complexity Analysis

Time: O(target * n). Space: O(target).

Interview Tip

When a problem asks for the minimum or maximum of something, it might be a DP problem. Keywords like 'minimum', 'maximum', 'longest', or 'shortest' suggest a DP approach.

Matrix Pathways

Matrix Pathways

You are positioned at the top-left corner of a m x n matrix, and can only move downward or rightward. Determine the number of unique pathways you can take to reach the bottom-right corner.

Example:

6 unique paths in 3x3 grid
python
Input: m = 3, n = 3
Output: 6

Intuition

The number of paths to any cell equals the sum of paths from the cell above and the cell to its left.

Bottom-right cell reached from above or left
Recurrence relation diagram

dp[r][c] = dp[r - 1][c] + dp[r][c - 1]

Base cases: All cells in row 0 and column 0 have exactly one path (set to 1).

Row 0 and column 0 base cases
Fully populated DP table

Implementation

python
def matrix_pathways(m, n):
    dp = [[1] * n for _ in range(m)]
    for r in range(1, m):
        for c in range(1, n):
            dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
    return dp[m - 1][n - 1]

Complexity Analysis

Time: O(m*n). Space: O(m*n).

Optimization

Only need to maintain two rows: prev_row and curr_row. Reduces space to O(n).

Two-row optimization diagram
prev_row and curr_row example
python
def matrix_pathways_optimized(m, n):
    prev_row = [1] * n
    for r in range(1, m):
        curr_row = [1] * n
        for c in range(1, n):
            curr_row[c] = prev_row[c] + curr_row[c - 1]
        prev_row = curr_row
    return prev_row[n - 1]

Neighborhood Burglary

Neighborhood Burglary

You plan to rob houses in a street where each house stores a certain amount of money. The neighborhood has a security system that sets off an alarm when two adjacent houses are robbed. Return the maximum amount of cash that can be stolen without triggering the alarms.

Example:

Houses diagram
python
Input: houses = [200, 300, 200, 50]
Output: 400

Explanation: Stealing from houses at indexes 0 and 2 yields 200 + 200 = 400 dollars.

Intuition

A greedy approach fails because it overlooks long-term consequences. At house i, we have two choices: skip it (profit = dp[i-1]) or rob it (profit = houses[i] + dp[i-2]).

Greedy option 1
Optimal option 2

dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])

Base cases: dp[0] = houses[0], dp[1] = max(houses[0], houses[1]).

Implementation

python
def neighborhood_burglary(houses):
    if not houses: return 0
    if len(houses) == 1: return houses[0]
    dp = [0] * len(houses)
    dp[0] = houses[0]
    dp[1] = max(houses[0], houses[1])
    for i in range(2, len(houses)):
        dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])
    return dp[len(houses) - 1]

Complexity Analysis

Time: O(n). Space: O(n).

Optimization

Only need the previous two values. Use prev_max_profit and prev_prev_max_profit variables. Reduces space to O(1).

python
def neighborhood_burglary_optimized(houses):
    if not houses: return 0
    if len(houses) == 1: return houses[0]
    prev_max_profit = max(houses[0], houses[1])
    prev_prev_max_profit = houses[0]
    for i in range(2, len(houses)):
        curr_max_profit = max(prev_max_profit, houses[i] + prev_prev_max_profit)
        prev_prev_max_profit = prev_max_profit
        prev_max_profit = curr_max_profit
    return prev_max_profit

Longest Common Subsequence

Longest Common Subsequence

Given two strings, find the length of their longest common subsequence (LCS). A subsequence is a sequence of characters derived from a string by deleting zero or more elements without changing the order of remaining elements.

Example:

python
Input: s1 = 'acabac', s2 = 'aebab'
Output: 3

Intuition

Two cases: (1) equal characters: include them, LCS(i,j) = 1 + LCS(i+1, j+1). (2) different characters: max(LCS excluding s1[i], LCS excluding s2[j]).

if s1[i] == s2[j]: LCS(i, j) = 1 + LCS(i + 1, j + 1)

if s1[i] != s2[j]: LCS(i, j) = max(LCS(i + 1, j), LCS(i, j + 1))

DP formula: dp[i][j]. Base cases: dp[len(s1)][j] = 0 and dp[i][len(s2)] = 0. Return dp[0][0].

DP table with base cases
Fully populated DP table

Implementation

python
def longest_common_subsequence(s1, s2):
    dp = [[0] * (len(s2) + 1) for _ in range(len(s1) + 1)]
    for i in range(len(s1) - 1, -1, -1):
        for j in range(len(s2) - 1, -1, -1):
            if s1[i] == s2[j]:
                dp[i][j] = 1 + dp[i + 1][j + 1]
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j + 1])
    return dp[0][0]

Complexity Analysis

Time: O(m*n). Space: O(m*n).

Optimization

Only need two rows: curr_row and prev_row. Space reduces to O(n).

python
def longest_common_subsequence_optimized(s1, s2):
    prev_row = [0] * (len(s2) + 1)
    for i in range(len(s1) - 1, -1, -1):
        curr_row = [0] * (len(s2) + 1)
        for j in range(len(s2) - 1, -1, -1):
            if s1[i] == s2[j]:
                curr_row[j] = 1 + prev_row[j + 1]
            else:
                curr_row[j] = max(prev_row[j], curr_row[j + 1])
        prev_row = curr_row
    return prev_row[0]

Longest Palindrome in a String

Longest Palindrome in a String

Return the longest palindromic substring within a given string.

Example:

python
Input: s = 'abccbaba'
Output: 'abccba'

Intuition

A substring s[i:j] is a palindrome if: (1) s[i] == s[j] and (2) s[i+1:j-1] is also a palindrome.

Palindrome contains smaller palindromes
Palindrome check with i and j pointers

dp[i][j] = True if s[i] == s[j] and dp[i + 1][j - 1]

Base cases: All length-1 substrings are palindromes (dp[i][i] = True). Length-2 substrings are palindromes if both characters are same.

Track longest palindrome with start_index and max_len. Return s[start_index : start_index + max_len].

Implementation

python
def longest_palindrome_in_a_string(s):
    n = len(s)
    if n == 0: return ''
    dp = [[False] * n for _ in range(n)]
    max_len = 1
    start_index = 0
    for i in range(n):
        dp[i][i] = True
    for i in range(n - 1):
        if s[i] == s[i + 1]:
            dp[i][i + 1] = True
            max_len = 2
            start_index = i
    for substring_len in range(3, n + 1):
        for i in range(n - substring_len + 1):
            j = i + substring_len - 1
            if s[i] == s[j] and dp[i + 1][j - 1]:
                dp[i][j] = True
                if substring_len > max_len:
                    max_len = substring_len
                    start_index = i
    return s[start_index : start_index + max_len]

Complexity Analysis

Time: O(n^2). Space: O(n^2).

Optimized Approach

Expand outward from each base case center. Two types: single-character (odd) and two-character (even) centers.

Expanding from single-char center
Expanding from two-char center
python
def longest_palindrome_in_a_string_expanding(s):
    n = len(s)
    start, max_len = 0, 0
    for center in range(n):
        odd_start, odd_length = expand_palindrome(center, center, s)
        if odd_length > max_len:
            start = odd_start
            max_len = odd_length
        if center < n - 1 and s[center] == s[center + 1]:
            even_start, even_length = expand_palindrome(center, center + 1, s)
            if even_length > max_len:
                start = even_start
                max_len = even_length
    return s[start : start + max_len]

Complexity Analysis

Time: O(n^2). Space: O(1).

Manacher's Algorithm

Manacher's Algorithm runs in O(n) time but is a specialized algorithm less common in coding interviews. Most interviewers want solutions that show strong problem-solving fundamentals.

Maximum Subarray Sum

Maximum Subarray Sum

Given an array of integers, return the sum of the subarray with the largest sum.

Example:

python
Input: nums = [3, 1, -6, 2, -1, 4, -9]
Output: 5

Explanation: subarray [2, -1, 4] has the largest sum of 5.

Intuition

For each number, choose max(curr_sum + num, num): continue existing subarray or start new one at current element.

Input array

Keep track of max_sum - the largest curr_sum encountered. This is Kadane's algorithm.

Implementation

python
def maximum_subarray_sum(nums):
    if not nums: return 0
    max_sum = current_sum = float('-inf')
    for num in nums:
        current_sum = max(current_sum + num, num)
        max_sum = max(max_sum, current_sum)
    return max_sum

Complexity Analysis

Time: O(n). Space: O(1).

Intuition - DP

Every subarray ends at a certain index. dp[i] = maximum subarray sum ending at index i.

max_subarray(i) = max(max_subarray(i - 1) + nums[i], nums[i])

dp[i] = max(dp[i - 1] + nums[i], nums[i])

Base case: dp[0] = nums[0]. Return max of all dp values.

Implementation - DP

python
def maximum_subarray_sum_dp(nums):
    n = len(nums)
    if n == 0: return 0
    dp = [0] * n
    dp[0] = nums[0]
    max_sum = dp[0]
    for i in range(1, n):
        dp[i] = max(dp[i - 1] + nums[i], nums[i])
        max_sum = max(max_sum, dp[i])
    return max_sum

Optimization

Use a single variable instead of full DP array. Space reduces to O(1).

python
def maximum_subarray_sum_dp_optimized(nums):
    n = len(nums)
    if n == 0: return 0
    current_sum = nums[0]
    max_sum = nums[0]
    for i in range(1, n):
        current_sum = max(nums[i], current_sum + nums[i])
        max_sum = max(max_sum, current_sum)
    return max_sum

0/1 Knapsack

/1 Knapsack

You are a thief planning to rob a store. You can only carry a knapsack with a maximum capacity of cap units. Each item (i) has a weight (weights[i]) and a value (values[i]). Find the maximum total value you can carry.

Example:

Knapsack problem diagram
python
Input: cap = 7, weights = [5, 3, 4, 1], values = [70, 50, 40, 10]
Output: 90

Explanation: Items 1 and 2 have combined value 50 + 40 = 90 and weight 3 + 4 = 7.

Intuition

Binary decision for each item: include or exclude (hence '0/1 Knapsack'). The recurrence relation:

knapsack(i, c) = max(values[i] + knapsack(i + 1, c - weights[i]), knapsack(i + 1, c))

If the item doesn't fit: knapsack(i, c) = knapsack(i + 1, c).

python
if weights[i] <= c:
    dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
else:
    dp[i][c] = dp[i + 1][c]

Base cases: dp[n][c] = 0 for all c (no items), dp[i][0] = 0 for all i (no capacity). Return dp[0][cap].

DP table with base cases
DP table population order

Implementation

python
def knapsack(cap, weights, values):
    n = len(values)
    dp = [[0 for x in range(cap + 1)] for x in range(n + 1)]
    for i in range(n - 1, -1, -1):
        for c in range(1, cap + 1):
            if weights[i] <= c:
                dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
            else:
                dp[i][c] = dp[i + 1][c]
    return dp[0][cap]

Complexity Analysis

Time: O(n*cap). Space: O(n*cap).

Optimization

Only need two rows: curr_row and prev_row. Space reduces to O(cap).

python
def knapsack_optimized(cap, weights, values):
    n = len(values)
    prev_row = [0] * (cap + 1)
    for i in range(n - 1, -1, -1):
        curr_row = [0] * (cap + 1)
        for c in range(1, cap + 1):
            if weights[i] <= c:
                curr_row[c] = max(values[i] + prev_row[c - weights[i]], prev_row[c])
            else:
                curr_row[c] = prev_row[c]
        prev_row = curr_row
    return prev_row[cap]

Largest Square in a Matrix

Largest Square in a Matrix

Determine the area of the largest square of 1's in a binary matrix.

Example:

5x5 matrix with 3x3 square highlighted
python
Output: 9

Intuition

Squares contain smaller squares inside them. The length of a square ending at (i,j) depends on the squares ending at its left (i,j-1), top (i-1,j), and top-left diagonal (i-1,j-1).

6x6 matrix with 4x4 square of 1s
Three 3x3 squares around cell (4,4)

if matrix[i][j] == 1: dp[i][j] = 1 + min(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1])

Base cases: row 0 and column 0 cells get dp value equal to matrix value (0 or 1).

DP table with base cases

Track max_len. Return max_len^2 for the area.

Implementation

python
def largest_square_in_a_matrix(matrix):
    if not matrix: return 0
    m, n = len(matrix), len(matrix[0])
    dp = [[0] * n for _ in range(m)]
    max_len = 0
    for j in range(n):
        if matrix[0][j] == 1:
            dp[0][j] = 1
            max_len = 1
    for i in range(m):
        if matrix[i][0] == 1:
            dp[i][0] = 1
            max_len = 1
    for i in range(1, m):
        for j in range(1, n):
            if matrix[i][j] == 1:
                dp[i][j] = 1 + min(dp[i-1][j], dp[i-1][j-1], dp[i][j-1])
                max_len = max(max_len, dp[i][j])
    return max_len ** 2

Complexity Analysis

Time: O(m*n). Space: O(m*n).

Optimization

Only need two rows: prev_row and curr_row. Space reduces to O(m).

python
def largest_square_in_a_matrix_optimized(matrix):
    if not matrix: return 0
    m, n = len(matrix), len(matrix[0])
    prev_row = [0] * n
    max_len = 0
    for i in range(m):
        curr_row = [0] * n
        for j in range(n):
            if i == 0 or j == 0:
                curr_row[j] = matrix[i][j]
            else:
                if matrix[i][j] == 1:
                    curr_row[j] = 1 + min(curr_row[j-1], prev_row[j-1], prev_row[j])
            max_len = max(max_len, curr_row[j])
        prev_row, curr_row = curr_row, [0] * n
    return max_len ** 2
Chapter 16

Greedy

~5 min read

Introduction to Greedy Algorithms

Introduction to Greedy Algorithms

Intuition

Greedy algorithms are a class of algorithms that make a series of decisions, where each decision is the best immediate choice given the options available.

Imagine you're planning a road trip from city A to city E, and you want to visit cities B, C, and D along the way.

Weighted graph showing road trip routes

You want the fastest route. Instead of checking every possible route, you take the route with the shortest duration at each leg of the trip.

Shortest path highlighted in graph

This is effectively a greedy approach to the problem, where you choose the best option at each step, aiming to find the best solution overall.

How does a greedy algorithm work? More formally, a greedy algorithm follows the greedy choice property, which states that the best overall solution to a problem (global optimum) can be arrived at by making the best possible decision at each step (local optimum).

Each decision is made based only on the current context, and ignores its impact on future steps. This process continues until the algorithm reaches a final solution.

How to tell if a problem can be solved using a greedy approach Greedy approaches are generally used for optimization problems, similar to DP. We check if the problem has an optimal substructure. The key difference between DP and greedy: greedy follows the greedy choice property, while DP considers all possible solutions. All greedy problems can be solved using DP, but not all DP problems can be solved greedily.

Real-world Example

Huffman coding in data compression: Huffman coding assigns variable-length codes to input characters based on their frequencies. The greedy approach always combines the two least frequent characters first, ensuring each step reduces the overall encoding size. Used in ZIP, JPEG.

Chapter Outline

Chapter outline: Reaching a Destination (Jump to End, Gas Stations) and Resource Allocation (Candies)

It's impractical to provide a one-size-fits-all framework for solving greedy problems because each one is unique. This chapter explores a variety of unique situations where the greedy choice property leads to the solution.

Jump to the End

Jump to the End

You are given an integer array in which you're originally positioned at index 0. Each number represents the maximum jump distance from the current index. Determine if it's possible to reach the end of the array.

Example 1:

Jump path in [3,2,0,2,5]
python
Input: nums = [3, 2, 0, 2, 5]
Output: True

Example 2:

Dead end in [2,1,0,3]
python
Input: nums = [2, 1, 0, 3]
Output: False

Intuition

From index i, the furthest index reachable is i + nums[i]. The challenge arises with 0s (dead ends). Think backward: set destination to last index and iterate backward. If i + nums[i] >= destination, set destination = i.

if i + nums[i] >= destination, we can jump to destination from index i.

Greedy choice: the first valid index from the right becomes the new destination. All other indexes that can reach the original destination can also reach this first valid index. Return true when destination == 0.

Implementation

python
def jump_to_the_end(nums):
    destination = len(nums) - 1
    for i in range(len(nums) - 1, -1, -1):
        if i + nums[i] >= destination:
            destination = i
    return destination == 0

Complexity Analysis

Time: O(n). Space: O(1).

Gas Stations

Gas Stations

There's a circular route which contains gas stations. At each station, you can fill your car with gas[i], and moving to the next station consumes cost[i]. Find the index of the gas station to start at to complete the circuit without running out of gas. Return -1 if impossible. Assume only one solution exists.

Example:

python
Input: gas = [2, 5, 1, 3], cost = [3, 2, 1, 4]
Output: 1

Intuition

Case 1: sum(gas) < sum(cost) — impossible, return -1. Case 2: sum(gas) >= sum(cost) — a valid start must exist.

Use net gas (gas[i] - cost[i]) at each station. Iterate forward, accumulating tank. If tank < 0 at station i, we cannot start from the current start or any station between start and i. Reset start = i+1, tank = 0.

If we cannot make it to station b from station a, we cannot start anywhere between a and b without running out of gas.

Cannot start between a and b diagram
Circular circuit segments diagram

Proof: if sum(gas) >= sum(cost), the valid start found by this greedy process is guaranteed to complete the circuit.

Implementation

python
def gas_stations(gas, cost):
    if sum(gas) < sum(cost):
        return -1
    start = tank = 0
    for i in range(len(gas)):
        tank += gas[i] - cost[i]
        if tank < 0:
            start, tank = i + 1, 0
    return start

Complexity Analysis

Time: O(n). Space: O(1).

Interview Tip

Tip: Demonstrate your greedy solution with examples if proving it formally is too difficult. Showing correctness with diverse examples is a good compromise in an interview setting.

Candies

Candies

You teach a class of children sitting in a row, each with a performance rating. Rules: (1) Each child must receive at least one candy. (2) If two adjacent children have different ratings, the higher-rated child must receive more candies. Find the minimum number of candies.

Example 1:

python
Input: ratings = [4, 3, 2, 4, 5, 1]
Output: 12

Explanation: Distribute candies as [3, 2, 1, 2, 3, 1].

Example 2:

python
Input: ratings = [1, 3, 3]
Output: 4

Intuition

Two-pass greedy: First pass (left-to-right): if ratings[i] > ratings[i-1], set candies[i] = candies[i-1] + 1. Second pass (right-to-left): if ratings[i] > ratings[i+1], set candies[i] = max(candies[i], candies[i+1] + 1). Start with 1 candy for each child.

First pass increasing ratings
Second pass decreasing ratings

This is a greedy solution: locally optimal choice for each child by only considering immediate neighbors. The two passes together ensure both left and right neighbor constraints are satisfied.

Implementation

python
def candies(ratings):
    n = len(ratings)
    candies = [1] * n
    for i in range(1, n):
        if ratings[i] > ratings[i - 1]:
            candies[i] = candies[i - 1] + 1
    for i in range(n - 2, -1, -1):
        if ratings[i] > ratings[i + 1]:
            candies[i] = max(candies[i], candies[i + 1] + 1)
    return sum(candies)

Complexity Analysis

Time: O(n). Space: O(n).

Chapter 17

Sort and Search

~8 min read

Sorting and searching are two of the most fundamental operations in computer science. In this chapter, we'll explore patterns and techniques for efficiently sorting data and searching through it.

Sorting

Sorting involves arranging elements in a specific order (usually ascending or descending). Common sorting algorithms include:

  • Merge Sort: O(n log n) time, O(n) space — stable, divide-and-conquer
  • Quick Sort: O(n log n) average time, O(log n) space — in-place, divide-and-conquer
  • Counting Sort: O(n + k) time, O(k) space — for integers in a known range

Searching

Searching involves finding a specific element or value in a data structure. Binary search is the most efficient algorithm for searching in sorted data:

  • Binary Search: O(log n) time — works only on sorted arrays
  • Linear Search: O(n) time — works on unsorted arrays

Problems

In this chapter, we'll solve the following problems:

  • Sort Linked List
  • Sort Array
  • Kth Largest Integer
  • Dutch National Flag

Sort Linked List

Sort Linked List

Given the head of a singly linked list, sort it in ascending order and return the sorted list's head.

Example:

python
Input: head = [4, 2, 1, 3]
Output: [1, 2, 3, 4]

Intuition

To sort a linked list, we can use merge sort, which is well-suited to linked lists because it doesn't require random access (unlike quicksort). The algorithm works by:

  1. Split the list into two halves using the slow and fast pointer technique.
  2. Recursively sort each half.
  3. Merge the two sorted halves.

Implementation

python
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def sort_linked_list(head: ListNode) -> ListNode:
    if not head or not head.next:
        return head
    
    # Split the list into two halves
    left, right = split_list(head)
    
    # Recursively sort each half
    left = sort_linked_list(left)
    right = sort_linked_list(right)
    
    # Merge the two sorted halves
    return merge(left, right)

def split_list(head: ListNode):
    slow, fast = head, head.next
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    right = slow.next
    slow.next = None
    return head, right

def merge(left: ListNode, right: ListNode) -> ListNode:
    dummy = ListNode(0)
    curr = dummy
    while left and right:
        if left.val <= right.val:
            curr.next = left
            left = left.next
        else:
            curr.next = right
            right = right.next
        curr = curr.next
    curr.next = left if left else right
    return dummy.next

Complexity Analysis

Time complexity: O(n log n) — each split is O(n), and there are O(log n) levels of recursion.

Space complexity: O(log n) — for the recursive call stack.

Sort Array

Sort Array

Given an array of integers nums, sort the array in ascending order and return it. You must solve the problem without using any built-in functions in O(nlog(n)) time complexity and with the smallest space complexity possible.

Example:

python
Input: nums = [5, 2, 3, 1]
Output: [1, 2, 3, 5]

Intuition

We can use quicksort, which has O(n log n) average time complexity and O(log n) space complexity. Quicksort works by:

  1. Selecting a pivot element from the array.
  2. Partitioning the other elements into two groups: elements less than the pivot and elements greater than the pivot.
  3. Recursively applying the same logic to both groups.

Alternatively, we can use counting sort when the range of elements is limited, which has O(n + k) time complexity where k is the range of input values.

Implementation

python
from typing import List
import random

def sort_array(nums: List[int]) -> List[int]:
    quicksort(nums, 0, len(nums) - 1)
    return nums

def quicksort(nums: List[int], low: int, high: int) -> None:
    if low < high:
        pivot_idx = partition(nums, low, high)
        quicksort(nums, low, pivot_idx - 1)
        quicksort(nums, pivot_idx + 1, high)

def partition(nums: List[int], low: int, high: int) -> int:
    # Randomly select a pivot to avoid worst case
    rand_idx = random.randint(low, high)
    nums[rand_idx], nums[high] = nums[high], nums[rand_idx]
    pivot = nums[high]
    i = low - 1
    for j in range(low, high):
        if nums[j] <= pivot:
            i += 1
            nums[i], nums[j] = nums[j], nums[i]
    nums[i + 1], nums[high] = nums[high], nums[i + 1]
    return i + 1

Complexity Analysis

Time complexity: O(n log n) average — worst case O(n²) but rare with random pivot selection.

Space complexity: O(log n) — for the recursive call stack.

Kth Largest Integer

Kth Largest Integer

Given an unsorted integer array nums and an integer k, return the kth largest element in the array.

Example:

python
Input: nums = [3, 2, 1, 5, 6, 4], k = 2
Output: 5

Intuition

There are two efficient approaches to solve this problem:

Approach 1: Min-Heap

We can maintain a min-heap of size k. For each element, if it's larger than the heap's minimum, we replace the minimum with this element. After processing all elements, the heap's minimum is the kth largest element.

This approach has O(n log k) time complexity and O(k) space complexity.

Approach 2: Quickselect

Quickselect is a selection algorithm that finds the kth smallest (or largest) element in an unsorted list. It's based on the partition step of quicksort.

This approach has O(n) average time complexity and O(log n) space complexity.

Implementation

python
import heapq
from typing import List

def kth_largest(nums: List[int], k: int) -> int:
    min_heap = []
    for num in nums:
        heapq.heappush(min_heap, num)
        if len(min_heap) > k:
            heapq.heappop(min_heap)
    return min_heap[0]
python
import random
from typing import List

def kth_largest_quickselect(nums: List[int], k: int) -> int:
    target = len(nums) - k  # kth largest = (n-k)th smallest
    
    def quickselect(low: int, high: int) -> int:
        pivot_idx = random.randint(low, high)
        nums[pivot_idx], nums[high] = nums[high], nums[pivot_idx]
        pivot = nums[high]
        i = low
        for j in range(low, high):
            if nums[j] <= pivot:
                nums[i], nums[j] = nums[j], nums[i]
                i += 1
        nums[i], nums[high] = nums[high], nums[i]
        if i == target:
            return nums[i]
        elif i < target:
            return quickselect(i + 1, high)
        else:
            return quickselect(low, i - 1)
    
    return quickselect(0, len(nums) - 1)

Complexity Analysis

Min-Heap: Time complexity O(n log k), Space complexity O(k).

Quickselect: Time complexity O(n) average, O(n²) worst case, Space complexity O(log n).

Dutch National Flag

Dutch National Flag

Given an array of 0s, 1s, and 2s representing red, white, and blue, respectively, sort the array in place so that it resembles the Dutch national flag, with all reds (0s) coming first, followed by whites (1s), and finally blues (2s).

Example:

python
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 O(nlog(n)) approach, where n denotes the length of the array. However, this 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:

Image represents a visual explanation of a sorting algorithm, specifically illustrating how elements naturally group during the process.

This allows us to focus on only positioning two numbers.

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.

We can set a left pointer to move any 0s we encounter to the left, and a right pointer to move any 2s to the right. To iterate through the array, we can use a separate pointer, i:

  • When we encounter a 0 at index i, swap it with nums[left].
  • When we encounter a 2 at index i, swap it with nums[right].

To understand how we should adjust these pointers after each swap, let's use the following example:

Image represents a one-dimensional array containing seven integer elements: 2, 0, 1, 2, 0, 0, and 1.

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:

After swap, 'swapped' arrow connects first and last elements.

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:

Diagram showing nums[i] == 1 condition leading to i++ action.

Now, pointer i is pointing at a 0, so let's swap it with nums[left]:

Diagram showing nums[i] == 0 condition leading to swap operation.

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.

We now know what to do whenever we encounter a 0, 1, or 2. We can continue applying this logic until the pointer i surpasses the right pointer, indicating all elements have been positioned correctly:

Diagram showing i > right stop condition.

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.

Implementation

python
from typing import List
    
def dutch_national_flag(nums: List[int]) -> None:
    i, left, right = 0, 0, len(nums) - 1
    while i <= right:
        # Swap 0s with the element at the left pointer.
        if nums[i] == 0:
            nums[i], nums[left] = nums[left], nums[i]
            left += 1
            i += 1
        # Swap 2s with the element at the right pointer.
        elif nums[i] == 2:
            nums[i], nums[right] = nums[right], nums[i]
            right -= 1
        else:
            i += 1

Complexity Analysis

Time complexity: The time complexity of dutch_national_flag is O(n) because we iterate through each element of nums once.

Space complexity: The space complexity is O(1).

Chapter 18

Bit Manipulation

~11 min read

Introduction to Bit Manipulation

Introduction to Bit Manipulation

Intuition

Bit manipulation is a technique used in programming to perform operations at the bit level, which can often lead to more efficient and faster algorithms.

When is bit manipulation useful? Bit manipulation allows us to work directly with the binary representation of numbers, making certain operations more efficient. Common tasks such as setting, clearing, toggling, and checking bits can be performed quickly using bitwise operators.

For example, one of the most common space optimization techniques involves using an unsigned 32-bit integer to represent a set of boolean values, where each bit in the integer corresponds to a different boolean value. This allows us to store and manipulate up to 32 states without using a boolean array or hash set.

Image represents a visual depiction of a binary counter's behavior.

Bitwise Operators

There are several fundamental bitwise operations, each serving a specific purpose. These are shown below, along with each operation's truth table:

Image represents a visual explanation of Boolean logic gates, specifically the NOT and AND gates.
Image represents a comparison of two logical operations, OR and XOR, using truth tables and examples.

Some useful characteristics of the XOR operator are:

  • a ^ 0 = a
  • a ^ a = 0

In addition, it's also important to understand the fundamental shift operators:

  • Left shift (<< n): Shifts the bits of a number to the left by n positions, adding 0s on the right. This is equivalent to multiplying a number by 2n.
  • Right shift (>> n): Shifts the bits of a number to the right by n positions, discarding bits on the right. This is equivalent to dividing a number by 2n (integer division).

Using these operators, here are some useful bit manipulation techniques to be aware of:

  • Setting the ith bit of x to 1: x |= (1 << i)
  • Clearing the ith bit of x to 0: x &= ~(1 << i)
  • Toggling the ith bit of x (from 0 to 1 or 1 to 0): x ^= (1 << i)
  • Checking if the ith bit of x is set: (x >> i) & 1
  • Removing the lowest set bit of x: x & (x - 1)
  • Extracting the lowest set bit of x: x & (-x)

Real-world Example

Data transmission in networks: In many network protocols, bit manipulation is used to efficiently encode, compress, and transmit data for fast communication. For example, IP addresses and subnet masks use bitwise AND operations to determine whether two devices are on the same network. Similarly, in error detection and correction algorithms like checksums or parity bits, bit manipulation promotes data integrity during transmission by identifying and correcting errors in the binary data.

Chapter Outline

Image represents a hierarchical diagram illustrating different coding patterns within the broader category of 'Bit Manipulation'.

To best grasp the fundamentals of bit manipulation, this chapter explores a variety of problems that utilize a range of complex bit manipulation techniques, as well as how to identify the appropriate bitwise operator based on specific requirements.

Hamming Weights of Integers

Hamming Weights of Integers

The Hamming weight of a number is the number of set bits (1-bits) in its binary representation. Given a positive integer n, return an array where the ith element is the Hamming weight of integer i for all integers from 0 to n.

Example:

python
Input: n = 7
Output: [0, 1, 1, 2, 1, 2, 2, 3]

Explanation:

NumberBinary representationNumber of set bits
000
111
2101
3112
41001
51012
61102
71113

Intuition - Count Bits For Each Number

The most straightforward strategy is to individually count the number of bits for each number from 0 to n.

Consider a number x = 25 and its binary representation:

x = 25 (base 10) = 11001 (base 2)

To count the number of set bits (1s) in a number, we can check each bit and increase a count whenever we find a set bit.

To check the LSB (least significant bit) of x, we can use the AND operator with 1: x & 1. If the result is 1, the LSB is set.

Bitwise AND operation: 11001 & 00001 = 00001

Now, how do we check the next bit? If we perform a bitwise right-shift operation on x, we shift all bits of x one position to the right. This effectively makes this next bit the new LSB:

Right shift operation on binary number

We now have a process we can repeat to count the number of set bits in a number:

  1. Check the LSB of x using x & 1, and increment count if it's 1.
  2. Right shift x.

Continue with the two steps above until x equals 0, indicating there are no more set bits to count. Doing this for every number from 0 to n provides the answer.

Implementation - Count Bits For Each Number

python
from typing import List

def hamming_weights_of_integers(n: int) -> List[int]:
    result = []
    for i in range(n + 1):
        count = 0
        x = i
        while x:
            count += x & 1
            x >>= 1
        result.append(count)
    return result

Complexity Analysis

Time complexity: The time complexity of hamming_weights_of_integers is O(n log(n)) because for each integer x from 0 to n, counting the number of set bits takes logarithmic time, as there are approximately log2(x) bits in that number. If we assume all integers have 32 bits, the time complexity simplifies to just O(n).

Space complexity: The space complexity is O(1) because no extra space is used except the space occupied by the output.

Intuition - Dynamic Programming

In the previous approach, it's important to note that by the time we reach integer x, we have already computed the result for all integers from 0 to x - 1. If we find a way to leverage these previous results, we can improve the efficiency of constructing the output array.

It would be wise to find a way to take advantage of some optimal substructure by treating the results from integers 0 to x - 1 as potential subproblems of x. This is the beginning of a DP solution. Let dp[x] represent the number of set bits in integer x.

A predictable way to access a subproblem of dp[x] is to right-shift x by 1, effectively removing its LSB. This is the subproblem dp[x >> 1], and the only difference between this and dp[x] is the LSB which was just removed.

As mentioned earlier, the LSB of x can be found using x & 1. Therefore:

  • If the LSB of x is 0, there is no difference in the number of bits between x and x >> 1:
  • If the LSB of x is 1, the difference in the number of bits between x and x >> 1 is 1:

Therefore, we can obtain dp[x] by using the result of dp[x >> 1] and adding the LSB to it:

dp[x] = dp[x >> 1] + (x & 1)

Now, we just need to know what our base case is.

Base case The simplest version of this problem is when n is 0. In this case, there are no set bits, so the number of set bits is 0. We can apply this base case by setting dp[0] to 0.

After the base case is set, we populate the rest of the DP array by applying our formula from dp[1] to dp[n]. The answer to the problem is then just the values in the DP array, containing the number of set bits for each number from 0 to n.

Implementation - Dynamic Programming

python
from typing import List

def hamming_weights_of_integers_dp(n: int) -> List[int]:
    dp = [0] * (n + 1)
    for i in range(1, n + 1):
        dp[i] = dp[i >> 1] + (i & 1)
    return dp

Complexity Analysis

Time complexity: The time complexity of hamming_weights_of_integers_dp is O(n) since we populate each element of the DP array once.

Space complexity: The space complexity is O(1) because no extra space is used, aside from the space taken up by the output, which is the DP array in this case.

Lonely Integer

Lonely Integer

Given an integer array where each number occurs twice except for one of them, find the unique number.

Example:

python
Input: nums = [1, 3, 3, 2, 1]
Output: 2

Constraints:

  • nums contains at least one element.

Intuition

Hash map solution A straightforward way to solve this problem is by using a hash map. The idea is to count the occurrences of each element in the array. We can do this by iterating through the array and increasing the frequency stored in the hash map of each element encountered in the array.

Hash map approach: nums=[1,3,3,2,1], hashmap shows {1:2, 2:1, 3:2}, lonely integer is 2.

Once populated, we can iterate through the hash map to find the element with a frequency of 1, which is our lonely integer. This approach takes O(n) time, but comes at the cost of O(n) space, where n denotes the length of the input array. Let's see if there's a way to solve this without additional data structures like a hash map.

Sorting solution Another way to solve this problem is to sort the array first, then look for the lonely integer by iterating through the array, and comparing each element with its neighbors. The lonely integer will be the one that doesn't have a duplicate next to it.

Sorting solution: [1,3,3,2,1] sorted to [1,1,2,3,3], 2 has no duplicates next to it.

This method takes O(n log(n)) time due to sorting, but has the benefit of not requiring any additional data structures (aside from any used during sorting). Is there a way we can achieve a linear time complexity while also maintaining constant space?

Bit manipulation A way to avoid using additional space is with bit manipulation. The XOR operation in particular can be useful when handling duplicate integers. Recall the following two characteristics of the XOR operator:

  • a ^ a == 0
  • a ^ 0 == a

As we can see, when we XOR two identical numbers, the result is 0. As each number except the lonely integer appears twice in the array, if we XOR all the numbers together, all pairs of identical numbers will cancel out to 0. This isolates the lonely integer: once all duplicate elements cancel to 0, XORing 0 with the lonely integer gives us the lonely integer.

This works independently of where the numbers are located in the array, as XOR follows the commutative and associative properties:

  • Commutative property: a ^ b == b ^ a
  • Associative property: (a ^ b) ^ c == a ^ (b ^ c)

So, as long as two of the same numbers exist in the array, they will get canceled out when we XOR all the elements. An example of this is shown below:

XOR all elements: 1^3^3^2^1 = (1^1)^(3^3)^2 = 0^0^2 = 2

This allows us to identify the lonely integer in linear time without using extra space.

Implementation

python
from typing import List
    
def lonely_integer(nums: List[int]) -> int:
    res = 0
    # XOR each element of the array so that duplicate values will cancel each other
    # out (x ^ x == 0).
    for num in nums:
        res ^= num
    # 'res' will store the lonely integer because it would not have been canceled out
    # by any duplicate.
    return res

Complexity Analysis

Time complexity: The time complexity of lonely_integer is O(n) because we perform a constant-time XOR operation on each element in nums.

Space complexity: The space complexity is O(1).

Swap Odd and Even Bits

Swap Odd and Even Bits

Given an unsigned 32-bit integer n, return an integer where all of n's even bits are swapped with their adjacent odd bits.

Example 1:

Binary 101001 (41) swapped to 010110 (22)
python
Input: n = 41
Output: 22

Example 2:

Binary 010111 (23) swapped to 101011 (43)
python
Input: n = 23
Output: 43

Intuition

Swapping even and odd bits means that each bit in an even position is swapped with the bit in the next odd position, and vice versa. Note that the positions start at position 0, which is the position of the least significant bit.

The key thing to notice is that, in order to perform the swap:

  • All bits in the even positions need to be shifted one position to the left.
  • All bits in the odd positions need to be shifted one position to the right.
Visual depiction showing even bits shift left, odd bits shift right.

This suggests that if we had a way to extract all the even and odd-positioned bits separately, we could shift them accordingly and then merge them back together, so the odd-positioned bits are in the even positions, and vice versa.

Flowchart: extract even bits (shift left) and odd bits (shift right), then merge with OR.

Let's start by figuring out how to obtain the even and odd bits of n.

Obtaining all even bits To obtain all even bits of n, we can use a mask which has all even bit positions set to 1:

even_mask = 0x55555555 (alternating 01 pattern)

Performing a bitwise-AND with this mask and n gives us an integer where all the bits at odd positions are set to 0, ensuring only the bits in even positions of n are preserved:

n AND even_mask = even_bits

Obtaining all odd bits Similarly, to obtain all the odd bits of n, we can use a mask with all odd bit positions are set to 1:

odd_mask = 0xAAAAAAAA (alternating 10 pattern)

Performing a bitwise-AND with this mask and n gives us an integer where all the bits at even positions are set to 0, ensuring only the bits at odd positions of n are preserved:

n AND odd_mask = odd_bits

Now that we've extracted all the even bits and odd bits separately, let's use them to obtain the result, where the bits at odd and even positions are swapped.

Shifting and merging the bits at odd and even positions We can use the shift operator to shift the bits at even positions to the left once, and the bits at odd positions to the right once:

even_bits shifted left by 1, odd_bits shifted right by 1

Then, to merge these together, we can use the bitwise-OR operator because it combines the two sets of bits into the final result.

shifted even_bits OR shifted odd_bits = result

Now, the odd-positioned bits are in the even positions and vice versa.

Implementation

python
def swap_odd_and_even_bits(n: int) -> int:
    # Mask to extract even bits (positions 0, 2, 4, ...): 0x55555555 = 01010101...
    even_mask = 0x55555555
    # Mask to extract odd bits (positions 1, 3, 5, ...): 0xAAAAAAAA = 10101010...
    odd_mask = 0xAAAAAAAA
    # Extract even bits and shift left, extract odd bits and shift right
    even_bits = (n & even_mask) << 1
    odd_bits = (n & odd_mask) >> 1
    # Merge the two
    return even_bits | odd_bits

Complexity Analysis

Time complexity: The time complexity of swap_odd_and_even_bits is O(1).

Space complexity: The space complexity is O(1).

Chapter 19

Math and Geometry

~23 min read

Introduction to Math and Geometry

Introduction to Math and Geometry

Intuition

This chapter tackles several problems relating to important math and geometry concepts in programming, which regularly appear in technical interviews.

We explore subjects such as:

  • Greatest common divisor (GCD).
  • Modular arithmetic.
  • Handling floating-point precision.
  • Handling integer overflow and underflow.
  • Recognizing patterns.

Real-world Example

Computer graphics: In 3D rendering, geometry is used to represent objects as shapes like polygons, and mathematical transformations such as rotation, scaling, and translation, are applied to these objects to animate them, or change their perspective. Algorithms that use trigonometry, vector math, and matrix operations, are critical for determining how objects move, interact with light, or cast shadows in virtual environments.

Chapter Outline

Hierarchical diagram of Math and Geometry chapter: Math (Reverse 32-bit Integer, The Josephus Problem, Triangle Numbers) and Geometry (Spiral Traversal, Maximum Collinear Points).

Spiral Traversal

Spiral Traversal

Return the elements of a matrix in clockwise spiral order.

Example:

5x4 grid showing spiral traversal path with numbers 0-19.
python
Output: [0, 1, 2, 3, 4, 9, 14, 19, 18, 17, 16, 15, 10, 5, 6, 7, 8, 13, 12, 11]

Intuition

To create the expected output for this problem, let's try simulating exactly what the problem describes and traverse the matrix in spiral order, adding each value to the output as we go. How can we do this?

Spiral traversal involves moving through the matrix in one direction until we can't go any further, then changing direction and continuing. Specifically, the sequence of directions is right, down, left, and up, repeated until all elements are traversed. To achieve this, we need to determine the exact conditions for switching directions.

Initially, our approach may seem simple: we start by moving right until reaching the right-most column of the matrix, at which point we switch directions. We can move and switch directions like this three times without running into any problems:

Three 4x4 matrices showing rightward, downward, and leftward traversals.

However, as shown below, if we move upward until we hit the top row of the matrix, we'll return to where we started, adding a value from a previously visited cell to the output:

4x5 grid showing revisited cell at (0,0) when moving up.

A potential solution to this is to keep track of all cells visited by using a hash set. This allows us to stop moving in a direction when we encounter a visited cell. While this approach is effective, it requires O(m*n) space. Is there a way to avoid revisiting cells without using an additional data structure?

Notice in the above diagrams that when we move in a certain direction, we continue until we reach one of the boundary rows or columns (i.e., the top or bottom row, or the leftmost or rightmost column).

What if we adjust these boundaries as we traverse the matrix, to avoid revisiting previous cells?

Adjusting boundaries Let's initialize the four boundaries (top, bottom, left, right) with their initial positions:

  • top = 0
  • bottom = m - 1
  • left = 0
  • right = n - 1
4x5 matrix with left, right, top, bottom boundary labels.

We begin traversal by moving right through the first row from the left boundary to the right. Since we've just visited all cells in the first row, we need to prevent future access to this row. This can be done by moving the top boundary down by 1 (top += 1), ensuring the top row can't be accessed:

Before and after: top boundary moves down after traversing first row.

Next, we move down from the top boundary to the bottom boundary. To ensure this column is not revisited, update the right boundary (right -= 1):

Before and after: right boundary moves left after traversing rightmost column.

Next, we move left from the right boundary to the left. To ensure this row doesn't get revisited, update the bottom boundary (bottom -= 1):

Before and after: bottom boundary moves up after traversing bottom row.

Next, we move up from the bottom boundary to the top boundary. To ensure this column isn't revisited, update the left boundary (left += 1):

Before and after: left boundary moves right after traversing leftmost column.

We've just discussed how to traverse in each of the four directions and update the corresponding boundaries. These traversals are repeated until either the top boundary surpasses the bottom boundary, or the left boundary surpasses the right boundary. Either of these indicate there are no more cells left to traverse.

In summary, we traverse the matrix in spiral order by repeating the following sequences of traversals:

  1. Move from left to right along the top boundary, then update the top boundary (top += 1)
  2. Move from top to bottom along the right boundary, then update the right boundary (right -= 1)
  3. Move from right to left along the bottom boundary, then update the bottom boundary (bottom -= 1)
  4. Move from bottom to top along the left boundary, then update the left boundary (left += 1)

This continues while top <= bottom and left <= right.

A crucial thing to keep in mind is that after updating the top boundary, the top boundary might pass the bottom boundary (top > bottom). So, we need to check that top <= bottom before traversing the bottom boundary. Similarly, we need to check that left <= right before traversing the left boundary to ensure the boundaries haven't crossed.

As we move through the matrix, we add each value we encounter to the output array. This way, the matrix values are recorded in a spiral order.

Implementation

python
from typing import List
    
def spiral_matrix(matrix: List[List[int]]) -> List[int]:
    if not matrix:
        return []
    result = []
    # Initialize the matrix boundaries.
    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1
    # Traverse the matrix in spiral order.
    while top <= bottom and left <= right:
        # Move from left to right along the top boundary.
        for i in range(left, right + 1):
            result.append(matrix[top][i])
        top += 1
        # Move from top to bottom along the right boundary.
        for i in range(top, bottom + 1):
            result.append(matrix[i][right])
        right -= 1
        # Check that the bottom boundary hasn't passed the top boundary before
        # moving from right to left along the bottom boundary.
        if top <= bottom:
            for i in range(right, left - 1, -1):
                result.append(matrix[bottom][i])
            bottom -= 1
        # Check that the left boundary hasn't passed the right boundary before
        # moving from bottom to top along the left boundary.
        if left <= right:
            for i in range(bottom, top - 1, -1):
                result.append(matrix[i][left])
            left += 1
    return result

Complexity Analysis

Time complexity: The time complexity of spiral_matrix is O(m*n) because we traverse each cell of the matrix once.

Space complexity: The space complexity is O(1). The res array is not included in the space complexity.

Reverse 32-Bit Integer

Reverse 32-Bit Integer

Reverse the digits of a signed 32-bit integer. If the reversed integer overflows (i.e., is outside the range [-2^31, 2^31 - 1]), return 0. Assume the environment only allows you to store integers within the signed 32-bit integer range.

Example 1:

python
Input: n = 420
Output: 24

Example 2:

python
Input: n = -15
Output: -51

Intuition

The primary challenge with this problem is in handling its edge cases. Before tackling these edge cases, let's first try handling the more basic cases and later see how we would need to modify our strategy.

Reversing positive numbers Consider n = 123. Let's try building the reversed integer one digit at a time. The first thing to figure out is how to iterate through the digits of n to build our reversed number (initially set to 0):

n = 123, reversed_n = 0

One way to do this is by starting at the last digit of n and appending each digit to reversed_n:

Step-by-step: m=123->m=12->m=1, reversed_n=3->reversed_n=32->reversed_n=321

Let's explore how we can do this. To extract the last digit, we can use the modulus operator: n % 10. This operation effectively finds what the remainder of n would be if divided by 10:

n = 123, digit = n % 10 = 3

After extracting the last digit, we can remove it by dividing n by 10, which shifts the second-to-last digit to the last position, preparing it for the next iteration:

n = n // 10 = 12

Once that's done, let's add the last digit extracted to our reversed number:

reversed_n += digit (digit = 3)

To process the next digit, let's extract it from n using the modulus operation, then remove it by dividing n by 10:

digit = n % 10 = 2, n = n // 10 = 1

To append this digit to reversed_n, we can multiply reversed_n by 10 to shift its digits to the left, making space for the new digit. Then, we just add the new digit as before:

reversed_n = 10 * reversed_n + digit = 30 + 2 = 32

We can repeat the above process until all digits of n are appended to reversed_n (i.e., until n equals 0). Here's a breakdown of this process:

  1. Extract the last digit: digit = n % 10.
  2. Remove the last digit: n = n // 10.
  3. Append the digit: reversed_n = reversed_n * 10 + digit.

Reversing negative numbers Before considering a separate strategy to handle negative numbers, let's first check if the set of steps above also work for negative numbers. Applying these steps to n = -15 gives:

n=-15: digit = n%10 = -5, n = n//10 = -1, reversed_n = -5
n=-1: digit = -1, n = 0, reversed_n = -51

As we can see, it works for negative numbers. Now, let's tackle situations in which reversing a number could result in integer overflow or underflow.

Detecting integer overflow If the reverse of a positive number is larger than 2^31 - 1, it will overflow, and we should return 0. Let's call this maximum value INT_MAX.

INT_MAX = 2^31 - 1 = 2147483647

Initially, it might seem sufficient to reverse the number completely, check if it exceeds 2^31 - 1, and return 0 if it does. However, in an environment where integers larger than 2^31 - 1 cannot be stored, attempting to reverse such an integer would cause an overflow:

2199999999 reversed = 9999999912 > INT_MAX -> overflow

So, let's think of another way to detect overflow.

We're constructing the number reversed_n one digit at a time, which means we need to ensure not to cause the number to overflow with each new digit we add. Let's think about when adding a new digit might cause reversed_n to become too large. Here's how we can analyze this:

If reversed_n is equal to 214748364 (i.e., INT_MAX // 10), then the final digit we can add to it must be less than or equal to 7 to avoid an overflow (since 2147483647 == INT_MAX):

reversed_n=214748364, digit=7: result=2147483647=INT_MAX, no overflow
reversed_n=214748364, digit=8: result=2147483648 > INT_MAX -> overflow

Now, keep in mind that when reversed_n == INT_MAX // 10, only one more digit can be added to it. The key observation here is that this digit can only ever be 1 because a larger final digit would be impossible:

reversed_n=2147483641 implies n=1463847412 (valid); reversed_n=2147483642 would imply impossible input

This means that when reversed_n == INT_MAX // 10, the last digit added to it won't cause an overflow, meaning we don't need to check the last digit in this case.

If reversed_n is already larger than INT_MAX // 10, adding any digit will cause it to overflow. We can handle this case with the following condition:

if reversed_n > INT_MAX // 10: return 0

Detecting integer underflow We can apply similar logic to the above for handling integer underflow. Here, we just need to check that reversed_n never falls below INT_MIN // 10:

if reversed_n < INT_MIN // 10: return 0

Implementation

In Python, using the modulus operator (%) with a negative number gives a positive result. To avoid this, we can instead use math.fmod and cast its result to an integer to attain a negative modulus value.

For division, Python's // operator performs floor division, which can result in an undesired value when dealing with negative numbers (e.g., -15 // 10 results in -2, instead of the desired -1). To achieve the desired behavior, use / for division and cast its result to an integer.

python
import math
    
def reverse_32_bit_integer(n: int) -> int:
    INT_MAX = 2**31 - 1
    INT_MIN = -2**31
    reversed_n = 0
    # Keep looping until we've added all digits of 'n' to 'reversed_n' in reverse
    # order.
    while n != 0:
        # digit = n % 10
        digit = int(math.fmod(n, 10))
        # n = n // 10
        n = int(n / 10)
        # Check for integer overflow or underflow.
        if reversed_n > int(INT_MAX / 10) or reversed_n < int(INT_MIN / 10):
            return 0
        # Add the current digit to 'reversed_n'.
        reversed_n = reversed_n * 10 + digit
    return reversed_n

Complexity Analysis

Time complexity: The time complexity of reverse_32_bit_integer is O(log(n)) because we loop through each digit of n, of which there are roughly log(n) digits. As this environment only supports 32-bit integers, the time complexity can also be considered O(1).

Space complexity: The space complexity is O(1).

Maximum Collinear Points

Maximum Collinear Points

Given a set of points in a two-dimensional plane, determine the maximum number of points that lie along the same straight line.

Example:

Coordinate system with points (1,1),(1,3),(2,2),(3,1),(3,3),(4,4); line through (1,1),(2,2),(3,3),(4,4) highlighted.
python
Input: points = [[1, 1], [1, 3], [2, 2], [3, 1], [3, 3], [4, 4]]
Output: 4

Constraints:

  • The input won't contain duplicate points.

Intuition

Two or more points are collinear if they lie on the same straight line. In other words, the slope between any pair of points among them will be equal:

Coordinate system showing 4 collinear points with slope=1.

Let's remind ourselves how the slope of a line is calculated given two points (xa, ya) and (xb, yb):

slope = rise / run = (yb - ya) / (xb - xa)

Using this formula, we can calculate the slope between all pairs of points from the input, and determine the largest number of pairs that share the same slope. However, this approach is flawed because two pairs of points with the same slope value may not be collinear:

Two separate line segments both with slope=1 but not collinear.

Let's think of a different way to find the answer. What if we try to find the maximum number of points collinear with a specific point? Let's call this point a focal point.

We can calculate the slope between the focal point and every other point in the input, using a hash map to count how many points correspond with each slope value. Note that the frequencies of points stored in the hash map do not include the focal point.

Focal point (2,2), slope_map: {1: 3, -1: 2}

This allows us to find the maximum number of points collinear with this focal point:

Max frequency in slope_map is 3, so max collinear points = 3+1 = 4.

Here, the slope with the highest frequency is 3, which means the number of points on the line defined by that slope is 3 + 1 = 4, where +1 is used to account for the focal point itself.

By repeating the process for every point, we can determine the maximum number of points that are collinear with each focal point. Our final answer is equal to the largest of these maximums.

Edge case: two points on the same x-axis When two collinear points share the same x-axis, the denominator of the slope equation will equal 0. This is problematic because dividing by 0 is undefined:

Vertical line through (2,1) and (2,4): slope = 3/0 = undefined.

To handle this issue, we can check if our run value is equal to 0. If so, we can just use infinity (float('inf')) to represent the value of this slope.

Avoiding precision issues A crucial thing to be mindful of is the precision of slopes when storing them as floats or doubles. Consider the following slopes:

499999999/500000000 and 499999998/499999999 both round to 0.999999998 in float but are not equal.

As we can see, despite the fractions themselves representing different slopes, presenting them as a float or double doesn't provide enough decimal-point precision to distinguish between them accurately. This can result in incorrectly identifying distinct slopes as the same.

To avoid this, we can represent a slope as a pair of integers, stored as a tuple: (rise, run). For example, the slope with a rise of 1 and a run of 2 can be represented as (1, 2) instead of 1 / 2 = 0.5.

Ensuring consistent representation of slopes There's just one more challenge to address: ensuring the representation of a slope is consistent for all equivalent slope fractions:

Fractions 1/2, 2/4, 13/26 all represent the same value.

If we reduce fractions to their simplest forms, we can consistently represent equal fractions that have different initial representations.

1/2, 2/4, 13/26 all reduce to 1/2.

But how can we do this? We can reduce fractions by dividing both the rise and run by their greatest common divisor (GCD).

Greatest common divisor The GCD of two numbers is the largest number that divides both of them exactly. By dividing the rise and run by their GCD, we reduce the slope to its simplest form:

(rise, run) = (13, 26) / gcd(13,26)=13 -> (1, 2)

This ensures all equal fractions are represented in the same way.

Implementation

python
from typing import List, Tuple
from collections import defaultdict
    
def maximum_collinear_points(points: List[List[int]]) -> int:
    res = 0
    # Treat each point as a focal point, and determine the maximum number of points
    # that are collinear with each focal point. The largest of these maximums is the
    # answer.
    for i in range(len(points)):
        res = max(res, max_points_from_focal_point(i, points))
    return res
    
def max_points_from_focal_point(focal_point_index: int, points: List[List[int]]) -> int:
    slopes_map = defaultdict(int)
    max_points = 0
    # For the current focal point, calculate the slope between it and every other
    # point. This allows us to group points that share the same slope.
    for j in range(len(points)):
        if j != focal_point_index:
            curr_slope = get_slope(points[focal_point_index], points[j])
            slopes_map[curr_slope] += 1
            # Update the maximum count of collinear points for the current focal
            # point.
            max_points = max(max_points, slopes_map[curr_slope])
    # Add 1 to max_points to account for the focal point itself.
    return max_points + 1
    
def get_slope(point_a: List[int], point_b: List[int]) -> Tuple[int, int]:
    rise = point_b[1] - point_a[1]
    run = point_b[0] - point_a[0]
    # Handle vertical lines.
    if run == 0:
        return (1, 0)
    # Reduce the slope fraction to its simplest form.
    common_divisor = gcd(abs(rise), abs(run))
    # Normalize the sign of the slope.
    sign = -1 if (rise < 0) != (run < 0) else 1
    return (sign * abs(rise) // common_divisor, abs(run) // common_divisor)
    
def gcd(a: int, b: int) -> int:
    while b != 0:
        a, b = b, a % b
    return a

While some programming languages, Python included, have their own internal implementation of the GCD function, its implementation is provided below for your information. This implementation is commonly known as the Euclidean algorithm:

python
# The Euclidean algorithm.
def gcd(a, b):
    while b != 0:
        a, b = b, a % b
    return a

Complexity Analysis

Time complexity: The time complexity of maximum_collinear_points is O(n^2 * log(m)), where n denotes the number of points, and m denotes the largest value among the coordinates.

  • The time complexity of the gcd(rise, run) function is O(log(min(rise, run))), which is approximately equal to O(log(m)) in the worst case.
  • The helper function max_points_from_focal_point, calls the gcd function a total of n-1 times: one for each point excluding the focal point, giving a time complexity of O(n log(m)).
  • The max_points_from_focal_point function is called a total of n times, resulting in an overall time complexity of O(n^2 log(m)).

Space complexity: The space complexity is O(n) due to the hash map, which in the worst case, stores n-1 key-value pairs: one for each point excluding the focal point.

The Josephus Problem

The Josephus Problem

There are n people standing in a circle, numbered from 0 to n - 1. Starting from person 0, count k people clockwise and remove the kth person. After the removal, begin counting again from the next person still in the circle. Repeat this process until only one person remains, and return that person's position.

Example:

Step-by-step: 5 people in circle, k=2, eliminating every 2nd person until only person 2 remains.
python
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.

This approach takes O(n*k) time because, to remove a node, we must iterate through k nodes in the linked list. Therefore, for each of the n nodes, we perform k iterations. Let's see if we can find a faster solution.

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.

12 people in circle, start at 0, k=4, remove person 3.
11 people remaining, new start at person 4.

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.

To account for the adjusted start position, we need to add k to the answer returned by josephus(n - 1, k). This is because, in this subproblem, it won't know to start counting from position 4: it will, by default, count from position 0. So, adding k to this subproblem's result accounts for this difference in the starting position.

This can be expressed with the following recurrence relation:

josephus(n, k) = josephus(n - 1, k) + k

The final consideration is ensuring the value of josephus(n - 1, k) + k doesn't exceed n - 1, as this represents the position of the last person. We can achieve this by applying the modulus operator (% n) to josephus(n - 1, k) + k. This results in the following updated recurrence relation:

josephus(n, k) = (josephus(n - 1, k) + k) % n

Now, all we need is a base case.

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

python
def josephus(n: int, k: int) -> int:
    # Base case: If there's only one person, the last person is person 0.
    if n == 1:
        return 0
    # Calculate the position of the last person remaining in the reduced problem
    # with 'n - 1' people. We use modulo 'n' to ensure the answer doesn't exceed
    # 'n - 1'.
    return (josephus(n - 1, k) + k) % n

Complexity Analysis

Time complexity: The time complexity of josephus is O(n) because we make a total of n recursive calls to this function until we reach the base case.

Space complexity: The space complexity is O(n) due to the recursive call stack, which grows up to a depth of n.

Optimization

We can implement the top-down recursive solution above using a bottom-up iterative approach. Let res represent an array that stores the solution to each subproblem, where res[i] contains the solution to the subproblem of an i-person circle. Using this array, our formula becomes:

res[i] = (res[i - 1] + k) % i

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

python
def josephus_optimized(n: int, k: int) -> int:
    res = 0
    for i in range(2, n + 1):
        # res[i] = (res[i - 1] + k) % i.
        res = (res + k) % i
    return res

Complexity Analysis

Time complexity: The time complexity of josephus_optimized is O(n) because we iterate through n subproblems.

Space complexity: The space complexity is O(1).

Triangle Numbers

Triangle Numbers

Consider a triangle composed of numbers where the top of the triangle is 1. Each subsequent number in the triangle is equal to the sum of three numbers above it: its top-left number, its top number, and its top-right number. If any of these three numbers don't exist, assume they are equal to 0.

Given a value representing a row of this triangle, return the position of the first even number in this row. Assume that the first number in each row is at position 1.

Example:

Triangle with 4 rows: row 4 = [1,3,6,7,6,3,1]; first even (6) is at position 3.
python
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.

Simplifying the triangle The first key observation is that the triangle is symmetric. This means we can exclude the right half of the triangle because if an even number exists in the right half, it definitely exists in the left half:

Symmetric triangle with vertical lines showing left half is sufficient.

To more easily visualize the positions of the numbers in each row, let's draw it such that numbers belonging to the same position are aligned:

5x5 matrix: rows 1-5 aligned by position showing values.

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:

5x5 binary matrix: 1=odd, 0=even (peach circles).

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:

Rows 3-5 showing first even at positions: row3=2, row4=3, row5=2.

From rows 3 to 5, one possible pattern to observe is that odd-numbered rows have the first even number at position 2. We could also hypothesize that even-numbered rows have the first even at position 3.

To confirm if this observation is consistent, let's look at some more rows:

7x7 matrix: odd rows have first even at pos 2, even rows vary.

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:

10x10 matrix: rows 3-6 pattern repeats for rows 7-10.

Now, we notice that the first four binary values from rows 3 to 6 repeat for rows 7 to 10. If we were to continue for future rows, we would notice that this pattern continues.

Essentially, the following pattern is consistently repeated, starting from row 3:

4x4 repeating unit (rows 3-6): first-even positions are 2, 3, 2, 4.

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:

Four arrays showing how 1,2,1 pattern generates predictable next row.

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 entire pattern, we can conclude that since the pattern repeated once (from rows 3 to 6 to rows 7 to 10), it will continue to repeat indefinitely.

This gives us the below cyclic rationale:

  • If n is odd (n % 2 != 0), return 2.
  • If n is a multiple of 4 (n % 4 == 0), return 3.
  • Else, return 4.
Three matrices: odd rows->pos2, multiples of 4->pos3, other rows->pos4.

This problem demonstrates how recognizing patterns and simplifying the problem can turn a time-consuming solution into a quick, constant-time one.

Implementation

python
def triangle_numbers(n: int) -> int:
    # If n is an odd-numbered row, the first even number always starts at position
    # 2.
    if n % 2 != 0:
        return 2
    # If n is a multiple of 4, the first even number always starts at position 3.
    elif n % 4 == 0:
        return 3
    # For all other rows, the first even number always starts at position 4.
    return 4

Complexity Analysis

Time complexity: The time complexity of triangle_numbers is O(1).

Space complexity: The space complexity is O(1).